Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
cebe9bf
Add design spec: Keep the metadata branch on a local remote with neut…
danielhanold Oct 6, 2026
fa13eba
feat(layout): resolve per-repo spellings and visibility mode from state
danielhanold Oct 6, 2026
d372d92
refactor: resolve the per-repo state folder through layout (shared be…
danielhanold Oct 6, 2026
96bff81
refactor(app): thread the resolved metadata remote and branch through…
danielhanold Oct 6, 2026
b18d1ee
refactor(repository): resolve metadata remote, branch, and worktree t…
danielhanold Oct 6, 2026
690a9a1
feat(config): add the visibility key and the private .git/dckt/config…
danielhanold Oct 6, 2026
b81e947
feat(gitcli): add InitBare and AddRemote for the private metadata remote
danielhanold Oct 6, 2026
b88bbee
feat(reposetup): manage a neutral # dckt: block in .git/info/exclude
danielhanold Oct 6, 2026
ea09940
feat(repository): init --private publishes the metadata branch to a l…
danielhanold Oct 6, 2026
7a9245a
feat(repository): private-aware check/prepare/repair with visibility-…
danielhanold Oct 6, 2026
b9b454e
test(app): extract the claim-to-implemented driver and name the metad…
danielhanold Oct 6, 2026
2bb631a
test(app): private-visibility acceptance — lifecycle, concurrent clai…
danielhanold Oct 6, 2026
78c923a
docs(skills): key metadata worktree and remote on the prepare context…
danielhanold Oct 6, 2026
ebe95a7
fix(repository): private init re-run adopts the published dckt branch…
danielhanold Oct 6, 2026
827f9a2
fix(repository): a same-path private re-clone replaces its stale chec…
danielhanold Oct 6, 2026
7d6cffc
fix(repository): a flagless private init re-run keeps the --metadata-…
danielhanold Oct 6, 2026
471a29c
fix(repository): private init refuses beside a .docket.local.yml befo…
danielhanold Oct 6, 2026
50750d7
fix(repository): private health remedies name prepare, not the refusi…
danielhanold Oct 6, 2026
8d9ed9b
fix(repository): private init refuses a default store recorded for a …
danielhanold Oct 6, 2026
c4d4e12
fix(private): absolutize a local --metadata-remote; skills say the me…
danielhanold Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions .docket.example.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@
# 2. committed <repo>/.docket.yml (every clone)
# 3. global ${XDG_CONFIG_HOME:-$HOME/.config}/docket/config.yml (every repository on this machine)
# 4. built-in the defaults compiled into docket
# A private repository (`.git/dckt/` present; see `visibility` below) reads `.git/dckt/config.yml`
# in place of layers 1 and 2, honoring the repo-only keys there; it never reads `.docket.yml`, and
# it refuses while a `.docket.local.yml` also exists.
# Nested blocks merge key by key: setting one key in a block leaves its other keys resolving from
# the layers below. A malformed file, an unknown key, or a bad value in any layer makes the whole
# configuration invalid, and docket refuses to change the repository until it is fixed.
Expand Down Expand Up @@ -43,6 +46,16 @@
# scope: repo-only (shared-setting guarded)
integration_branch: auto

# visibility — where docket keeps this repository's planning data. `shared` (the default) keeps it
# on a `docket` branch pushed to origin, beside a `.docket/` worktree, so everyone with the
# repository sees the backlog. `private` keeps it on your machine only: a `dckt` branch pushed to a
# bare repository under ~/.local/share/dckt, with nothing docket-named inside the repository — for
# repositories that must not carry any docket files. `docket repository init` sets a new
# repository up in this mode (`--private` / `--shared` override it); changing it later does not
# move a repository that is already set up.
# scope: any layer
visibility: shared

# changes_dir / adrs_dir / results_dir — where the change files (and the board), the ADR ledger,
# and the results records live on the `docket` branch, relative to its root. Each must be a clean
# relative path inside the repository.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
# Keep the metadata branch on a local remote with neutral naming: design

Change 0531 — Keep the metadata branch on a local remote with neutral naming

Change #531, groomed interactively on 2026-10-05. Second in the private-visibility series (#529–#533). It builds in parallel with #530; #532 and #533 depend on both.

## Summary

Some host repositories forbid a `docket` branch, root-level tool files, or any docket-specific content, yet one user should still be able to run docket there, privately, on their own machine. This change adds a **private** visibility. The repository keeps docket's one metadata layout (ADR-0099: an orphan metadata branch reached through a metadata worktree), but:

- the metadata branch is pushed to a **bare repository on the user's machine** instead of `origin`;
- every per-repo name docket writes is spelled **`dckt`**;
- **nothing** docket-named sits in the repository root.

After `docket repository init --private`, a private clone looks like this:

```
repo/
├── .git/
│ ├── config # remote "dckt" → the bare repo
│ ├── info/exclude # "# dckt:start" block holding .worktrees/
│ └── dckt/ # config.yml + run tracker, transactions, workspaces, gate drives, …
├── .worktrees/ # feature worktrees (neutral name)
└── src/ …

git branch → main, dckt, feat/… git remote → origin, dckt

${XDG_DATA_HOME:-~/.local/share}/dckt/<owner>-<repo>/
├── remote.git/ # bare metadata remote
└── checkouts/<clone-id>/ # the metadata worktree
```

## Evidence gathered at grooming

- **Remote.** `const originRemote gitcli.RemoteName = "origin"` (`internal/app/status_git.go`) has 57 non-test uses across 28 files. `setupRemote()` (`internal/app/repository_facts.go`) returns it, and its comment says "minting a remote config key is out of scope". Metadata uses include:
- every `transaction.Engine` request (about 27 call sites);
- `loadOperationalContext` → `fetchPinnedRevision` (every read, including `run.start` and `run.verdict`);
- the sweep session;
- `gatherRepoFacts`;
- the check and prepare augments;
- `publishOrAdoptMetadataRoot` (init);
- migrate (5 sites) and repair (2 sites).
- **Code-side uses stay on `origin`:** default-branch resolution, integration fetches, workspace publish/rewrite, finalize, cleanup, maintenance, and the `run.verify` feature-head probe.
- **Locking.** The engine's lease push (`--force-with-lease`) is the only thing serializing metadata writers, and no local lock exists. A lease push to a path remote takes the bare repository's ref lock atomically, so the compare-and-swap holds for concurrent local loops. Removing the remote entirely would need a new local compare-and-swap, which was rejected.
- **Remote names.** `validateRemoteName` (`internal/gitcli/types.go`) requires a configured remote (`git remote get-url`), and `GIT_CONFIG*` are scrubbed. The metadata remote must therefore be a real remote in `.git/config`.
- **Branch name.** `reposetup.MetadataBranchName = "docket"` (`internal/reposetup/branch.go`) is the single spelling. Metadata commit receipts (`Docket-Result`) don't record the branch name; one was decoded at grooming to `{"id","op","path","slug"}`.
- **Worktree.** `docketWorktreeName = ".docket"` (`internal/app/repository_facts.go`), `initplan.go`, and the health refs. Skills carry 18 literal `.docket/` references in 9 files, plus `origin/docket` assertions (implement-next's `.docket` HEAD equals `origin/docket` check, and docket-adr's "push `origin/docket`").
- **State folder.** `filepath.Join(<common-dir>, "docket", …)` appears in about 20 places across 10 files: run tracker, run-tracker resume, agent guardian, transactions, workspaces manifest, gate drives/scopes/budgets/worktree locks, suite budget state, empty hooks, and the install record.
- **Config.** `config.LoadFilesystemSources` (`internal/config/fs.go`) reads `.docket.yml`, `.docket.local.yml`, and the global file. Operational commands read `.docket.yml` from the pinned origin default-branch blob (`readPinnedOptionalBlob` in `operational_context.go`). The shared-setting guard (`applySharedSettingGuard` / `isMachineLayer` in `internal/config/resolve.go`) ignores repository-identity keys in machine layers.
- **Ignore block.** `reposetup.EnsureGitignoreBlock` writes the managed `.gitignore` block. `CondCommittedIgnoreValid` requires it to be **committed**, and `.git/info/exclude` is not accepted today.
- **Data root.** The install's data root resolves `XDG_DATA_HOME` (`internal/install/roots.go`).

## Decisions (settled with the human)

1. **`visibility: shared | private` is an ordinary config key.** It can be set in any layer with normal precedence (repo-local > committed > global > built-in `shared`). The human rejected any special scoping twice: config keys must behave uniformly.
2. **`init` uses the effective value, and flags override it.** `docket repository init` sets the repository up in the effective mode; `--private` and `--shared` override it. A global `visibility: private` therefore makes new repositories on that machine private, and a repository's own file overrides that.
3. **A repository's actual mode is its state, not its config.** A `.git/dckt/` folder means private. Editing config never moves a repository; #533's `set-visibility` does that. When a **repository-level** file states a different mode than the state, `repository check` reports it (report-only). A global value never triggers that finding.
4. **Every per-repo name docket writes is spelled `dckt` in private mode:** the state folder, the config file location, the metadata branch, the git remote, the bare-remote and checkout store, and the exclude-block markers. Not renamed: the `docket` binary, its install paths, `~/.config/docket/config.yml`, agent files, and `Docket-*` trailers inside metadata commits.
5. **The bare remote lives outside the clone,** at `${XDG_DATA_HOME:-~/.local/share}/dckt/<owner>-<repo>/remote.git`, named from origin's URL. It survives re-cloning, and two clones of one repository share one backlog. `--metadata-remote <url>` points it at any git URL instead, for example a private personal repository for backup. The `dckt` git remote's URL *is* the setting; there is no `metadata_remote` config key.
6. **The metadata worktree lives outside the clone** at `…/<owner>-<repo>/checkouts/<clone-id>/`. `<clone-id>` is the clone's folder name plus the first 8 hex characters of sha256 of the clone's absolute path, for example `api-3f9c2a1b`. It is stateless: a moved clone gets a fresh checkout, and the old one is reported and pruned. Nothing is lost, because every write lives in the bare remote.
7. **Private config comes from `.git/dckt/config.yml` plus the global file.** A committed `.docket.yml` is never read, and repository-identity keys are honored from `.git/dckt/config.yml`. With a single clone the guard's rationale (every clone must agree) does not apply; this relates to ADR-0019. If both `.docket.local.yml` and `.git/dckt/config.yml` exist, docket refuses and names both paths.
8. **Sparse documentation.** The private feature is documented only in `.docket.example.yml` (and its embedded twin), command help, and skills. There are no `docs/` pages.

## Design

### 1. The `visibility` key

- A schema row: enum `shared|private`, default `shared`, all layers, ordinary precedence. `docket diagnostic config` shows the winning layer.
- `.docket.example.yml` documents it in two or three lines. Its correspondence guard requires that.
- A new finding (report-only) for a repository-level file that disagrees with the repository's state. Its remedy names `docket repository set-visibility <mode>`, which arrives with #533.

### 2. Mode detection and resolved values

One resolver decides the mode from state: `.git/dckt/` exists → private. Four resolved values replace the hard-coded spellings:

| Value | Shared | Private |
|---|---|---|
| metadata remote | `origin` | `dckt` |
| metadata branch | `docket` | `dckt` |
| state folder | `<git-common-dir>/docket/` | `<git-common-dir>/dckt/` |
| metadata worktree | `<primary>/.docket` | `<data>/dckt/<owner>-<repo>/checkouts/<clone-id>/` |

Rules for the replacement:

- Derive every replacement site from a whole-repository grep (`originRemote`, `setupRemote`, `MetadataBranchName`, `docketWorktreeName`, `Join(… "docket" …)` under the common dir), never from this list.
- Sort each site into metadata versus code-side. Only metadata sites change; code-side remote work stays on `origin`.
- `fetchPinnedRevision`'s metadata fetch is split from the default and integration fetches.
- `repository.prepare`'s context gains `metadata_remote` (it already returns `metadata_branch` and `metadata_worktree_path`), so skills key on context values rather than spellings.
- `<owner>-<repo>` is derived from origin's URL: lowercase, with non-alphanumeric runs collapsed to `-`.

### 3. `docket repository init --private`

The steps run in an order that keeps `git status` clean throughout:

1. Write the `# dckt:start` / `# dckt:end` block into `.git/info/exclude`, holding `.worktrees/`.
2. Create `.git/dckt/config.yml` with `visibility: private` plus the discovered test policy (what `ensureTestPolicyConfig` writes to `.docket.yml` today).
3. Create the bare remote (`git init --bare`) at the default path, or use `--metadata-remote <url>`. If it already holds a `dckt` branch (a second clone, or an earlier private stint), adopt it.
4. Add the `dckt` remote, or verify that an existing one points at the same URL.
5. Create the orphan `dckt` branch with a create-only lease push, or adopt the existing branch.
6. Attach the metadata worktree at the resolved checkout path, with worktree-scoped hooks disabled under `.git/dckt/`.

Private init never writes `.gitignore`, `.docket.yml`, `.docket.local.yml`, or any file in the worktree root. Shared init (`--shared`, or the default) is unchanged. `repository configure-tests` writes to `.git/dckt/config.yml` in private repositories.

### 4. Config in private repositories

The sources are built-in defaults, the global file, and `.git/dckt/config.yml`. The last one acts as the repository layer and honors repository-identity keys. A committed `.docket.yml` is never read, neither from the origin default-branch blob nor from disk. The global layer still cannot set repository-identity keys. Agent model and effort pins remain global-only, exactly as today. Two local config files (`.docket.local.yml` and `.git/dckt/config.yml`) is a configuration error naming both, with the same posture as a malformed file.

### 5. Check, prepare, repair

- **Bootstrap guard.** The 2×2 guard probes the metadata branch on the resolved remote. Legacy detection on the integration branch is unchanged.
- **Health.**
- metadata branch present on the resolved remote;
- metadata worktree at the resolved path;
- in private mode, the ignore block present in `.git/info/exclude`, satisfying the condition the committed `.gitignore` block satisfies in shared mode;
- no pending-review requirement for `.docket.yml` in private mode.
- **Orphaned checkouts.** A checkout whose clone moved or vanished is reported, and `repository repair` prunes it.
- **The mismatch finding** from section 1.

### 6. Links

After #530, metadata links are relative, which is what private mode needs: there is no GitHub URL for a private metadata branch. Any remaining metadata `BlobURLOnBranch` use is relative in private mode. PR rows stay absolute, because the PR is on GitHub.

### 7. Skills and agents

Replace the 18 literal `.docket/` references and every `origin/docket` spelling with the prepare-context values (`metadata_worktree_path`, `metadata_remote`, `metadata_branch`, `metadata_branch_revision`). This includes:

- implement-next's metadata-HEAD assertion;
- docket-adr's commit-and-push instruction;
- the convention's bootstrap-guard and branch-model prose, which describe the shared layout as the default and the resolved values as authoritative.

The embedded twins change in step, and the prose-contract pins are updated. Skills mention private mode only where behavior differs.

### 8. Documentation

`.docket.example.yml` (plus its twin) and command help only. No `docs/` page mentions private visibility.

## Acceptance criteria

1. **Integration test** against a temporary bare "origin": `init --private`, then create → groom → claim → plan → build → PR (fake GitHub) → mark-implemented.
- Origin gains only the feature branch.
- `git status --porcelain --ignored` in the root shows nothing docket-named; `.worktrees/` appears only as ignored.
- `git branch` and `git remote` show `dckt`.
- No path under the clone contains `docket`.
2. **Concurrency.** Two concurrent claims of one change in a private repository: exactly one wins, and the other reports `contended` (the lease push to the bare remote is the compare-and-swap).
3. **Global default.** With global `visibility: private`, an already-set-up shared repository keeps running shared with no finding. A new repository's `init` is private. `--shared` and a repository file saying `shared` both override.
4. **Refusals and findings.** Both local config files present: refusal naming both. A repository file contradicting state: mismatch finding. A moved clone: orphaned-checkout finding, then `repair` prunes it.
5. **Shared mode unchanged.** The shared-mode suite is unchanged and green.
6. **Mutation tests.** Hard-wiring any resolver to its shared spelling turns a private-mode test red.
7. **Example config.** `.docket.example.yml` documents `visibility`, and the correspondence guard passes.

The plan may split this into 2a (remote, branch, worktree resolvers) and 2b (config, state folder, init/check/prepare) if the PR grows too large.

## ADRs expected

- Private visibility keeps the single metadata layout and varies only where the metadata branch is published and how per-repo paths are spelled. Relates to ADR-0099 and ADR-0001.
- Repository-identity keys come from the private repository's local config when there is a single clone. Relates to ADR-0019.

## Out of scope

- Keeping docket out of PRs, commits, and shipped files: writing rules and the leak check (#532).
- Switching an existing repository's mode (#533).
- Renaming the binary, install paths, global config path, agent files, or metadata-commit trailers.
- Branch-name templates, fork workflows, and repositories without PR permission.
9 changes: 4 additions & 5 deletions internal/app/adr_ops.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import (
"github.com/danielhanold/docket/internal/domain"
"github.com/danielhanold/docket/internal/gitcli"
"github.com/danielhanold/docket/internal/render"
"github.com/danielhanold/docket/internal/reposetup"
"github.com/danielhanold/docket/internal/repository"
"github.com/danielhanold/docket/internal/repository/transaction"
)
Expand Down Expand Up @@ -195,8 +194,8 @@ func ADRRecordOp(ctx context.Context, deps PlanningDeps, repoDir string, req ADR

txReq := transaction.Request{
Repository: repo,
Remote: originRemote,
TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName),
Remote: metadataRemote(pin.Layout),
TargetRef: metadataRef(pin.Layout),
Idempotency: &transaction.IdempotencyKey{RequestID: req.RequestID, Digest: digest},
Loader: newPlanningLoader(eff),
Scope: adrScope("", adrProducingChangePaths(req.Change), path.Join(eff.ADRsDir.Value, "README.md")),
Expand Down Expand Up @@ -714,8 +713,8 @@ func adrReplace(ctx context.Context, deps PlanningDeps, repoDir, opKey string, r

res, execErr := deps.Engine.Execute(ctx, transaction.Request{
Repository: repo,
Remote: originRemote,
TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName),
Remote: metadataRemote(pin.Layout),
TargetRef: metadataRef(pin.Layout),
Idempotency: &transaction.IdempotencyKey{RequestID: req.RequestID, Digest: digest},
Loader: newPlanningLoader(eff),
Scope: adrScope(req.Target.Path, adrProducingChangePaths(req.Successor.Change), path.Join(eff.ADRsDir.Value, "README.md")),
Expand Down
8 changes: 4 additions & 4 deletions internal/app/agent_guardian.go
Original file line number Diff line number Diff line change
Expand Up @@ -248,18 +248,18 @@ func (h *GuardianHandle) Complete() {

// markerPathFor returns the durable completion-marker path for a run, beside the
// run's run-key directory so it shares that record's lifetime and 0700 privacy.
func markerPathFor(gitCommonDir, runKey string) string {
return filepath.Join(gitCommonDir, "docket", runTrackerDirName, runKey, "owner-complete.marker")
func markerPathFor(stateDir, runKey string) string {
return filepath.Join(stateDir, runTrackerDirName, runKey, "owner-complete.marker")
}

// AgentGuardianMarkerPath resolves the completion-marker path a guardian for
// (repoDir, runKey) watches, beside the run's run-key directory. The CLI passes
// it to SpawnAgentGuardian so the owner and the guardian agree on the one file
// whose presence distinguishes a clean end from an abrupt death.
func AgentGuardianMarkerPath(repoDir, runKey string) (string, error) {
common, err := runTrackerGitCommonDir(repoDir)
state, err := runTrackerStateDir(repoDir)
if err != nil {
return "", err
}
return markerPathFor(common, runKey), nil
return markerPathFor(state, runKey), nil
}
Loading
Loading