From cebe9bfaea7d2ffac351ffb5796964bd1925bc06 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 06:06:48 -0400 Subject: [PATCH 01/20] Add design spec: Keep the metadata branch on a local remote with neutral naming --- ...metadata-branch-on-a-local-remot-design.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-05-private-visibility-keep-the-metadata-branch-on-a-local-remot-design.md diff --git a/docs/superpowers/specs/2026-10-05-private-visibility-keep-the-metadata-branch-on-a-local-remot-design.md b/docs/superpowers/specs/2026-10-05-private-visibility-keep-the-metadata-branch-on-a-local-remot-design.md new file mode 100644 index 000000000..c9bb5e037 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-private-visibility-keep-the-metadata-branch-on-a-local-remot-design.md @@ -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/-/ +├── remote.git/ # bare metadata remote +└── checkouts// # 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(, "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/-/remote.git`, named from origin's URL. It survives re-cloning, and two clones of one repository share one backlog. `--metadata-remote ` 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 `…/-/checkouts//`. `` 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 `, 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 | `/docket/` | `/dckt/` | +| metadata worktree | `/.docket` | `/dckt/-/checkouts//` | + +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. +- `-` 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 `. 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. From fa13ebaeff6e23a7499824830e647ba5f8da2c74 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 06:40:45 -0400 Subject: [PATCH 02/20] feat(layout): resolve per-repo spellings and visibility mode from state --- internal/layout/layout.go | 240 +++++++++++++++++++++++++++++++++ internal/layout/layout_test.go | 192 ++++++++++++++++++++++++++ 2 files changed, 432 insertions(+) create mode 100644 internal/layout/layout.go create mode 100644 internal/layout/layout_test.go diff --git a/internal/layout/layout.go b/internal/layout/layout.go new file mode 100644 index 000000000..9913fdaf3 --- /dev/null +++ b/internal/layout/layout.go @@ -0,0 +1,240 @@ +// Package layout owns every per-repo spelling docket writes and decides the +// visibility mode from STATE (a /dckt/ directory means private). +// +// It is a stdlib-only leaf: it imports no internal package, because gitcli and +// config import it. +package layout + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// Mode is a repository's visibility mode. +type Mode string + +const ( + Shared Mode = "shared" + Private Mode = "private" + + SharedName = "docket" + PrivateName = "dckt" + SharedRemote = "origin" + SharedWorktreeDir = ".docket" + PrivateConfigFile = "config.yml" + PrivateConfigDisplay = ".git/dckt/config.yml" +) + +// Detect decides the mode from state; a probe error is returned, never guessed. +func Detect(commonDir string) (Mode, error) { + p := filepath.Join(commonDir, PrivateName) + fi, err := os.Lstat(p) + switch { + case err == nil && fi.IsDir(): + return Private, nil + case err == nil: + return "", fmt.Errorf("layout: %s exists but is not a directory", p) + case errors.Is(err, fs.ErrNotExist): + return Shared, nil + default: + return "", fmt.Errorf("layout: probing %s: %w", p, err) + } +} + +// StateName serves path helpers with no error channel. It fails CLOSED: +// anything present (or unprobeable) at /dckt selects the private name, +// so a write beneath it fails loudly instead of landing in a docket-named +// folder. Mode-deciding callers use Detect. +func StateName(commonDir string) string { + if _, err := os.Lstat(filepath.Join(commonDir, PrivateName)); errors.Is(err, fs.ErrNotExist) { + return SharedName + } + return PrivateName +} + +// StateDirOf is the per-repo state folder beneath the git common dir. +func StateDirOf(commonDir string) string { return filepath.Join(commonDir, StateName(commonDir)) } + +// PrivateConfigPath is the private repository-layer config file. +func PrivateConfigPath(commonDir string) string { + return filepath.Join(commonDir, PrivateName, PrivateConfigFile) +} + +// CommonDirOf resolves a working-tree root's common dir from the filesystem +// alone (no git process). ok=false when /.git does not exist. +// +// A /.git directory is the common dir. A .git file names a gitdir on its +// first "gitdir:" line (relative paths resolve against root); that gitdir's +// commondir file (relative paths resolve against the gitdir) names the common +// dir, and a gitdir with no commondir file (a submodule) is its own common dir. +// Every read error other than not-exist is returned. +func CommonDirOf(root string) (string, bool, error) { + dotGit := filepath.Join(root, ".git") + fi, err := os.Stat(dotGit) + if errors.Is(err, fs.ErrNotExist) { + return "", false, nil + } + if err != nil { + return "", false, fmt.Errorf("layout: probing %s: %w", dotGit, err) + } + if fi.IsDir() { + return dotGit, true, nil + } + + gitdir, err := readGitdirLine(dotGit) + if err != nil { + return "", false, err + } + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(root, gitdir) + } + gitdir = filepath.Clean(gitdir) + + raw, err := os.ReadFile(filepath.Join(gitdir, "commondir")) + if errors.Is(err, fs.ErrNotExist) { + return gitdir, true, nil + } + if err != nil { + return "", false, fmt.Errorf("layout: reading commondir of %s: %w", gitdir, err) + } + common := strings.TrimSpace(string(raw)) + if common == "" { + return "", false, fmt.Errorf("layout: %s/commondir is empty", gitdir) + } + if !filepath.IsAbs(common) { + common = filepath.Join(gitdir, common) + } + return filepath.Clean(common), true, nil +} + +// readGitdirLine returns the path on the first "gitdir:" line of a .git file. +func readGitdirLine(dotGit string) (string, error) { + f, err := os.Open(dotGit) + if err != nil { + return "", fmt.Errorf("layout: reading %s: %w", dotGit, err) + } + defer f.Close() + sc := bufio.NewScanner(f) + for sc.Scan() { + if rest, ok := strings.CutPrefix(sc.Text(), "gitdir:"); ok { + if p := strings.TrimSpace(rest); p != "" { + return p, nil + } + break + } + } + if err := sc.Err(); err != nil { + return "", fmt.Errorf("layout: reading %s: %w", dotGit, err) + } + return "", fmt.Errorf("layout: %s names no gitdir", dotGit) +} + +// DataHome is ${XDG_DATA_HOME:-$HOME/.local/share}; XDG_DATA_HOME counts only +// when absolute (the installer's rule in install.ResolveRoots). No XDG and a +// home error (or empty home) → error. +func DataHome(getenv func(string) string, home func() (string, error)) (string, error) { + if x := getenv("XDG_DATA_HOME"); x != "" && filepath.IsAbs(x) { + return x, nil + } + h, err := home() + if err != nil { + return "", fmt.Errorf("layout: resolving the home directory: %w", err) + } + if h == "" { + return "", errors.New("layout: resolving the home directory: empty home") + } + return filepath.Join(h, ".local", "share"), nil +} + +// OwnerRepo derives - from origin's URL: the last two segments +// (split on / : \, trailing "/" and ".git" removed), lowercased, with +// non-alphanumeric runs collapsed to "-". +func OwnerRepo(originURL string) (string, error) { + s := strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(originURL), "/"), ".git") + parts := strings.FieldsFunc(s, func(r rune) bool { return r == '/' || r == ':' || r == '\\' }) + if len(parts) > 2 { + parts = parts[len(parts)-2:] + } + var b strings.Builder + dash := false + for _, r := range strings.ToLower(strings.Join(parts, "-")) { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + b.WriteRune(r) + dash = false + } else if !dash && b.Len() > 0 { + b.WriteByte('-') + dash = true + } + } + slug := strings.TrimRight(b.String(), "-") + if slug == "" { + return "", fmt.Errorf("layout: cannot derive an owner/repo name from origin URL %q", originURL) + } + return slug, nil +} + +// CloneID is -; the basename keeps +// [A-Za-z0-9._-] and maps anything else to '-'. +func CloneID(primaryWorktree string) string { + sum := sha256.Sum256([]byte(primaryWorktree)) + base := strings.Map(func(r rune) rune { + if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' || r == '_' || r == '-' { + return r + } + return '-' + }, filepath.Base(primaryWorktree)) + return base + "-" + hex.EncodeToString(sum[:])[:8] +} + +// Layout is one repository's resolved per-repo spellings. +type Layout struct { + Mode Mode + StateDir, MetadataRemote, MetadataBranch, MetadataWorktree string + ConfigPath, StoreDir, DefaultBareRemote, CheckoutsDir string // private only +} + +// SharedLayout is the layout of a shared repository: the docket branch on +// origin, checked out at /.docket. +func SharedLayout(commonDir, primaryWorktree string) Layout { + return Layout{ + Mode: Shared, + StateDir: filepath.Join(commonDir, SharedName), + MetadataRemote: SharedRemote, + MetadataBranch: SharedName, + MetadataWorktree: filepath.Join(primaryWorktree, SharedWorktreeDir), + } +} + +// PrivateLayout is the layout of a private repository: the dckt branch on a +// bare dckt remote under /dckt/, with this clone's +// metadata checkout under the store's checkouts folder. +func PrivateLayout(commonDir, primaryWorktree, dataHome, ownerRepo string) Layout { + store := filepath.Join(dataHome, PrivateName, ownerRepo) + checkouts := filepath.Join(store, "checkouts") + return Layout{ + Mode: Private, + StateDir: filepath.Join(commonDir, PrivateName), + MetadataRemote: PrivateName, + MetadataBranch: PrivateName, + MetadataWorktree: filepath.Join(checkouts, CloneID(primaryWorktree)), + ConfigPath: PrivateConfigPath(commonDir), + StoreDir: store, + DefaultBareRemote: filepath.Join(store, "remote.git"), + CheckoutsDir: checkouts, + } +} + +// MetadataRef is the local metadata branch ref. +func (l Layout) MetadataRef() string { return "refs/heads/" + l.MetadataBranch } + +// TrackingRef is the remote-tracking ref of the metadata branch. +func (l Layout) TrackingRef() string { + return "refs/remotes/" + l.MetadataRemote + "/" + l.MetadataBranch +} diff --git a/internal/layout/layout_test.go b/internal/layout/layout_test.go new file mode 100644 index 000000000..0077013a2 --- /dev/null +++ b/internal/layout/layout_test.go @@ -0,0 +1,192 @@ +package layout + +import ( + "errors" + "os" + "path/filepath" + "regexp" + "testing" +) + +func TestDetect(t *testing.T) { + t.Run("empty common dir is shared", func(t *testing.T) { + c := t.TempDir() + m, err := Detect(c) + if err != nil || m != Shared { + t.Fatalf("Detect = (%q, %v), want (shared, nil)", m, err) + } + if got, want := StateDirOf(c), filepath.Join(c, "docket"); got != want { + t.Fatalf("StateDirOf = %q, want %q", got, want) + } + }) + t.Run("dckt directory is private", func(t *testing.T) { + c := t.TempDir() + if err := os.Mkdir(filepath.Join(c, "dckt"), 0o755); err != nil { + t.Fatal(err) + } + m, err := Detect(c) + if err != nil || m != Private { + t.Fatalf("Detect = (%q, %v), want (private, nil)", m, err) + } + if got, want := StateDirOf(c), filepath.Join(c, "dckt"); got != want { + t.Fatalf("StateDirOf = %q, want %q", got, want) + } + if got, want := PrivateConfigPath(c), filepath.Join(c, "dckt", "config.yml"); got != want { + t.Fatalf("PrivateConfigPath = %q, want %q", got, want) + } + }) + t.Run("dckt file fails closed", func(t *testing.T) { + c := t.TempDir() + if err := os.WriteFile(filepath.Join(c, "dckt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + if m, err := Detect(c); err == nil { + t.Fatalf("Detect = (%q, nil), want an error", m) + } + if got := StateName(c); got != "dckt" { + t.Fatalf("StateName = %q, want dckt", got) + } + }) +} + +func TestOwnerRepoForms(t *testing.T) { + ok := map[string]string{ + "git@github.com:DanielHanold/Docket.git": "danielhanold-docket", + "https://github.com/o/r": "o-r", + "https://github.com/o/r.git/": "o-r", + "ssh://git@host:2222/Team.X/My_Repo.git": "team-x-my-repo", + "/tmp/abc/origin.git": "abc-origin", + } + for in, want := range ok { + got, err := OwnerRepo(in) + if err != nil || got != want { + t.Errorf("OwnerRepo(%q) = (%q, %v), want (%q, nil)", in, got, err, want) + } + } + for _, in := range []string{"", " ", "/", ".git", "::"} { + if got, err := OwnerRepo(in); err == nil { + t.Errorf("OwnerRepo(%q) = (%q, nil), want an error", in, got) + } + } +} + +func TestCloneID(t *testing.T) { + a := CloneID("/home/u/src/api") + if !regexp.MustCompile(`^api-[0-9a-f]{8}$`).MatchString(a) { + t.Fatalf("CloneID = %q, want api-<8 hex>", a) + } + if again := CloneID("/home/u/src/api"); again != a { + t.Fatalf("CloneID not deterministic: %q vs %q", a, again) + } + if other := CloneID("/home/u/other/api"); other == a { + t.Fatalf("CloneID collides for distinct paths: %q", other) + } +} + +func TestDataHome(t *testing.T) { + home := func() (string, error) { return "/home/u", nil } + env := func(v string) func(string) string { + return func(k string) string { + if k == "XDG_DATA_HOME" { + return v + } + return "" + } + } + if got, err := DataHome(env("/xdg/data"), home); err != nil || got != "/xdg/data" { + t.Errorf("absolute XDG: (%q, %v), want /xdg/data", got, err) + } + want := filepath.Join("/home/u", ".local", "share") + if got, err := DataHome(env("rel/data"), home); err != nil || got != want { + t.Errorf("relative XDG: (%q, %v), want %q", got, err, want) + } + if got, err := DataHome(env(""), home); err != nil || got != want { + t.Errorf("unset XDG: (%q, %v), want %q", got, err, want) + } + noHome := func() (string, error) { return "", errors.New("no home") } + if got, err := DataHome(env(""), noHome); err == nil { + t.Errorf("home error: (%q, nil), want an error", got) + } + emptyHome := func() (string, error) { return "", nil } + if got, err := DataHome(env(""), emptyHome); err == nil { + t.Errorf("empty home: (%q, nil), want an error", got) + } +} + +func TestLayouts(t *testing.T) { + s := SharedLayout("/r/.git", "/r") + wantS := Layout{Shared, "/r/.git/docket", "origin", "docket", "/r/.docket", "", "", "", ""} + if s != wantS { + t.Fatalf("SharedLayout = %+v, want %+v", s, wantS) + } + if s.MetadataRef() != "refs/heads/docket" || s.TrackingRef() != "refs/remotes/origin/docket" { + t.Fatalf("shared refs = %q, %q", s.MetadataRef(), s.TrackingRef()) + } + + p := PrivateLayout("/r/.git", "/r", "/data", "o-r") + wantP := Layout{Private, "/r/.git/dckt", "dckt", "dckt", + "/data/dckt/o-r/checkouts/" + CloneID("/r"), "/r/.git/dckt/config.yml", + "/data/dckt/o-r", "/data/dckt/o-r/remote.git", "/data/dckt/o-r/checkouts"} + if p != wantP { + t.Fatalf("PrivateLayout = %+v, want %+v", p, wantP) + } + if p.MetadataRef() != "refs/heads/dckt" || p.TrackingRef() != "refs/remotes/dckt/dckt" { + t.Fatalf("private refs = %q, %q", p.MetadataRef(), p.TrackingRef()) + } +} + +func TestCommonDirOf(t *testing.T) { + t.Run("git directory", func(t *testing.T) { + root := t.TempDir() + gd := filepath.Join(root, ".git") + if err := os.Mkdir(gd, 0o755); err != nil { + t.Fatal(err) + } + c, ok, err := CommonDirOf(root) + if err != nil || !ok || c != gd { + t.Fatalf("CommonDirOf = (%q, %v, %v), want (%q, true, nil)", c, ok, err, gd) + } + }) + t.Run("linked worktree", func(t *testing.T) { + base := t.TempDir() + common := filepath.Join(base, "main", ".git") + gitdir := filepath.Join(common, "worktrees", "feat") + if err := os.MkdirAll(gitdir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitdir, "commondir"), []byte("../..\n"), 0o644); err != nil { + t.Fatal(err) + } + root := filepath.Join(base, "feat") + if err := os.Mkdir(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil { + t.Fatal(err) + } + c, ok, err := CommonDirOf(root) + if err != nil || !ok || c != common { + t.Fatalf("CommonDirOf = (%q, %v, %v), want (%q, true, nil)", c, ok, err, common) + } + }) + t.Run("relative gitdir without commondir is its own common dir", func(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "modules", "sub") + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".git"), []byte("gitdir: modules/sub\n"), 0o644); err != nil { + t.Fatal(err) + } + c, ok, err := CommonDirOf(root) + if err != nil || !ok || c != sub { + t.Fatalf("CommonDirOf = (%q, %v, %v), want (%q, true, nil)", c, ok, err, sub) + } + }) + t.Run("plain directory", func(t *testing.T) { + c, ok, err := CommonDirOf(t.TempDir()) + if err != nil || ok { + t.Fatalf("CommonDirOf = (%q, %v, %v), want ok=false, nil error", c, ok, err) + } + }) +} From d372d9215b4fea0099090709e7c95486a54688a5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 06:46:21 -0400 Subject: [PATCH 03/20] refactor: resolve the per-repo state folder through layout (shared behavior unchanged) Every path beneath /docket/ (run tracker, resume lock, guardian marker, transactions, workspaces, gate-drive store roots, the suite budget-state file, the empty hooks dir) is now built from layout.StateDirOf, and reposeed.RecordPath takes the state name, which ResolveRepoPhase derives from layout.CommonDirOf + layout.StateName. With no /dckt directory every path is byte-identical to before. The new repoguard TestNoInlineStateFolderSpelling rejects any filepath.Join/path.Join with a literal "docket" argument outside the layout package and the two machine-path files (mutation-tested by re-inlining "docket" in workspacesRoot). The workspace import boundary admits the stdlib-only internal/layout leaf. Default-tag tests prove gatedrive.OpenStore, workspacesRoot, transactionsRoot and RecordPath follow a /dckt folder. The git-resolved sites (run tracker, suiterunner, hooks) are proven by Task 10's private-lifecycle walk. --- internal/app/agent_guardian.go | 8 +- internal/app/gate_drive.go | 2 +- internal/app/gate_integration_test.go | 2 +- internal/app/repophase.go | 11 +- internal/app/repophase_integration_test.go | 7 +- internal/app/runtracker_run_record.go | 2 +- internal/app/runtracker_start.go | 6 +- internal/app/runtracker_store.go | 45 ++++--- internal/gatedrive/scope.go | 2 +- internal/gatedrive/store.go | 23 ++-- internal/gatedrive/store_layout_test.go | 38 ++++++ internal/gatedrive/suitebudget.go | 2 +- internal/gatedrive/worktree_lock.go | 2 +- internal/gitcli/hooksoff.go | 6 +- internal/install/repophase.go | 2 +- internal/repoguard/layout_guard_test.go | 125 ++++++++++++++++++ internal/reposeed/record.go | 9 +- internal/reposeed/record_test.go | 23 +++- internal/repository/transaction/candidate.go | 6 +- .../transaction/root_layout_test.go | 31 +++++ internal/suiterunner/budgetstate.go | 9 +- internal/suiterunner/schedule.go | 2 +- internal/workspace/boundary_test.go | 8 +- internal/workspace/manifest.go | 6 +- internal/workspace/metadir.go | 2 +- internal/workspace/result.go | 2 +- internal/workspace/root_layout_test.go | 29 ++++ 27 files changed, 346 insertions(+), 64 deletions(-) create mode 100644 internal/gatedrive/store_layout_test.go create mode 100644 internal/repoguard/layout_guard_test.go create mode 100644 internal/repository/transaction/root_layout_test.go create mode 100644 internal/workspace/root_layout_test.go diff --git a/internal/app/agent_guardian.go b/internal/app/agent_guardian.go index 3e6a74f4e..bd1d8b157 100644 --- a/internal/app/agent_guardian.go +++ b/internal/app/agent_guardian.go @@ -248,8 +248,8 @@ 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 @@ -257,9 +257,9 @@ func markerPathFor(gitCommonDir, runKey string) string { // 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 } diff --git a/internal/app/gate_drive.go b/internal/app/gate_drive.go index 62e4ed095..68866c45b 100644 --- a/internal/app/gate_drive.go +++ b/internal/app/gate_drive.go @@ -558,7 +558,7 @@ func holderChangeLabel(changeID string) string { func incumbentRemedyMessage(inc *gatedrive.IncumbentSnapshot) string { switch { case inc == nil: - return "holder unknown: another gate's supervisor holds this worktree's lock and it could not be confirmed running; wait for it to finish — the worktree frees itself when that gate ends — or find the holding process with lsof on this worktree's busy.lock under the repository's Git common dir (docket/worktree-locks//busy.lock); never start a second gate here" + return "holder unknown: another gate's supervisor holds this worktree's lock and it could not be confirmed running; wait for it to finish — the worktree frees itself when that gate ends — or find the holding process with lsof on this worktree's busy.lock under the repository's per-repo state folder (worktree-locks//busy.lock); never start a second gate here" case inc.Kind == "raw" && inc.RawRunDir != "" && rawRunIDShape.MatchString(inc.RawRunID): dir := quoteOperand(inc.RawRunDir) return "a raw gate run holds this worktree (run dir " + dir + "); wait for it, or stop it with docket gate stop " + dir + " --reason — the worktree frees itself when the run ends" diff --git a/internal/app/gate_integration_test.go b/internal/app/gate_integration_test.go index bad7e828a..f22ce5ca8 100644 --- a/internal/app/gate_integration_test.go +++ b/internal/app/gate_integration_test.go @@ -69,7 +69,7 @@ func TestIntegrationGateLifecycleRawLaunchHoldsWorktreeLock(t *testing.T) { if got := admissionRefusalCause(err); got != "incumbent-run:"+res.RunID { t.Fatalf("busy refusal cause = %q, want incumbent-run:%s", got, res.RunID) } - // holder.json sits beside busy.lock under /docket/worktree-locks//. + // holder.json sits beside busy.lock under /worktree-locks//. sum := sha256.Sum256([]byte(root)) buf, err := os.ReadFile(filepath.Join(gitDir, "docket", "worktree-locks", hex.EncodeToString(sum[:]), "holder.json")) if err != nil { diff --git a/internal/app/repophase.go b/internal/app/repophase.go index a2fd7dd62..e25c96749 100644 --- a/internal/app/repophase.go +++ b/internal/app/repophase.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposeed" ) @@ -82,7 +83,15 @@ func ResolveRepoPhase(ctx context.Context, git *gitcli.Client, repoDir string, h return nil, "", nil, nil } root, gitDir := wt.Root, wt.GitDir - recordPath := reposeed.RecordPath(gitDir) + common, ok, err := layout.CommonDirOf(root) + if err != nil || !ok { + if err == nil { + err = fmt.Errorf("%s has no .git entry", root) + } + return nil, "", nil, &RepoResolutionError{Reason: install.ReasonInvalidRepoDir, + Err: fmt.Errorf("cannot resolve the Git common dir of %q: %w", root, err)} + } + recordPath := reposeed.RecordPath(gitDir, layout.StateName(common)) // The repository configuration layer. LoadFilesystemSources reads the global // layer too — which is exactly what lets the provenance guard below tell a diff --git a/internal/app/repophase_integration_test.go b/internal/app/repophase_integration_test.go index 8aa242d06..ea1bce891 100644 --- a/internal/app/repophase_integration_test.go +++ b/internal/app/repophase_integration_test.go @@ -15,6 +15,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposeed" ) @@ -120,7 +121,7 @@ func TestIntegrationContextProbeResolveRepoPhaseAbsentKeyNotAuthorized(t *testin if gotRoot != root || phase.Worktree != root { t.Errorf("root = %q / %q, want %q", gotRoot, phase.Worktree, root) } - if want := reposeed.RecordPath(gitDir); phase.RecordPath != want { + if want := reposeed.RecordPath(gitDir, layout.SharedName); phase.RecordPath != want { t.Errorf("record path = %q, want %q", phase.RecordPath, want) } if len(phase.Targets) != 0 { @@ -195,7 +196,7 @@ func TestIntegrationContextProbeResolveRepoPhaseScopedHarnessCarriesUnrelatedRec if err != nil { t.Fatal(err) } - recPath := reposeed.RecordPath(gitDir) + recPath := reposeed.RecordPath(gitDir, layout.SharedName) if err := os.MkdirAll(filepath.Dir(recPath), 0o755); err != nil { t.Fatal(err) } @@ -269,7 +270,7 @@ func TestIntegrationContextProbeResolveRepoPhaseRetiresDroppedClaudeLink(t *test if err != nil { t.Fatal(err) } - recPath := reposeed.RecordPath(gitDir) + recPath := reposeed.RecordPath(gitDir, layout.SharedName) if err := os.MkdirAll(filepath.Dir(recPath), 0o755); err != nil { t.Fatal(err) } diff --git a/internal/app/runtracker_run_record.go b/internal/app/runtracker_run_record.go index 06a0c9b26..30fb53f57 100644 --- a/internal/app/runtracker_run_record.go +++ b/internal/app/runtracker_run_record.go @@ -4,7 +4,7 @@ // directory, keyed by the run key, so the run key that already locates a // dispatch's attribution/retry state also locates its run. // -// WHERE: /docket/run-tracker//run.json — the run record +// WHERE: /run-tracker//run.json — the run record // sits next to the run-tracker record.json the run start minted. Rooting under the git // COMMON dir (via runKeyDir, the shared preamble the claim-binding primitives // use) keeps it outside every worktree yet reachable from any linked worktree, is diff --git a/internal/app/runtracker_start.go b/internal/app/runtracker_start.go index 134748e49..efe82edc5 100644 --- a/internal/app/runtracker_start.go +++ b/internal/app/runtracker_start.go @@ -274,15 +274,15 @@ func resumeIncumbentRemedy(runKey string) string { // acquireResumeLock takes the exclusive per-change resume lock that serializes // `run start --resume` starts of one change (change 0463). It lives outside the -// run-tracker root, under /docket/run-tracker-resume/, so the +// run-tracker root, under /run-tracker-resume/, so the // scanners that walk run-key directories never see it. Closing the returned file // releases the lock. func acquireResumeLock(repoDir, changeID string) (*os.File, error) { - common, err := runTrackerGitCommonDir(repoDir) + state, err := runTrackerStateDir(repoDir) if err != nil { return nil, err } - dir := filepath.Join(common, "docket", runTrackerResumeDirName, changeID) + dir := filepath.Join(state, runTrackerResumeDirName, changeID) if err := os.MkdirAll(dir, 0o700); err != nil { return nil, runErr(ErrRunRecordIO, "resume-lock-dir", err) } diff --git a/internal/app/runtracker_store.go b/internal/app/runtracker_store.go index e48ee748e..c968010e3 100644 --- a/internal/app/runtracker_store.go +++ b/internal/app/runtracker_store.go @@ -4,7 +4,8 @@ // so a dispatch's gate outcome survives the launching process, a // `git worktree remove`, and a restart. // -// WHERE: /docket/run-tracker//record.json — the same family as +// WHERE: /run-tracker//record.json (the state folder +// is layout.StateDirOf of the git common dir) — the same family as // the dispatch dir and the gate-drive store. Rooting under the git COMMON dir // (not a worktree's .git) means the record sits outside every worktree yet stays // reachable from any linked worktree of the same repository, is never tracked, @@ -51,6 +52,8 @@ import ( "strconv" "strings" "time" + + "github.com/danielhanold/docket/internal/layout" ) // runTrackerSchemaVersion is the on-disk record schema this store understands. A record @@ -91,7 +94,7 @@ const ( runTrackerClaimBindingName = "claim-binding.json" ) -// The run tracker's local storage roots under /docket/ +// The run tracker's local storage roots under the per-repo state folder // (ADR-0129 row 38, change 0471). They were renamed by RESET, not migrated: the // binary never reads the retired rungate/ and rungate-resume/ roots, which stay // inert on disk and may be deleted by hand. @@ -351,15 +354,25 @@ func runTrackerGitCommonDir(repoDir string) (string, error) { return common, nil } -// runTrackerRoot resolves /docket/run-tracker for repoDir. It resolves, -// never creates — an observer must be able to ask where the root is without -// minting one as a side effect of looking. -func runTrackerRoot(repoDir string) (string, error) { +// runTrackerStateDir resolves the repository's per-repo state folder +// (layout.StateDirOf of the canonical git common dir) for repoDir. +func runTrackerStateDir(repoDir string) (string, error) { common, err := runTrackerGitCommonDir(repoDir) if err != nil { return "", err } - return filepath.Join(common, "docket", runTrackerDirName), nil + return layout.StateDirOf(common), nil +} + +// runTrackerRoot resolves the run-tracker root beneath the per-repo state folder +// for repoDir. It resolves, never creates — an observer must be able to ask where +// the root is without minting one as a side effect of looking. +func runTrackerRoot(repoDir string) (string, error) { + state, err := runTrackerStateDir(repoDir) + if err != nil { + return "", err + } + return filepath.Join(state, runTrackerDirName), nil } // validateRunKey enforces the path-safety contract before any path is built: a @@ -384,7 +397,7 @@ func MintRunTrackerRecord(repoDir string, rec RunTrackerRecord) (string, error) if err != nil { return "", err } - root := filepath.Join(common, "docket", runTrackerDirName) + root := filepath.Join(layout.StateDirOf(common), runTrackerDirName) if err := os.MkdirAll(root, 0o755); err != nil { return "", runTrackerErr(ErrRunTrackerIO, "mint", err) } @@ -427,7 +440,7 @@ func LoadRunTrackerRecord(repoDir, key string) (RunTrackerRecord, error) { if err != nil { return RunTrackerRecord{}, err } - dir := filepath.Join(common, "docket", runTrackerDirName, key) + dir := filepath.Join(layout.StateDirOf(common), runTrackerDirName, key) buf, err := os.ReadFile(filepath.Join(dir, runTrackerRecordFileName)) if err != nil { if errors.Is(err, fs.ErrNotExist) { @@ -504,7 +517,7 @@ func SaveRunTrackerRecord(repoDir, key string, rec RunTrackerRecord) error { if err != nil { return err } - dir := filepath.Join(common, "docket", runTrackerDirName, key) + dir := filepath.Join(layout.StateDirOf(common), runTrackerDirName, key) if fi, serr := os.Stat(dir); serr != nil || !fi.IsDir() { return runTrackerErr(ErrRunTrackerNotFound, "save", serr) } @@ -581,11 +594,11 @@ func ConsumeRunTrackerRetry(repoDir, key string, attempt, limit int) (bool, erro if attempt >= limit || attempt < 1 { return false, nil } - common, err := runTrackerGitCommonDir(repoDir) + state, err := runTrackerStateDir(repoDir) if err != nil { return false, err } - dir := filepath.Join(common, "docket", runTrackerDirName, key) + dir := filepath.Join(state, runTrackerDirName, key) if fi, serr := os.Stat(dir); serr != nil || !fi.IsDir() { return false, runTrackerErr(ErrRunTrackerNotFound, "consume", serr) } @@ -625,11 +638,11 @@ func RunTrackerRetryUsage(repoDir, key string) (int, error) { if err := validateRunKey(key); err != nil { return 0, err } - common, err := runTrackerGitCommonDir(repoDir) + state, err := runTrackerStateDir(repoDir) if err != nil { return 0, err } - dir := filepath.Join(common, "docket", runTrackerDirName, key) + dir := filepath.Join(state, runTrackerDirName, key) n, cerr := countRunTrackerRetryMarkers(dir) if cerr != nil { return 0, runTrackerErr(ErrRunTrackerIO, "retry-usage", cerr) @@ -644,11 +657,11 @@ func runKeyDir(repoDir, key, op string) (string, error) { if err := validateRunKey(key); err != nil { return "", err } - common, err := runTrackerGitCommonDir(repoDir) + state, err := runTrackerStateDir(repoDir) if err != nil { return "", err } - dir := filepath.Join(common, "docket", runTrackerDirName, key) + dir := filepath.Join(state, runTrackerDirName, key) if fi, serr := os.Stat(dir); serr != nil || !fi.IsDir() { return "", runTrackerErr(ErrRunTrackerNotFound, op, serr) } diff --git a/internal/gatedrive/scope.go b/internal/gatedrive/scope.go index b7efd612d..f3c5e8a3c 100644 --- a/internal/gatedrive/scope.go +++ b/internal/gatedrive/scope.go @@ -17,7 +17,7 @@ // directory, sharing every durability and privacy discipline the drive store // established (store.go): // -// /docket/gate-scopes/v2//record.json +// /gate-scopes/v2//record.json // // The directory is owner-only (0700) and its record is private (0600). Writes go // through the same writeAtomicJSON helper, and every mutating transition runs diff --git a/internal/gatedrive/store.go b/internal/gatedrive/store.go index ac673e647..29a72da60 100644 --- a/internal/gatedrive/store.go +++ b/internal/gatedrive/store.go @@ -4,7 +4,7 @@ // outside every worktree yet stays reachable from any linked worktree of the // same repository (spec "Durable drive record → Location and privacy"): // -// /docket/gate-drives/v2//record.json +// /gate-drives/v2//record.json // // The directory is owner-only (0700) and its files are private (0600). Writes go // through a sibling temp file, fsync, atomic rename, and a directory fsync, so a @@ -37,6 +37,8 @@ import ( "os" "path/filepath" "syscall" + + "github.com/danielhanold/docket/internal/layout" ) const ( @@ -149,19 +151,20 @@ type Store struct { lockRoot string } -// OpenStore returns a Store rooted at /docket/gate-drives/v2 with -// a sibling recovery-scope root at /docket/gate-scopes/v2, a -// sibling suite-attempt-budget root at -// /docket/gate-suite-budgets/v1, and a sibling worktree-lock root -// at /docket/worktree-locks. It creates nothing; directories are +// OpenStore returns a Store rooted at gate-drives/v2 beneath the per-repo state +// folder (layout.StateDirOf of gitCommonDir, resolved once here) with a sibling +// recovery-scope root at gate-scopes/v2, a sibling suite-attempt-budget root at +// gate-suite-budgets/v1, and a sibling worktree-lock root at worktree-locks. It +// creates nothing; directories are // minted lazily by NewDrive/PrepareScope/ReserveSuiteAttempt/TryWorktreeLock so // an unused store leaves no trace. func OpenStore(gitCommonDir string) *Store { + state := layout.StateDirOf(gitCommonDir) return &Store{ - root: filepath.Join(gitCommonDir, "docket", "gate-drives", "v2"), - scopeRoot: filepath.Join(gitCommonDir, "docket", "gate-scopes", "v2"), - suiteBudgetRoot: filepath.Join(gitCommonDir, "docket", "gate-suite-budgets", "v1"), - lockRoot: filepath.Join(gitCommonDir, "docket", "worktree-locks"), + root: filepath.Join(state, "gate-drives", "v2"), + scopeRoot: filepath.Join(state, "gate-scopes", "v2"), + suiteBudgetRoot: filepath.Join(state, "gate-suite-budgets", "v1"), + lockRoot: filepath.Join(state, "worktree-locks"), } } diff --git a/internal/gatedrive/store_layout_test.go b/internal/gatedrive/store_layout_test.go new file mode 100644 index 000000000..6f7711a92 --- /dev/null +++ b/internal/gatedrive/store_layout_test.go @@ -0,0 +1,38 @@ +package gatedrive + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestOpenStoreRootsFollowStateFolder proves every root OpenStore composes sits +// beneath the per-repo state folder: /dckt/ when that directory exists, +// /docket/ once it is gone. +func TestOpenStoreRootsFollowStateFolder(t *testing.T) { + common := testsupport.TempDir(t) + priv := filepath.Join(common, "dckt") + if err := os.Mkdir(priv, 0o755); err != nil { + t.Fatal(err) + } + assertStoreRootsUnder(t, OpenStore(common), priv) + if err := os.Remove(priv); err != nil { + t.Fatal(err) + } + assertStoreRootsUnder(t, OpenStore(common), filepath.Join(common, "docket")) +} + +func assertStoreRootsUnder(t *testing.T, s *Store, dir string) { + t.Helper() + want := Store{ + root: filepath.Join(dir, "gate-drives", "v2"), + scopeRoot: filepath.Join(dir, "gate-scopes", "v2"), + suiteBudgetRoot: filepath.Join(dir, "gate-suite-budgets", "v1"), + lockRoot: filepath.Join(dir, "worktree-locks"), + } + if *s != want { + t.Errorf("OpenStore roots = %+v, want %+v", *s, want) + } +} diff --git a/internal/gatedrive/suitebudget.go b/internal/gatedrive/suitebudget.go index 5bee66d4c..97dd0dd8f 100644 --- a/internal/gatedrive/suitebudget.go +++ b/internal/gatedrive/suitebudget.go @@ -13,7 +13,7 @@ // Git common directory, sharing every durability and privacy discipline the // drive store established (store.go): // -// /docket/gate-suite-budgets/v1//record.json +// /gate-suite-budgets/v1//record.json // // where is the lowercase-hex sha256 of RepoIdentity, ChangeID, and Phase // joined by NUL — path-safe by construction, mirroring capHash. The directory is diff --git a/internal/gatedrive/worktree_lock.go b/internal/gatedrive/worktree_lock.go index 249c06f62..16b1407cf 100644 --- a/internal/gatedrive/worktree_lock.go +++ b/internal/gatedrive/worktree_lock.go @@ -2,7 +2,7 @@ // slot). One canonical worktree admits at most one live top-level gate: every // launch site first takes a NON-BLOCKING exclusive flock on // -// /docket/worktree-locks//busy.lock +// /worktree-locks//busy.lock // // and hands it to the gate supervisor (process.LaunchRequest.WorktreeLock), which // holds it for its whole life; the kernel releases it when the supervisor exits diff --git a/internal/gitcli/hooksoff.go b/internal/gitcli/hooksoff.go index ef63f6f4a..d5c928c5e 100644 --- a/internal/gitcli/hooksoff.go +++ b/internal/gitcli/hooksoff.go @@ -4,6 +4,8 @@ import ( "context" "os" "path/filepath" + + "github.com/danielhanold/docket/internal/layout" ) // disableHooksOp labels every Failure from the worktree hook-disabling surface. @@ -59,7 +61,7 @@ func (c *Client) DisableWorktreeHooks(ctx context.Context, worktreeDir string) e } // emptyHooksDir resolves the worktree's common git dir and returns docket's -// absolute, empty hooks directory under it (/docket/empty-hooks), creating +// absolute, empty hooks directory under it (/empty-hooks), creating // it when absent. It is the one place that directory is named: DisableWorktreeHooks // points a worktree's per-worktree core.hooksPath at it, and the in-place // fast-forward primitives force it per command with `-c core.hooksPath=`. @@ -85,7 +87,7 @@ func (c *Client) emptyHooksDir(ctx context.Context, op Operation, worktreeDir st if err != nil { return "", newFailure(op, KindInvalidOutput, "cannot canonicalize git common dir", err) } - empty := filepath.Join(common, "docket", "empty-hooks") + empty := filepath.Join(layout.StateDirOf(common), "empty-hooks") if err := os.MkdirAll(empty, 0o755); err != nil { return "", newFailure(op, KindCommandFailed, "cannot create empty hooks dir", err) } diff --git a/internal/install/repophase.go b/internal/install/repophase.go index c6f67fd49..a2a67f4b0 100644 --- a/internal/install/repophase.go +++ b/internal/install/repophase.go @@ -52,7 +52,7 @@ type RepoPhase struct { // reproducer. They ride the same journaled transaction as the writes. Removals []TargetRecord // RecordPath is where the repository ownership record is published: - // /docket/install.json. It is one of the transaction's commit + // //install.json. It is one of the transaction's commit // documents, journaled and rolled back beside the machine state. RecordPath string // RecordBytes is the desired record's canonical bytes; nil when the phase is diff --git a/internal/repoguard/layout_guard_test.go b/internal/repoguard/layout_guard_test.go new file mode 100644 index 000000000..ab7e35d10 --- /dev/null +++ b/internal/repoguard/layout_guard_test.go @@ -0,0 +1,125 @@ +package repoguard + +// TestNoInlineStateFolderSpelling pins that no maintained Go source spells the +// per-repo state folder inline. The folder's name depends on the repository's +// visibility mode (layout.StateName), so every path beneath it must be built +// from layout.StateDirOf / layout.StateName, never from a literal "docket" +// path segment. +// +// Shape: every filepath.Join (or path.Join) call, at any nesting depth, with a +// string-literal argument equal to "docket", in a non-test .go file under +// internal/ or cmd/. Skipped: testdata/, the embedded asset tree, the layout +// package itself (which owns the spelling), and the two machine-path files +// whose "docket" segment names the binary's own per-user folders, not a +// repository's state folder (internal/config/fs.go and internal/install/roots.go). +// +// Mutation-tested: re-inlining "docket" in workspace.workspacesRoot reddens it. + +import ( + "go/ast" + "go/parser" + "go/token" + "io/fs" + "path/filepath" + "sort" + "strconv" + "strings" + "testing" +) + +// stateFolderGuardSkips are the files whose "docket" path segment is not a +// per-repo state folder. +var stateFolderGuardSkips = map[string]string{ + "internal/config/fs.go": "global config path ${XDG_CONFIG_HOME}/docket/config.yml", + "internal/install/roots.go": "the binary's per-user install roots", +} + +// walkGo visits every non-test .go file under root's internal/ and cmd/ trees, +// passing its slash-separated path relative to root. It skips testdata/, the +// embedded asset tree, and the layout package, and fails the test closed on a +// walk error. +func walkGo(t *testing.T, root string, visit func(rel string)) { + t.Helper() + for _, top := range []string{"internal", "cmd"} { + err := filepath.WalkDir(filepath.Join(root, top), func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(root, p) + if rerr != nil { + return rerr + } + rel = filepath.ToSlash(rel) + if d.IsDir() { + switch { + case d.Name() == "testdata", + rel == "internal/assets/embedded", + rel == "internal/layout": + return filepath.SkipDir + } + return nil + } + if !strings.HasSuffix(rel, ".go") || strings.HasSuffix(rel, "_test.go") { + return nil + } + visit(rel) + return nil + }) + if err != nil { + t.Fatalf("walk %s: %v", top, err) + } + } +} + +// isPathJoin reports whether call is filepath.Join or path.Join. +func isPathJoin(call *ast.CallExpr) bool { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Join" { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && (pkg.Name == "filepath" || pkg.Name == "path") +} + +func TestNoInlineStateFolderSpelling(t *testing.T) { + root := guardRoot(t) + fset := token.NewFileSet() + parsed := 0 + var offenders []string + walkGo(t, root, func(rel string) { + if _, skip := stateFolderGuardSkips[rel]; skip { + return + } + file, err := parser.ParseFile(fset, filepath.Join(root, filepath.FromSlash(rel)), nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + parsed++ + ast.Inspect(file, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok || !isPathJoin(call) { + return true + } + for _, arg := range call.Args { + lit, ok := arg.(*ast.BasicLit) + if !ok || lit.Kind != token.STRING { + continue + } + if v, err := strconv.Unquote(lit.Value); err == nil && v == "docket" { + pos := fset.Position(call.Pos()) + offenders = append(offenders, rel+":"+strconv.Itoa(pos.Line)) + } + } + return true + }) + }) + // Population floor: a walk that silently parsed nothing would pass vacuously. + if parsed < 100 { + t.Fatalf("parsed only %d Go files under internal/ and cmd/; the walk is broken", parsed) + } + if len(offenders) > 0 { + sort.Strings(offenders) + t.Errorf("per-repo state folder spelled inline (build it from layout.StateDirOf / layout.StateName):\n %s", + strings.Join(offenders, "\n ")) + } +} diff --git a/internal/reposeed/record.go b/internal/reposeed/record.go index ff35744c1..ebcb98df0 100644 --- a/internal/reposeed/record.go +++ b/internal/reposeed/record.go @@ -35,16 +35,17 @@ type SurfaceRecord struct { } // Record is the published per-worktree ownership document at -// /docket/install.json. +// //install.json. type Record struct { FormatVersion int `json:"format_version"` Surfaces []SurfaceRecord `json:"surfaces"` // sorted by Path } // RecordPath is the per-working-tree ownership document location under a git -// dir: /docket/install.json. -func RecordPath(gitDir string) string { - return filepath.Join(gitDir, "docket", "install.json") +// dir: //install.json, where stateName is the repository's +// per-repo state folder name (layout.StateName of its common dir). +func RecordPath(gitDir, stateName string) string { + return filepath.Join(gitDir, stateName, "install.json") } // LoadRecord reads the per-worktree ownership record. An absent file is "not diff --git a/internal/reposeed/record_test.go b/internal/reposeed/record_test.go index a81eaeb74..af6238d36 100644 --- a/internal/reposeed/record_test.go +++ b/internal/reposeed/record_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" ) // sampleTargets renders one target of each kind under root, mirroring the shapes @@ -226,7 +227,27 @@ func TestToStateProducesAbsoluteCleanedPaths(t *testing.T) { } func TestRecordPath(t *testing.T) { - if got, want := RecordPath("/some/.git"), filepath.Join("/some/.git", "docket", "install.json"); got != want { + if got, want := RecordPath("/some/.git", "docket"), filepath.Join("/some/.git", "docket", "install.json"); got != want { t.Errorf("RecordPath = %q, want %q", got, want) } } + +// TestRecordPathUsesStateName proves the record follows the repository's state +// folder name: a /dckt directory puts it under /dckt/, and its +// absence puts it back under /docket/. +func TestRecordPathUsesStateName(t *testing.T) { + common := t.TempDir() + priv := filepath.Join(common, "dckt") + if err := os.Mkdir(priv, 0o755); err != nil { + t.Fatal(err) + } + if got, want := RecordPath(common, layout.StateName(common)), filepath.Join(priv, "install.json"); got != want { + t.Errorf("private RecordPath = %q, want %q", got, want) + } + if err := os.Remove(priv); err != nil { + t.Fatal(err) + } + if got, want := RecordPath(common, layout.StateName(common)), filepath.Join(common, "docket", "install.json"); got != want { + t.Errorf("shared RecordPath = %q, want %q", got, want) + } +} diff --git a/internal/repository/transaction/candidate.go b/internal/repository/transaction/candidate.go index 5f1cfb6a4..e4999ee51 100644 --- a/internal/repository/transaction/candidate.go +++ b/internal/repository/transaction/candidate.go @@ -34,6 +34,7 @@ import ( "time" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" ) const ( @@ -100,11 +101,12 @@ type manifest struct { } // transactionsRoot is the private root of all candidates for repo: -// /docket/transactions. It sits under the shared common directory so +// transactions/ beneath the per-repo state folder (layout.StateDirOf). It sits +// under the shared common directory so // it is invisible to any working-tree status and shared across every linked // worktree of the repository. func transactionsRoot(repo gitcli.Repository) string { - return filepath.Join(repo.CommonDir, "docket", "transactions") + return filepath.Join(layout.StateDirOf(repo.CommonDir), "transactions") } // newTransactionID mints a 32-character lowercase-hex id from 128 bits of diff --git a/internal/repository/transaction/root_layout_test.go b/internal/repository/transaction/root_layout_test.go new file mode 100644 index 000000000..ca46627bc --- /dev/null +++ b/internal/repository/transaction/root_layout_test.go @@ -0,0 +1,31 @@ +package transaction + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestTransactionsRootFollowsStateFolder proves the transactions root sits +// beneath the per-repo state folder: /dckt/ when that directory exists, +// /docket/ once it is gone. +func TestTransactionsRootFollowsStateFolder(t *testing.T) { + common := testsupport.TempDir(t) + repo := gitcli.Repository{CommonDir: common} + priv := filepath.Join(common, "dckt") + if err := os.Mkdir(priv, 0o755); err != nil { + t.Fatal(err) + } + if got, want := transactionsRoot(repo), filepath.Join(priv, "transactions"); got != want { + t.Errorf("private transactionsRoot = %q, want %q", got, want) + } + if err := os.Remove(priv); err != nil { + t.Fatal(err) + } + if got, want := transactionsRoot(repo), filepath.Join(common, "docket", "transactions"); got != want { + t.Errorf("shared transactionsRoot = %q, want %q", got, want) + } +} diff --git a/internal/suiterunner/budgetstate.go b/internal/suiterunner/budgetstate.go index 9ebf5319e..030c7e6c4 100644 --- a/internal/suiterunner/budgetstate.go +++ b/internal/suiterunner/budgetstate.go @@ -9,7 +9,7 @@ // // The store speaks the oracle's exact v1 format so a human reads both files with // one set of eyes — the ONLY deliberate divergence is the default PATH: the Go -// runner writes /docket/development-test-budget-state.tsv, never +// runner writes /development-test-budget-state.tsv, never // the Bash runner's /docket/run-tests-budget-state.tsv. Two independent // writers on one advisory file would corrupt both histories, so the runners keep // separate files with the same schema (a documented intentional deviation, pinned @@ -30,6 +30,8 @@ import ( "strconv" "strings" "time" + + "github.com/danielhanold/docket/internal/layout" ) // bsSchema is the budget-state store schema version, embedded in the context key @@ -153,7 +155,8 @@ func relUnderRoot(root, path string) (string, bool) { } // DefaultStatePath resolves the Go runner's OWN advisory budget-state store: -// /docket/development-test-budget-state.tsv. It is deliberately +// development-test-budget-state.tsv beneath the per-repo state folder +// (layout.StateDirOf of the git common dir). It is deliberately // NOT the Bash oracle's /docket/run-tests-budget-state.tsv — see the // file header and TestStorePathIsNotTheBashRunners. A relative git-common-dir is // anchored to repoRoot, mirroring the oracle's anchoring of a relative git dir. @@ -169,7 +172,7 @@ func DefaultStatePath(repoRoot string) (string, error) { if !filepath.IsAbs(gd) { gd = filepath.Join(repoRoot, gd) } - return filepath.Join(gd, "docket", storeBasename), nil + return filepath.Join(layout.StateDirOf(gd), storeBasename), nil } // ApplyScreenObservations folds this run's contended parallel measurements into diff --git a/internal/suiterunner/schedule.go b/internal/suiterunner/schedule.go index df92995e3..a0565989a 100644 --- a/internal/suiterunner/schedule.go +++ b/internal/suiterunner/schedule.go @@ -36,7 +36,7 @@ type Config struct { // Go wrappers translate it into `go test -p` / GOMAXPROCS. 0 => Go defaults. GoTestConcurrency int - StatePath string // default /docket/development-test-budget-state.tsv; DOCKET_RUNTESTS_STATE overrides + StatePath string // default /development-test-budget-state.tsv; DOCKET_RUNTESTS_STATE overrides Strict bool // DOCKET_RUNTESTS_STRICT=1 — confirm every candidate and gate on a breach (exit 4) Verbose bool // reserved false in 0318 (the command exposes no flags) diff --git a/internal/workspace/boundary_test.go b/internal/workspace/boundary_test.go index d1d65bc9e..cfccf08ca 100644 --- a/internal/workspace/boundary_test.go +++ b/internal/workspace/boundary_test.go @@ -32,10 +32,12 @@ const modulePrefix = "github.com/danielhanold/docket/" // workspaceAllowedInModule is the exact set of in-module packages a workspace // production file may import. Derived from the spec's dependency direction; any -// in-module import outside it fails. +// in-module import outside it fails. internal/layout is the stdlib-only leaf +// that spells the per-repo state folder the workspaces root lives beneath. var workspaceAllowedInModule = []string{ "internal/domain", "internal/gitcli", + "internal/layout", } // moduleRoot walks up from the package directory to the directory holding go.mod. @@ -125,8 +127,8 @@ func allProductionGoFilesUnder(t *testing.T, root string) []string { } // TestWorkspaceImportsOnlyDomainAndGitcli pins the OUTWARD boundary: every -// in-module import a workspace production file declares must be internal/domain or -// internal/gitcli. Adding an import of config/document/repository/etc. reddens. +// in-module import a workspace production file declares must be internal/domain, +// internal/gitcli, or the leaf internal/layout. Adding an import of config/document/repository/etc. reddens. func TestWorkspaceImportsOnlyDomainAndGitcli(t *testing.T) { files := productionGoFiles(t, ".") if len(files) == 0 { diff --git a/internal/workspace/manifest.go b/internal/workspace/manifest.go index 8d0401fd2..f19022c33 100644 --- a/internal/workspace/manifest.go +++ b/internal/workspace/manifest.go @@ -25,6 +25,7 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" ) const ( @@ -105,11 +106,12 @@ func workspaceID(ref gitcli.RefName) string { } // workspacesRoot is the root of all workspace state for a repository: -// /docket/workspaces. It sits under the shared common directory so it +// workspaces/ beneath the per-repo state folder (layout.StateDirOf). It sits +// under the shared common directory so it // is invisible to any working-tree status and shared across every linked // worktree of the repository. func workspacesRoot(commonDir string) string { - return filepath.Join(commonDir, "docket", "workspaces") + return filepath.Join(layout.StateDirOf(commonDir), "workspaces") } // workspaceDir is the per-workspace directory root/. diff --git a/internal/workspace/metadir.go b/internal/workspace/metadir.go index 70154d20b..5aa7b749e 100644 --- a/internal/workspace/metadir.go +++ b/internal/workspace/metadir.go @@ -14,7 +14,7 @@ import "github.com/danielhanold/docket/internal/gitcli" // manifest and receipt already live in. // MetaDir returns the per-workspace metadata directory a target's manifest and -// rebase receipt live in — /docket/workspaces/. +// rebase receipt live in — /workspaces/. // commonDir is a repository's canonical common directory (gitcli.Repository's // CommonDir); featureRef is the fully qualified refs/heads/feat/. The // returned path is exactly the directory WriteRebaseReceipt and its siblings diff --git a/internal/workspace/result.go b/internal/workspace/result.go index 7ed734b94..f8e67cfdc 100644 --- a/internal/workspace/result.go +++ b/internal/workspace/result.go @@ -1,5 +1,5 @@ // Package workspace owns docket's local persistent feature-workspace state: the -// manifest under /docket/workspaces/ and the checkout at +// manifest under /workspaces/ and the checkout at // /.worktrees/. It composes typed values from internal/domain // (effective-base/change-id/slug semantics) and drives Git through // internal/gitcli; it imports no other internal package. diff --git a/internal/workspace/root_layout_test.go b/internal/workspace/root_layout_test.go new file mode 100644 index 000000000..36067341f --- /dev/null +++ b/internal/workspace/root_layout_test.go @@ -0,0 +1,29 @@ +package workspace + +import ( + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestWorkspacesRootFollowsStateFolder proves the workspaces root sits beneath +// the per-repo state folder: /dckt/ when that directory exists, +// /docket/ once it is gone. +func TestWorkspacesRootFollowsStateFolder(t *testing.T) { + common := testsupport.TempDir(t) + priv := filepath.Join(common, "dckt") + if err := os.Mkdir(priv, 0o755); err != nil { + t.Fatal(err) + } + if got, want := workspacesRoot(common), filepath.Join(priv, "workspaces"); got != want { + t.Errorf("private workspacesRoot = %q, want %q", got, want) + } + if err := os.Remove(priv); err != nil { + t.Fatal(err) + } + if got, want := workspacesRoot(common), filepath.Join(common, "docket", "workspaces"); got != want { + t.Errorf("shared workspacesRoot = %q, want %q", got, want) + } +} From 96bff811568534e035e177515262d7d07d7d802a Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 06:59:08 -0400 Subject: [PATCH 04/20] refactor(app): thread the resolved metadata remote and branch through StatusPin The operational loader resolves the repository's layout right after discovery (resolveLayout: shared reads no origin URL; private locates the store from origin's URL and the data home) and carries it on StatusPin.Layout. The metadata pin fetch, the sweep session's fetch, all 22 metadata transaction.Request sites (Remote/TargetRef via metadataRemote/ metadataRef of the pinned layout), the link context, the implementation context's metadata ref, and the human status line now read it. reposetup.MetadataBranchName is deleted. Repository-family sites alias layout.SharedName with a Task 4 marker until Task 4 threads sc.layout. render.LinkContext gains PrivateMetadata: a private metadata branch has no web page, so its blob links render repo-relative. The status JSON keeps omitting metadata_branch (the 0363 protocol guard TestStatusContextProtocolOmitsModeFields); StatusContext.MetadataBranch is json:"-" and feeds only the human line. Guards: TestTransactionRequestsUseResolvedMetadataRemote (new) and TestNoIntegrationPushOutsidePRMerge shape 1 (now keyed on metadataRef calls) were mutation-tested together with the wiring test TestIntegrationRecordOpsClaimTransactionTargetsPinnedMetadataLayout. --- internal/app/adr_ops.go | 9 +- internal/app/change_attach.go | 5 +- internal/app/change_claim.go | 9 +- internal/app/change_claim_integration_test.go | 30 +++++ internal/app/change_create.go | 5 +- internal/app/change_create_test.go | 1 + internal/app/change_groom.go | 5 +- internal/app/change_halt.go | 9 +- internal/app/change_implemented.go | 5 +- internal/app/change_kill.go | 5 +- internal/app/change_lifecycle.go | 5 +- internal/app/change_reclaim.go | 5 +- internal/app/change_reconcile.go | 5 +- internal/app/change_relink.go | 5 +- internal/app/derived_views_test.go | 5 +- internal/app/evidence_section.go | 5 +- internal/app/finalize_block.go | 9 +- internal/app/finalize_closeout.go | 9 +- internal/app/implementation_context.go | 3 +- internal/app/layout_resolve.go | 61 ++++++++++ internal/app/layout_resolve_test.go | 105 +++++++++++++++++ internal/app/learning_ops.go | 9 +- internal/app/learning_ops_test.go | 1 + internal/app/link_context.go | 11 +- internal/app/link_context_test.go | 29 +++++ internal/app/operational_context.go | 38 ++++--- internal/app/repair_remedy_family_test.go | 3 +- .../repocheck_corpus_link_integration_test.go | 6 +- internal/app/repocheck_integration_test.go | 3 +- internal/app/repository_check.go | 4 +- internal/app/repository_check_derived_test.go | 13 ++- internal/app/repository_facts.go | 3 +- internal/app/repository_init.go | 3 +- internal/app/repository_migrate.go | 9 +- internal/app/repository_prepare.go | 7 +- internal/app/repository_prepare_test.go | 5 +- internal/app/repository_repair.go | 5 +- .../app/repository_repair_integration_test.go | 17 +-- internal/app/repository_repair_test.go | 7 +- internal/app/status.go | 5 + internal/app/status_corpus_test.go | 1 + internal/app/status_git.go | 3 +- internal/app/status_human.go | 12 +- internal/app/status_human_test.go | 2 + internal/app/status_result.go | 6 + internal/app/status_test.go | 7 ++ internal/app/sweep_session.go | 8 +- internal/config/config.go | 3 +- internal/render/link.go | 11 +- internal/render/link_test.go | 27 +++++ internal/repoguard/integration_push_test.go | 25 ++++- internal/repoguard/layout_guard_test.go | 106 ++++++++++++++++++ internal/reposetup/branch.go | 9 -- internal/reposetup/initplan.go | 5 +- internal/reposetup/initplan_test.go | 3 +- 55 files changed, 555 insertions(+), 151 deletions(-) create mode 100644 internal/app/layout_resolve.go create mode 100644 internal/app/layout_resolve_test.go delete mode 100644 internal/reposetup/branch.go diff --git a/internal/app/adr_ops.go b/internal/app/adr_ops.go index 8b0c57a45..1a4f761ef 100644 --- a/internal/app/adr_ops.go +++ b/internal/app/adr_ops.go @@ -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" ) @@ -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")), @@ -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")), diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index 0c645cd85..cbaaae54d 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -16,7 +16,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" ) @@ -302,8 +301,8 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string } 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(ac.recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_claim.go b/internal/app/change_claim.go index ed0a099a5..138f15ed1 100644 --- a/internal/app/change_claim.go +++ b/internal/app/change_claim.go @@ -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" ) @@ -254,8 +253,8 @@ func ChangeClaim(ctx context.Context, deps PlanningDeps, repoDir string, req Cha 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, @@ -332,8 +331,8 @@ func ChangeRefreshClaim(ctx context.Context, deps PlanningDeps, repoDir string, 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_claim_integration_test.go b/internal/app/change_claim_integration_test.go index 176e24530..9584b2eeb 100644 --- a/internal/app/change_claim_integration_test.go +++ b/internal/app/change_claim_integration_test.go @@ -9,6 +9,7 @@ package app import ( "context" "fmt" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/repository/transaction" "path" "strings" @@ -16,6 +17,35 @@ import ( "time" ) +// TestIntegrationRecordOpsClaimTransactionTargetsPinnedMetadataLayout pins the +// caller wiring (defaulted-param-hides-caller-wiring): the claim transaction's +// remote and target ref come from the PINNED layout, not a shared default. The +// pin carries a NON-default private layout, so a site that still hardcodes +// origin/docket reddens here even though every shared-mode test stays green. +func TestIntegrationRecordOpsClaimTransactionTargetsPinnedMetadataLayout(t *testing.T) { + repoDir := newRunTrackerRepo(t) + engine := &claimGateEngine{result: appliedGateResult(t, 3)} + deps := gateClaimDeps(t, engine, []StatusBlob{changeBlob(3, "widget", "feat", "high", "")}) + reader := deps.Reader.(*fakeReader) + reader.pin.Layout = layout.PrivateLayout("/c", "/r", "/d", "o-r") + + res := ChangeClaim(context.Background(), deps, repoDir, + ChangeClaimRequest{ID: 3, Revision: gateClaimRevision}) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied (%v)", res.Result, res.Findings) + } + if len(engine.calls) != 1 { + t.Fatalf("engine calls = %d, want 1", len(engine.calls)) + } + req := engine.calls[0] + if req.Remote != "dckt" { + t.Errorf("transaction remote = %q, want dckt (the pinned private metadata remote)", req.Remote) + } + if req.TargetRef != "refs/heads/dckt" { + t.Errorf("transaction target ref = %q, want refs/heads/dckt (the pinned private metadata branch)", req.TargetRef) + } +} + // TestIntegrationRecordOpsClaimRunContextInvalidRefusesBeforeTransaction: a supplied context that // matches no started run-tracker record is a typed refusal that writes nothing and never // degrades to an ungated claim (spec: "Never treat a supplied but invalid diff --git a/internal/app/change_create.go b/internal/app/change_create.go index 13e2247ac..ab6402700 100644 --- a/internal/app/change_create.go +++ b/internal/app/change_create.go @@ -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" ) @@ -246,8 +245,8 @@ func ChangeCreate(ctx context.Context, deps PlanningDeps, repoDir string, req Ch 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), Operation: op, diff --git a/internal/app/change_create_test.go b/internal/app/change_create_test.go index ff7977a89..3c1cafbca 100644 --- a/internal/app/change_create_test.go +++ b/internal/app/change_create_test.go @@ -70,6 +70,7 @@ func mainModePin(surfaces []string) StatusPin { return StatusPin{ DefaultBranch: "main", Config: config.Snapshot{Effective: planningTestConfig(surfaces)}, + Layout: testSharedLayout(), } } diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index fd5e1855a..9713e74ff 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -15,7 +15,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" ) @@ -256,8 +255,8 @@ func ChangeGroom(ctx context.Context, deps PlanningDeps, repoDir string, req Cha // the same fetched base, so the check is just as exact. 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(req.Path), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_halt.go b/internal/app/change_halt.go index 392c09154..aef258d70 100644 --- a/internal/app/change_halt.go +++ b/internal/app/change_halt.go @@ -12,7 +12,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" "github.com/danielhanold/docket/internal/workspace" @@ -215,8 +214,8 @@ func ChangeHalt(ctx context.Context, deps PlanningDeps, repoDir string, req Halt } 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, @@ -328,8 +327,8 @@ func ChangeResumeHalted(ctx context.Context, deps PlanningDeps, wdeps WorkspaceD } 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_implemented.go b/internal/app/change_implemented.go index 7034df3e3..94b0f06de 100644 --- a/internal/app/change_implemented.go +++ b/internal/app/change_implemented.go @@ -13,7 +13,6 @@ import ( "github.com/danielhanold/docket/internal/evidence" "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" ) @@ -336,8 +335,8 @@ func ChangeMarkImplemented(ctx context.Context, deps PlanningDeps, wdeps Workspa // (admitWorkflowMutation is not duplicated at engine boundaries). 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_kill.go b/internal/app/change_kill.go index 4922239d0..4207acab5 100644 --- a/internal/app/change_kill.go +++ b/internal/app/change_kill.go @@ -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" ) @@ -160,8 +159,8 @@ func ChangeKill(ctx context.Context, deps PlanningDeps, repoDir string, req Chan 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(req.Path), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_lifecycle.go b/internal/app/change_lifecycle.go index 47c05fef7..45ee79d62 100644 --- a/internal/app/change_lifecycle.go +++ b/internal/app/change_lifecycle.go @@ -12,7 +12,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/transaction" ) @@ -250,8 +249,8 @@ func executeChangeLifecycle(ctx context.Context, deps PlanningDeps, repoDir, opK 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(revision)}, diff --git a/internal/app/change_reclaim.go b/internal/app/change_reclaim.go index dbe67e184..8e98894b0 100644 --- a/internal/app/change_reclaim.go +++ b/internal/app/change_reclaim.go @@ -12,7 +12,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" "github.com/danielhanold/docket/internal/workspace" @@ -249,8 +248,8 @@ func ChangeReclaim(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, } 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_reconcile.go b/internal/app/change_reconcile.go index 5e926e04a..e271904b0 100644 --- a/internal/app/change_reconcile.go +++ b/internal/app/change_reconcile.go @@ -12,7 +12,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" ) @@ -190,8 +189,8 @@ func ChangeReconcile(ctx context.Context, deps PlanningDeps, repoDir string, req 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/change_relink.go b/internal/app/change_relink.go index a9e3fcfee..451e79ad0 100644 --- a/internal/app/change_relink.go +++ b/internal/app/change_relink.go @@ -12,7 +12,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" "github.com/danielhanold/docket/internal/workspace" @@ -232,8 +231,8 @@ func Relink(ctx context.Context, deps FinalizeDeps, repoDir string, req RelinkRe } res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ Repository: repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Remote: metadataRemote(pin.Layout), + TargetRef: metadataRef(pin.Layout), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.ExpectRevision)}, diff --git a/internal/app/derived_views_test.go b/internal/app/derived_views_test.go index ad36b7d68..767f0fb8a 100644 --- a/internal/app/derived_views_test.go +++ b/internal/app/derived_views_test.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -365,7 +366,7 @@ func TestDerivedViewFindingsAcceptsNoticeBearingBoard(t *testing.T) { if !strings.Contains(string(withNotice), "0099-broken.md") { t.Fatalf("fixture board lacks the notice:\n%s", withNotice) } - link := render.LinkContext{MetadataBranch: reposetup.MetadataBranchName} + link := render.LinkContext{MetadataBranch: layout.SharedName} for _, f := range derivedViewFindings(cfg, checkCorpus{records: recs, link: link, board: corpusFile{present: true, bytes: withNotice}}) { if f.Code == reposetup.CodeBoardStale { t.Errorf("notice-bearing board reported stale: %+v", f) @@ -464,7 +465,7 @@ func TestDerivedViewFindingsAcceptsNoticeBearingADRIndex(t *testing.T) { if err != nil { t.Fatalf("render: %v", err) } - link := render.LinkContext{MetadataBranch: reposetup.MetadataBranchName} + link := render.LinkContext{MetadataBranch: layout.SharedName} for _, f := range derivedViewFindings(cfg, checkCorpus{records: recs, link: link, adrIndex: corpusFile{present: true, bytes: withNotice}}) { if f.Code == reposetup.CodeADRIndexStale { t.Errorf("notice-bearing ADR index reported stale: %+v", f) diff --git a/internal/app/evidence_section.go b/internal/app/evidence_section.go index f78207bde..0c9bc5cc6 100644 --- a/internal/app/evidence_section.go +++ b/internal/app/evidence_section.go @@ -14,7 +14,6 @@ import ( "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/render" - "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository/transaction" ) @@ -156,8 +155,8 @@ func recordBuildEvidence(ctx context.Context, deps PlanningDeps, repoDir, opKey } 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(revision)}, diff --git a/internal/app/finalize_block.go b/internal/app/finalize_block.go index cda0fc3f7..e9c97624c 100644 --- a/internal/app/finalize_block.go +++ b/internal/app/finalize_block.go @@ -12,7 +12,6 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" "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" "github.com/danielhanold/docket/internal/workspace" @@ -312,8 +311,8 @@ func FinalizeBlock(ctx context.Context, deps FinalizeDeps, repoDir string, req B } res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ Repository: repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Remote: metadataRemote(pin.Layout), + TargetRef: metadataRef(pin.Layout), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, @@ -476,8 +475,8 @@ func FinalizeClearBlock(ctx context.Context, deps FinalizeDeps, repoDir string, } res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ Repository: repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Remote: metadataRemote(pin.Layout), + TargetRef: metadataRef(pin.Layout), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(recPath), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index 288a2c6f1..07faa1e8b 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -15,7 +15,6 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" "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" ) @@ -743,8 +742,8 @@ func closeoutStacked(ctx context.Context, deps FinalizeDeps, cc *closeoutContext } res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ Repository: cc.repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Remote: metadataRemote(cc.pin.Layout), + TargetRef: metadataRef(cc.pin.Layout), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(cc.change.Path()), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(cc.revision)}, @@ -806,8 +805,8 @@ func runCloseoutArchiveTransaction(ctx context.Context, deps FinalizeDeps, cc *c } res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ Repository: cc.repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Remote: metadataRemote(cc.pin.Layout), + TargetRef: metadataRef(cc.pin.Layout), Expected: expectations, Loader: newPlanningLoader(cc.eff), Scope: changeScope(id, cc.change.Path(), true, targetPaths...), diff --git a/internal/app/implementation_context.go b/internal/app/implementation_context.go index a4c1cc5e5..1df0d76d7 100644 --- a/internal/app/implementation_context.go +++ b/internal/app/implementation_context.go @@ -5,7 +5,6 @@ import ( "fmt" "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" ) @@ -302,7 +301,7 @@ func ContextImplementation(ctx context.Context, deps PlanningDeps, repoDir strin changeBlob := blobByPath[selected.Path()] bundle := &ImplementationContext{ - MetadataRef: reposetup.MetadataBranchName, + MetadataRef: pin.Layout.MetadataBranch, MetadataCommit: metadataRevision(pin), Change: ContextEntity{ Path: selected.Path(), diff --git a/internal/app/layout_resolve.go b/internal/app/layout_resolve.go new file mode 100644 index 000000000..061594117 --- /dev/null +++ b/internal/app/layout_resolve.go @@ -0,0 +1,61 @@ +package app + +import ( + "context" + "fmt" + "os" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" +) + +// This file resolves a discovered repository's per-repo layout — where its +// metadata branch is published and how the branch is spelled — and owns the +// two accessors every metadata transaction and metadata fetch reads it +// through. The mode is decided from STATE (layout.Detect), never from config. + +// remoteURLReader is the one Git read resolveLayout needs: origin's configured +// URL, which names a private repository's metadata store. *gitcli.Client +// satisfies it. +type remoteURLReader interface { + RemoteURL(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName) (string, error) +} + +// resolveLayout resolves repo's layout. A shared repository needs nothing but +// its common dir — origin's URL is never read, so an unreadable origin URL +// never breaks a shared repository. A private repository locates its store +// from origin's URL and the data home. Every failure is an external failure: +// the mode probe and the store location are facts docket cannot guess. +func resolveLayout(ctx context.Context, r remoteURLReader, repo gitcli.Repository) (layout.Layout, error) { + mode, err := layout.Detect(repo.CommonDir) + if err != nil { + return layout.Layout{}, fmt.Errorf("%w: resolving the repository's visibility mode: %v", ErrStatusExternal, err) + } + if mode == layout.Shared { + return layout.SharedLayout(repo.CommonDir, repo.PrimaryWorktree), nil + } + url, err := r.RemoteURL(ctx, repo, originRemote) + if err != nil { + return layout.Layout{}, fmt.Errorf("%w: private repository: reading origin's URL to locate the metadata store: %v", ErrStatusExternal, err) + } + ownerRepo, err := layout.OwnerRepo(url) + if err != nil { + return layout.Layout{}, fmt.Errorf("%w: private repository: locating the metadata store: %v", ErrStatusExternal, err) + } + dataHome, err := layout.DataHome(os.Getenv, os.UserHomeDir) + if err != nil { + return layout.Layout{}, fmt.Errorf("%w: private repository: locating the metadata store: %v", ErrStatusExternal, err) + } + return layout.PrivateLayout(repo.CommonDir, repo.PrimaryWorktree, dataHome, ownerRepo), nil +} + +// metadataRemote is the remote the metadata branch is fetched from and +// lease-pushed to: origin in a shared repository, the bare dckt remote in a +// private one. Every metadata transaction.Request names its Remote through +// this accessor (TestTransactionRequestsUseResolvedMetadataRemote). +func metadataRemote(l layout.Layout) gitcli.RemoteName { return gitcli.RemoteName(l.MetadataRemote) } + +// metadataRef is the fully-qualified metadata branch ref. Every metadata +// transaction.Request names its TargetRef through this accessor +// (TestNoIntegrationPushOutsidePRMerge). +func metadataRef(l layout.Layout) gitcli.RefName { return gitcli.RefName(l.MetadataRef()) } diff --git a/internal/app/layout_resolve_test.go b/internal/app/layout_resolve_test.go new file mode 100644 index 000000000..160c8acac --- /dev/null +++ b/internal/app/layout_resolve_test.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/testsupport" +) + +// fakeURLReader scripts origin's URL read for resolveLayout. calls counts the +// reads so a shared-mode test can prove no URL read happened. +type fakeURLReader struct { + url string + err error + calls int +} + +func (f *fakeURLReader) RemoteURL(_ context.Context, _ gitcli.Repository, _ gitcli.RemoteName) (string, error) { + f.calls++ + return f.url, f.err +} + +// TestResolveLayoutSharedNeedsNoOriginURL: with no /dckt directory the +// repository is shared, and resolving its layout never reads origin's URL (a +// shared repository must not depend on a readable origin URL). +func TestResolveLayoutSharedNeedsNoOriginURL(t *testing.T) { + common := testsupport.TempDir(t) + r := &fakeURLReader{err: errors.New("origin URL must not be read in shared mode")} + got, err := resolveLayout(context.Background(), r, gitcli.Repository{CommonDir: common, PrimaryWorktree: "/r"}) + if err != nil { + t.Fatalf("resolveLayout: %v", err) + } + if want := layout.SharedLayout(common, "/r"); got != want { + t.Fatalf("layout = %+v, want %+v", got, want) + } + if r.calls != 0 { + t.Errorf("shared resolution read origin's URL %d times, want 0", r.calls) + } + if metadataRemote(got) != "origin" || metadataRef(got) != "refs/heads/docket" { + t.Errorf("shared metadata remote/ref = %q/%q, want origin/refs/heads/docket", metadataRemote(got), metadataRef(got)) + } +} + +// TestResolveLayoutPrivateUsesOriginAndDataHome: a /dckt directory makes +// the repository private; the store is located from origin's URL and the data +// home, and the metadata remote and branch are both dckt. +func TestResolveLayoutPrivateUsesOriginAndDataHome(t *testing.T) { + common := testsupport.TempDir(t) + if err := os.Mkdir(filepath.Join(common, layout.PrivateName), 0o755); err != nil { + t.Fatal(err) + } + data := testsupport.TempDir(t) + t.Setenv("XDG_DATA_HOME", data) + r := &fakeURLReader{url: "git@github.com:O/R.git"} + got, err := resolveLayout(context.Background(), r, gitcli.Repository{CommonDir: common, PrimaryWorktree: "/r"}) + if err != nil { + t.Fatalf("resolveLayout: %v", err) + } + if want := layout.PrivateLayout(common, "/r", data, "o-r"); got != want { + t.Fatalf("layout = %+v, want %+v", got, want) + } + if got := metadataRemote(got); got != "dckt" { + t.Errorf("metadataRemote = %q, want dckt", got) + } + if got := metadataRef(got); got != "refs/heads/dckt" { + t.Errorf("metadataRef = %q, want refs/heads/dckt", got) + } +} + +// TestResolveLayoutPrivateWithoutOriginURLFails: a private repository whose +// origin URL cannot be read cannot locate its store — a clear external +// failure, never a guessed location. +func TestResolveLayoutPrivateWithoutOriginURLFails(t *testing.T) { + common := testsupport.TempDir(t) + if err := os.Mkdir(filepath.Join(common, layout.PrivateName), 0o755); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_DATA_HOME", testsupport.TempDir(t)) + r := &fakeURLReader{err: errors.New("no such remote")} + _, err := resolveLayout(context.Background(), r, gitcli.Repository{CommonDir: common, PrimaryWorktree: "/r"}) + if err == nil { + t.Fatal("resolveLayout succeeded without a readable origin URL, want an error") + } + if !errors.Is(err, ErrStatusExternal) { + t.Errorf("err = %v, want ErrStatusExternal", err) + } +} + +// TestResolveLayoutUnprobeableStateFails: a /dckt that exists but is not +// a directory is neither mode; resolution errors rather than guessing shared. +func TestResolveLayoutUnprobeableStateFails(t *testing.T) { + common := testsupport.TempDir(t) + if err := os.WriteFile(filepath.Join(common, layout.PrivateName), nil, 0o644); err != nil { + t.Fatal(err) + } + _, err := resolveLayout(context.Background(), &fakeURLReader{url: "git@github.com:o/r.git"}, gitcli.Repository{CommonDir: common, PrimaryWorktree: "/r"}) + if !errors.Is(err, ErrStatusExternal) { + t.Fatalf("err = %v, want ErrStatusExternal", err) + } +} diff --git a/internal/app/learning_ops.go b/internal/app/learning_ops.go index 26a088f9d..5ca8ca6ce 100644 --- a/internal/app/learning_ops.go +++ b/internal/app/learning_ops.go @@ -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/transaction" ) @@ -197,8 +196,8 @@ func LearningRecordOp(ctx context.Context, deps PlanningDeps, repoDir string, re 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), Operation: op, @@ -255,8 +254,8 @@ func LearningUpdate(ctx context.Context, deps PlanningDeps, repoDir string, req 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), Expected: []transaction.EntityExpectation{{ Path: gitcli.RepoPath(req.Path), Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, diff --git a/internal/app/learning_ops_test.go b/internal/app/learning_ops_test.go index ad5dcf039..efe3c3f5e 100644 --- a/internal/app/learning_ops_test.go +++ b/internal/app/learning_ops_test.go @@ -18,6 +18,7 @@ func learningsDisabledPin() StatusPin { return StatusPin{ DefaultBranch: "main", Config: config.Snapshot{Effective: eff}, + Layout: testSharedLayout(), } } diff --git a/internal/app/link_context.go b/internal/app/link_context.go index a66b92968..38f56a737 100644 --- a/internal/app/link_context.go +++ b/internal/app/link_context.go @@ -3,8 +3,8 @@ package app import ( "strings" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" - "github.com/danielhanold/docket/internal/reposetup" ) // This file owns the app layer's link-context derivation (change 0341). The @@ -46,8 +46,10 @@ func githubWebURL(remoteURL string) string { // linkContextOf is the sole constructor of the LinkContext app operations hand // to render: the repository web URL and the branch names travel together, so // no call site can silently omit a field again — the exact defect 0341 fixes. -// The metadata records always live on the fixed docket branch; the integration -// branch (0417: the ref for a done change's Plan/Results rows) comes from the +// The metadata records live on the pin's resolved metadata branch; a private +// repository's metadata branch has no web page, so PrivateMetadata makes its +// blob links render repo-relative (render.LinkContext.PrivateMetadata). The +// integration branch (0417: the ref for a done change's Plan/Results rows) comes from the // pin, falling back to the default branch exactly as finalize's // closeoutContext resolves it for git operations. func linkContextOf(pin StatusPin) render.LinkContext { @@ -57,7 +59,8 @@ func linkContextOf(pin StatusPin) render.LinkContext { } return render.LinkContext{ RepoWebURL: pin.RepoWebURL, - MetadataBranch: reposetup.MetadataBranchName, + MetadataBranch: pin.Layout.MetadataBranch, IntegrationBranch: integration, + PrivateMetadata: pin.Layout.Mode == layout.Private, } } diff --git a/internal/app/link_context_test.go b/internal/app/link_context_test.go index 3648c9316..f03dd2801 100644 --- a/internal/app/link_context_test.go +++ b/internal/app/link_context_test.go @@ -3,6 +3,7 @@ package app import ( "testing" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" ) @@ -43,6 +44,7 @@ func TestLinkContextOfCarriesBothFields(t *testing.T) { pin := StatusPin{ RepoWebURL: "https://github.com/owner/repo", IntegrationBranch: "main", + Layout: testSharedLayout(), } got := linkContextOf(pin) want := render.LinkContext{ @@ -58,6 +60,33 @@ func TestLinkContextOfCarriesBothFields(t *testing.T) { } } +// TestLinkContextOfCarriesPrivateLayout pins that the metadata branch and the +// private flag come from the PINNED layout (defaulted-param-hides-caller-wiring: +// a non-default private layout must arrive), so a private repository's +// metadata links render repo-relative instead of pointing at a web page that +// does not exist. Mutation probes: hardcode MetadataBranch, or drop the +// PrivateMetadata assignment, in linkContextOf — each reddens. +func TestLinkContextOfCarriesPrivateLayout(t *testing.T) { + pin := StatusPin{ + RepoWebURL: "https://github.com/owner/repo", + IntegrationBranch: "main", + Layout: layout.PrivateLayout("/c", "/r", "/d", "o-r"), + } + got := linkContextOf(pin) + want := render.LinkContext{ + RepoWebURL: "https://github.com/owner/repo", + MetadataBranch: "dckt", + IntegrationBranch: "main", + PrivateMetadata: true, + } + if got != want { + t.Fatalf("linkContextOf = %+v, want %+v", got, want) + } + if url := got.BlobURL("docs/x.md"); url != "" { + t.Fatalf("BlobURL on a private metadata branch = %q, want empty", url) + } +} + // TestLinkContextOfIntegrationFallsBackToDefaultBranch mirrors // closeoutContext's integration-branch fallback: an unresolved // IntegrationBranch on the pin falls back to DefaultBranch. diff --git a/internal/app/operational_context.go b/internal/app/operational_context.go index 883b9429d..83738f3b9 100644 --- a/internal/app/operational_context.go +++ b/internal/app/operational_context.go @@ -8,6 +8,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -84,7 +85,8 @@ type operationalContext struct { defaultRevision string integrationBranch string integrationRevision string - metadataRevision string // pinned tip of the fixed docket metadata branch + metadataRevision string // pinned tip of the resolved metadata branch + layout layout.Layout repoWebURL string snapshot config.Snapshot diags []config.Diagnostic @@ -92,15 +94,17 @@ type operationalContext struct { } // loadOperationalContext performs the spec's one ordered read: discover the -// canonical repository and resolve the remote default branch → resolve +// canonical repository and resolve its layout (resolveLayout) and the remote +// default branch → resolve // configuration from the pinned repository blob plus the machine layers (the // obsolete tombstone is diagnostic-only and cannot influence the result; an // invalid configuration fails closed here, BEFORE any topology classification) // → fetch and pin the integration revision → probe the classifier facts and // classify ONCE → refuse a legacy repository with the typed refusal → fetch -// and pin the fixed remote docket revision. The whole read is pinned against -// exact commit ids so a later concurrent fetch cannot change what any concern -// observes. +// and pin the metadata revision through the layout's metadata remote and +// branch (origin/docket in a shared repository, the bare dckt remote's dckt +// branch in a private one). The whole read is pinned against exact commit ids +// so a later concurrent fetch cannot change what any concern observes. func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir string) (operationalContext, error) { var oc operationalContext @@ -119,6 +123,11 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir } oc.repo = repo + oc.layout, err = resolveLayout(ctx, client, repo) + if err != nil { + return oc, err + } + ref, err := client.RemoteDefaultBranch(ctx, repo, originRemote) if err != nil { return oc, classifyGitFailure(err) @@ -129,7 +138,7 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir } oc.defaultBranch = defaultBranch - defaultRev, err := fetchPinnedRevision(ctx, client, repo, ref) + defaultRev, err := fetchPinnedRevision(ctx, client, repo, originRemote, ref) if err != nil { return oc, err } @@ -171,7 +180,7 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir oc.integrationBranch = eff.IntegrationBranch.Value oc.integrationRevision = defaultRev if oc.integrationBranch != defaultBranch { - oc.integrationRevision, err = fetchPinnedRevision(ctx, client, repo, gitcli.RefName(branchRefPrefix+oc.integrationBranch)) + oc.integrationRevision, err = fetchPinnedRevision(ctx, client, repo, originRemote, gitcli.RefName(branchRefPrefix+oc.integrationBranch)) if err != nil { return oc, err } @@ -192,22 +201,23 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir return oc, rerr } - // Pin the fixed metadata branch. For any admitted state without a remote - // docket branch (fresh, or an unproven probe), the fetch fails exactly the + // Pin the resolved metadata branch. For any admitted state without a remote + // metadata branch (fresh, or an unproven probe), the fetch fails exactly the // way the pre-gate PinContext failed — the gate adds no acceptance the // single-pin contract did not already have. - oc.metadataRevision, err = fetchPinnedRevision(ctx, client, repo, gitcli.RefName(branchRefPrefix+reposetup.MetadataBranchName)) + oc.metadataRevision, err = fetchPinnedRevision(ctx, client, repo, metadataRemote(oc.layout), metadataRef(oc.layout)) if err != nil { return oc, err } return oc, nil } -// fetchPinnedRevision fetches one fully-qualified branch through origin and +// fetchPinnedRevision fetches one fully-qualified branch through remote and // returns its pinned commit id, mapping any adapter failure to a status -// classification. -func fetchPinnedRevision(ctx context.Context, client *gitcli.Client, repo gitcli.Repository, branch gitcli.RefName) (string, error) { - rev, err := client.FetchBranch(ctx, repo, originRemote, branch) +// classification. Code branches come through origin; the metadata branch +// through metadataRemote of the resolved layout. +func fetchPinnedRevision(ctx context.Context, client *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, branch gitcli.RefName) (string, error) { + rev, err := client.FetchBranch(ctx, repo, remote, branch) if err != nil { return "", classifyGitFailure(err) } diff --git a/internal/app/repair_remedy_family_test.go b/internal/app/repair_remedy_family_test.go index 0feef4053..43bc72de9 100644 --- a/internal/app/repair_remedy_family_test.go +++ b/internal/app/repair_remedy_family_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -37,7 +38,7 @@ func TestRepairableFamilyRemediesNameRepairNotMigrate(t *testing.T) { // non-repairable derived: an unbalanced managed marker {path: "docs/changes/active/0004-malformed.md", bytes: []byte("---\n" + malformedFM + "---\n\n## Artifacts\n\n\n| Artifact | Link |\n\n## Why\n\nbody\n"), kind: repository.KindChange, location: repository.LocationActive}, }, - link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}, + link: render.LinkContext{MetadataBranch: layout.SharedName}, board: corpusFile{present: true, bytes: []byte("# Backlog\n\nstale bytes\n")}, } fm, extra := checkCorpusOutcome(cfg, corpus, nil) diff --git a/internal/app/repocheck_corpus_link_integration_test.go b/internal/app/repocheck_corpus_link_integration_test.go index ea7eb2ebd..baa932431 100644 --- a/internal/app/repocheck_corpus_link_integration_test.go +++ b/internal/app/repocheck_corpus_link_integration_test.go @@ -8,8 +8,8 @@ import ( "testing" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" - "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/testsupport" ) @@ -147,8 +147,8 @@ func TestIntegrationRepoCheckCorpusPinsDoneChangeToIntegrationBranch(t *testing. t.Errorf("a legacy done record rendered a Spec (merged) row; rendered block:\n%s", body) } // Guard the assertion itself: Plan/Results must not sit on the metadata branch. - if strings.Contains(body, "/blob/"+reposetup.MetadataBranchName+"/docs/superpowers/plans/") || - strings.Contains(body, "/blob/"+reposetup.MetadataBranchName+"/docs/results/") { + if strings.Contains(body, "/blob/"+layout.SharedName+"/docs/superpowers/plans/") || + strings.Contains(body, "/blob/"+layout.SharedName+"/docs/results/") { t.Errorf("Plan/Results resolved onto the metadata branch; rendered block:\n%s", body) } // A done change built on the metadata-branch flow: Plan/Results relative, diff --git a/internal/app/repocheck_integration_test.go b/internal/app/repocheck_integration_test.go index 5af1c3f4b..9d731aee9 100644 --- a/internal/app/repocheck_integration_test.go +++ b/internal/app/repocheck_integration_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -216,7 +217,7 @@ func (r *initRepo) writeDocketFileAndPush(t *testing.T, relPath, content, messag writeRepoFile(t, dotDocket, relPath, content) runGit(t, dotDocket, "add", "--", relPath) runGit(t, dotDocket, "commit", "-q", "-m", message) - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) } // TestIntegrationRepoCheckDerivedViewDrift proves a healthy repository whose diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 7d358511a..7d8a14156 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -14,6 +14,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -167,7 +168,7 @@ func checkRevisions(facts reposetup.Facts) map[string]string { // never to a false absence — so a probe that could not run can never let the // classifier read healthy. func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) { - metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout // Metadata root shape: the shared ownership verifier decides, at the FETCHED // remote docket tip, whether the tip's sole parentless-root lineage is a @@ -522,6 +523,7 @@ func readCheckCorpus(ctx context.Context, git *gitcli.Client, sc setupContext) ( corpus.link = linkContextOf(StatusPin{ RepoWebURL: githubWebURL(remoteURL), IntegrationBranch: sc.integrationBranch, + Layout: layout.SharedLayout(sc.repo.CommonDir, sc.repo.PrimaryWorktree), // Task 4: resolve through sc.layout }) return corpus, nil } diff --git a/internal/app/repository_check_derived_test.go b/internal/app/repository_check_derived_test.go index 92a321a08..f54a9c876 100644 --- a/internal/app/repository_check_derived_test.go +++ b/internal/app/repository_check_derived_test.go @@ -7,6 +7,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -63,7 +64,7 @@ func canonicalChangeRecord(t *testing.T, cfg config.Effective) (path string, can if out != 0 { // domain.LookupFound == 0 t.Fatalf("example change absent from snapshot (outcome %d)", out) } - body, err := render.ArtifactBlockContent(c, snap, render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}) + body, err := render.ArtifactBlockContent(c, snap, render.LinkContext{MetadataBranch: layout.SharedName}) if err != nil { t.Fatalf("render artifact block: %v", err) } @@ -91,7 +92,7 @@ func TestDerivedViewFindingsCleanCorpus(t *testing.T) { corpus := checkCorpus{ records: []corpusRecord{rec}, - link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}, + link: render.LinkContext{MetadataBranch: layout.SharedName}, board: corpusFile{present: true, bytes: board}, adrIndex: corpusFile{present: true, bytes: adr}, } @@ -109,7 +110,7 @@ func TestDerivedViewFindingsStaleBoard(t *testing.T) { rec := corpusRecord{path: path, bytes: canonical, kind: repository.KindChange, location: repository.LocationActive} corpus := checkCorpus{ records: []corpusRecord{rec}, - link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}, + link: render.LinkContext{MetadataBranch: layout.SharedName}, board: corpusFile{present: true, bytes: []byte("# Backlog\n\nstale bytes\n")}, } got := findingByCode(derivedViewFindings(cfg, corpus), reposetup.CodeBoardStale) @@ -132,7 +133,7 @@ func TestDerivedViewFindingsStaleArtifactLinks(t *testing.T) { kind: repository.KindChange, location: repository.LocationActive, } - corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}} + corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: layout.SharedName}} got := findingByCode(derivedViewFindings(cfg, corpus), reposetup.CodeArtifactLinksStale) if got == nil { t.Fatal("stale artifact-links block did not produce an artifact-links-stale finding") @@ -152,7 +153,7 @@ func TestDerivedViewFindingsMissingArtifactLinks(t *testing.T) { kind: repository.KindChange, location: repository.LocationActive, } - corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}} + corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: layout.SharedName}} got := findingByCode(derivedViewFindings(cfg, corpus), reposetup.CodeArtifactLinksMissing) if got == nil { t.Fatal("missing artifact-links block did not produce an artifact-links-missing finding") @@ -170,7 +171,7 @@ func TestDerivedViewFindingsMalformedMarkers(t *testing.T) { // A start marker with no matching end: document.Parse rejects it. src := []byte("---\n" + derivedChangeFM + "---\n\n## Artifacts\n\n\n| Artifact | Link |\n\n## Why\n\nbody\n") rec := corpusRecord{path: "docs/changes/active/0001-example.md", bytes: src, kind: repository.KindChange, location: repository.LocationActive} - corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}} + corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: layout.SharedName}} got := findingByCode(derivedViewFindings(cfg, corpus), reposetup.CodeArtifactLinksMalformed) if got == nil { t.Fatal("unbalanced markers did not produce an artifact-links-malformed finding") diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index a9d541dea..baea866db 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -10,6 +10,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -243,7 +244,7 @@ func gatherRepoFacts(ctx context.Context, p setupProber, in repoFactsInput) (rep // (adopt on the expected empty orphan, refuse on anything foreign), so the // authoritative adopt/conflict decision keys on the promised remote state, not // a gather-time proxy. - metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout rr, merr := p.ProbeRemoteBranch(ctx, in.repo, setupRemote(), metaRef) if merr != nil { sc.diagnostics = append(sc.diagnostics, setupDiag{Probe: "remote-metadata-branch", Err: merr}) diff --git a/internal/app/repository_init.go b/internal/app/repository_init.go index f4e49a3c9..322e46e7a 100644 --- a/internal/app/repository_init.go +++ b/internal/app/repository_init.go @@ -14,6 +14,7 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/harness" "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -104,7 +105,7 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { // verified init-equivalent lineage AT ITS TIP (descendants preserved), or // refuse a migration-seeded, foreign, or unreadable branch — the remote is // never overwritten or reset to the seed. - metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout metadataTip, createdRemote, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, metaRef, sc.sourceRevision, sc.defaultBranch) if refusal != nil { return *refusal diff --git a/internal/app/repository_migrate.go b/internal/app/repository_migrate.go index ba95dd9c5..083d32e45 100644 --- a/internal/app/repository_migrate.go +++ b/internal/app/repository_migrate.go @@ -12,6 +12,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" ) @@ -250,7 +251,7 @@ func migrateRoute(ctx context.Context, git *gitcli.Client, facts reposetup.Facts // Fetch the published metadata branch so its object is local, then re-read // its tip authoritatively (ls-remote gave only the id at gather time). The // branch decision keys on this re-read, never a local proxy. - rev, ferr := git.FetchBranch(ctx, sc.repo, setupRemote(), gitcli.RefName(branchRefPrefix+reposetup.MetadataBranchName)) + rev, ferr := git.FetchBranch(ctx, sc.repo, setupRemote(), gitcli.RefName(branchRefPrefix+layout.SharedName)) // Task 4: resolve through sc.layout if ferr != nil { r := migrateExternalFailure(reposetup.StateConflict, "re-reading the published metadata branch", ferr) return phaseRefuse, &r @@ -484,7 +485,7 @@ func repairedCandidateErrors(cfg config.Effective, mr migrationRepairs) []string func migrateExecute(ctx context.Context, git *gitcli.Client, hooks setupHooks, facts reposetup.Facts, sc setupContext, plan reposetup.MigrationPlan, mr migrationRepairs, phase migratePhase) RepositoryMigrateResult { sourceRevision := sc.sourceRevision sourceOID := gitcli.ObjectID(sourceRevision) - docketRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout integrationRef := gitcli.RefName(branchRefPrefix + sc.integrationBranch) // Full-corpus validation BEFORE any branch change. A non-repairable error in @@ -749,7 +750,7 @@ func migrateResumeLocal(ctx context.Context, git *gitcli.Client, hooks setupHook } metadataTip := gitcli.ObjectID(sc.metadataTip) integrationTip := gitcli.ObjectID(sc.sourceRevision) - docketRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout pendingLocal := migrateLocalFinish(ctx, git, facts, sc, docketRef, metadataTip, integrationTip, integrationTip) return migrateApplied(sc, metadataTip, integrationTip, sc.sourceRevision, []string{}, []string{}, nil, pendingLocal) } @@ -974,7 +975,7 @@ func migratePreviewText(sc setupContext, plan reposetup.MigrationPlan, mr migrat fmt.Fprintf(&b, " repository: %s\n", sc.repo.PrimaryWorktree) fmt.Fprintf(&b, " remote: %s\n", setupRemote()) fmt.Fprintf(&b, " integration: %s @ %s\n", sc.integrationBranch, sourceRevision) - fmt.Fprintf(&b, " destination: %s (orphan metadata branch)\n", reposetup.MetadataBranchName) + fmt.Fprintf(&b, " destination: %s (orphan metadata branch)\n", layout.SharedName) // Task 4: resolve through sc.layout fmt.Fprintf(&b, " copy set: %s\n", strings.Join(plan.Copy.Prefixes, ", ")) fmt.Fprintf(&b, " removal set: %s/, %s, %s\n", plan.Removal.ActiveDir, plan.Removal.BoardPath, plan.Removal.ReadmePath) fmt.Fprintf(&b, " config edit: %s\n", migrateConfigEditText(plan.ConfigEdit)) diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 8a820fc2a..0ca30753c 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -9,6 +9,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -527,7 +528,7 @@ func buildPrepareContext(cfg config.Effective, sc setupContext, f reposetup.Fact DefaultBranchRevision: f.RemoteDefaultBranch.Tip, IntegrationBranch: sc.integrationBranch, IntegrationBranchRevision: f.RemoteIntegration.Tip, - MetadataBranch: reposetup.MetadataBranchName, + MetadataBranch: layout.SharedName, // Task 4: resolve through sc.layout MetadataBranchRevision: f.RemoteMetadata.Tip, MetadataWorktreePath: filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName), ChangesDir: cfg.ChangesDir.Value, @@ -608,7 +609,7 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // PresenceUnknown and never read. Every probe maps its own error to the safe Unknown // value so a probe that could not run can never let the router read healthy. func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) prepareHolder { - metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout // Metadata root shape at the FETCHED remote docket tip (fetch it first so the // object is local on a clone that never fetched docket). The shared ownership @@ -683,7 +684,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // re-read remote state so a lost-response retry converges by re-reading topology. func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, verdict prepareVerdict) error { worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) - metaRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout switch verdict.action { case prepareActionAttach: return prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict) diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index a3cb5a8e0..d9a468476 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -10,6 +10,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -568,8 +569,8 @@ func TestRepositoryPrepareContextFieldsTyped(t *testing.T) { if c.IntegrationBranchRevision != "i0" { t.Errorf("integration_branch_revision = %q, want the pinned i0", c.IntegrationBranchRevision) } - if c.MetadataBranch != reposetup.MetadataBranchName { - t.Errorf("metadata_branch = %q, want %q", c.MetadataBranch, reposetup.MetadataBranchName) + if c.MetadataBranch != layout.SharedName { + t.Errorf("metadata_branch = %q, want %q", c.MetadataBranch, layout.SharedName) } if c.MetadataBranchRevision != "m9" { t.Errorf("metadata_branch_revision = %q, want the pinned m9", c.MetadataBranchRevision) diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index 0f53266a2..4037dd711 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -10,6 +10,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" ) @@ -263,7 +264,7 @@ func manualReviewLine(code, path, message string) string { // carried forward from the pinned tree. func executeRepositoryRepair(ctx context.Context, git *gitcli.Client, sc setupContext, metadataTip string, plan repositoryRepairPlan) RepositoryRepairResult { tipOID := gitcli.ObjectID(metadataTip) - docketRef := gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName) + docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout ops := make([]gitcli.TreeOp, 0, len(plan.files)) for _, f := range plan.files { @@ -394,7 +395,7 @@ func repairPreviewText(sc setupContext, metadataTip string, plan repositoryRepai fmt.Fprintf(&b, "docket repository repair — preview\n") fmt.Fprintf(&b, " repository: %s\n", sc.repo.PrimaryWorktree) fmt.Fprintf(&b, " remote: %s\n", setupRemote()) - fmt.Fprintf(&b, " metadata: %s @ %s\n", reposetup.MetadataBranchName, metadataTip) + fmt.Fprintf(&b, " metadata: %s @ %s\n", layout.SharedName, metadataTip) // Task 4: resolve through sc.layout fmt.Fprintf(&b, " repairs:\n") for _, f := range plan.frontmatter { fmt.Fprintf(&b, " [%s] %s\n", f.Code, f.Path) diff --git a/internal/app/repository_repair_integration_test.go b/internal/app/repository_repair_integration_test.go index bc8488844..4b4cfce10 100644 --- a/internal/app/repository_repair_integration_test.go +++ b/internal/app/repository_repair_integration_test.go @@ -11,6 +11,7 @@ import ( "testing" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -46,21 +47,21 @@ func (r *initRepo) publishHealthyDrift(t *testing.T) { writeRepoFile(t, dotDocket, "docs/changes/active/0001-example.md", staleRepairRecord()) runGit(t, dotDocket, "add", "--", "docs/changes/BOARD.md", "docs/changes/active/0001-example.md") runGit(t, dotDocket, "commit", "-q", "-m", "publish stale derived views") - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) } // currentDocketTip returns the remote docket branch tip via an independent git // oracle. func currentDocketTip(t *testing.T, r *initRepo) string { t.Helper() - runGit(t, r.invocation, "fetch", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, r.invocation, "fetch", "-q", "origin", string(layout.SharedName)) return strings.TrimSpace(runGit(t, r.invocation, "rev-parse", "FETCH_HEAD")) } // showDocketFile reads a file from the remote docket branch tip. func showDocketFile(t *testing.T, r *initRepo, relPath string) string { t.Helper() - runGit(t, r.invocation, "fetch", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, r.invocation, "fetch", "-q", "origin", string(layout.SharedName)) return runGit(t, r.invocation, "show", "FETCH_HEAD:"+relPath) } @@ -105,7 +106,7 @@ func (r *initRepo) publishFrontmatterDrift(t *testing.T) { writeRepoFile(t, dotDocket, repairEmptyPath, repairArchivedRecord(4, "archived-empty", "claimed_at:")) runGit(t, dotDocket, "add", "--", repairStampPath, repairEmptyPath) runGit(t, dotDocket, "commit", "-q", "-m", "publish archived claim stamps") - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) } // TestIntegrationRepoRepairPreviewListsBothKindsAndWritesNothing proves the @@ -284,7 +285,7 @@ func TestIntegrationRepoRepairConvertsAbsoluteSameBranchLinksOnce(t *testing.T) writeRepoFile(t, dotDocket, convertLegacyPath, convertLegacyRecord()) runGit(t, dotDocket, "add", "--", convertADRPath, convertActivePath, convertLegacyPath) runGit(t, dotDocket, "commit", "-q", "-m", "publish records with absolute same-branch links") - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) stale := map[string]bool{} for _, f := range r.runCheck(t).Findings { @@ -383,7 +384,7 @@ func TestIntegrationRepoRepairRefusesLegacyRepository(t *testing.T) { if !strings.Contains(res.HumanText(), "docket repository migrate") { t.Errorf("legacy refusal must name `docket repository migrate`: %q", res.HumanText()) } - if _, err := tryGit(r.origin, "rev-parse", "--verify", "refs/heads/"+string(reposetup.MetadataBranchName)); err == nil { + if _, err := tryGit(r.origin, "rev-parse", "--verify", "refs/heads/"+string(layout.SharedName)); err == nil { t.Errorf("a legacy refusal must not create the docket branch") } } @@ -543,7 +544,7 @@ func (r *initRepo) publishPRBacklinkRecords(t *testing.T) { writeRepoFile(t, dotDocket, prRepairOKPath, prArchivedRecord(364, "fixed-change", 251)) runGit(t, dotDocket, "add", "--", prRepairPath, prRepairOKPath) runGit(t, dotDocket, "commit", "-q", "-m", "publish archived PR-bearing records") - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) } func (r *initRepo) runPRRepair(t *testing.T, gh RepairGitHub, o RepairOptions) RepositoryRepairResult { @@ -601,7 +602,7 @@ func TestIntegrationRepoRepairPRBacklinksSkipsUnreadable(t *testing.T) { writeRepoFile(t, dotDocket, third, prArchivedRecord(365, "third-change", 252)) runGit(t, dotDocket, "add", "--", third) runGit(t, dotDocket, "commit", "-q", "-m", "third") - runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + runGit(t, dotDocket, "push", "-q", "origin", string(layout.SharedName)) gh := &fakeRepairGitHub{t: t, bodies: map[int]string{ 250: prBodyNaming(prRepairActive), diff --git a/internal/app/repository_repair_test.go b/internal/app/repository_repair_test.go index a69f39fa4..7e15e46ae 100644 --- a/internal/app/repository_repair_test.go +++ b/internal/app/repository_repair_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -21,7 +22,7 @@ func repairArchivedDone(claimedLine string) []byte { const repairArchivedPath = "docs/changes/archive/2026-01-02-0003-archived-change.md" func repairLink() render.LinkContext { - return render.LinkContext{MetadataBranch: reposetup.MetadataBranchName} + return render.LinkContext{MetadataBranch: layout.SharedName} } // TestPlanRepositoryRepairComposesFrontmatterAndArtifactLinks proves a record that @@ -364,7 +365,7 @@ func TestComposeDerivedRepairBoardBytes(t *testing.T) { path, canonical := canonicalChangeRecord(t, cfg) rec := corpusRecord{path: path, bytes: canonical, kind: repository.KindChange, location: repository.LocationActive} snap, _ := buildCorpusSnapshot(cfg, []corpusRecord{rec}) - corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}} + corpus := checkCorpus{records: []corpusRecord{rec}, link: render.LinkContext{MetadataBranch: layout.SharedName}} recByPath := map[string]corpusRecord{rec.path: rec} got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, boardCorpusPath(cfg)) @@ -386,7 +387,7 @@ func TestComposeDerivedRepairArtifactLinksBytes(t *testing.T) { path := "docs/changes/active/0001-example.md" stale := corpusRecord{path: path, bytes: changeRecordBytes(derivedChangeFM, ""), kind: repository.KindChange, location: repository.LocationActive} snap, _ := buildCorpusSnapshot(cfg, []corpusRecord{stale}) - corpus := checkCorpus{records: []corpusRecord{stale}, link: render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}} + corpus := checkCorpus{records: []corpusRecord{stale}, link: render.LinkContext{MetadataBranch: layout.SharedName}} recByPath := map[string]corpusRecord{path: stale} got, err := composeDerivedRepairBytes(setupContext{cfg: cfg}, snap, corpus, recByPath, path) diff --git a/internal/app/status.go b/internal/app/status.go index ee1a88506..2b27e2a65 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/repository" ) @@ -73,6 +74,9 @@ type StatusPin struct { RepoWebURL string Config config.Snapshot ConfigDiags []config.Diagnostic + // Layout is the repository's resolved per-repo layout (resolveLayout): the + // metadata remote and branch every metadata fetch and transaction targets. + Layout layout.Layout } // StatusBlob is one record read from a pinned source: its declared kind and @@ -791,6 +795,7 @@ func contextFromPin(pin StatusPin) StatusContext { IntegrationBranch: pin.IntegrationBranch, IntegrationRevision: pin.IntegrationRevision, MetadataRevision: pin.MetadataRevision, + MetadataBranch: pin.Layout.MetadataBranch, } } diff --git a/internal/app/status_corpus_test.go b/internal/app/status_corpus_test.go index e80f08c94..e5ae0d621 100644 --- a/internal/app/status_corpus_test.go +++ b/internal/app/status_corpus_test.go @@ -136,6 +136,7 @@ func TestStatusCorpusFrozenSemantics(t *testing.T) { MetadataRevision: "cccccccccccccccccccccccccccccccccccccccc", Config: cfg, ConfigDiags: cfgDiags, + Layout: testSharedLayout(), } fake := &fakeReader{pin: pin, corpus: blobs, facts: domain.NewBranchFacts(nil)} diff --git a/internal/app/status_git.go b/internal/app/status_git.go index 19681a53f..d21298935 100644 --- a/internal/app/status_git.go +++ b/internal/app/status_git.go @@ -69,6 +69,7 @@ func (r *gitStatusReader) PinContext(ctx context.Context, repoDir string) (Statu Config: oc.snapshot, ConfigDiags: oc.diags, RepoWebURL: oc.repoWebURL, + Layout: oc.layout, }, nil } @@ -210,7 +211,7 @@ func (r *gitStatusReader) openSource(ctx context.Context, rev string) (gitcli.Ob } // metadataRevision is the revision the metadata source reads from: the pinned -// tip of the fixed docket metadata branch. +// tip of the resolved metadata branch. func metadataRevision(pin StatusPin) string { return pin.MetadataRevision } diff --git a/internal/app/status_human.go b/internal/app/status_human.go index 719db744e..c54bee4eb 100644 --- a/internal/app/status_human.go +++ b/internal/app/status_human.go @@ -4,8 +4,6 @@ import ( "fmt" "strconv" "strings" - - "github.com/danielhanold/docket/internal/reposetup" ) // StatusResult is a fully-computed operation outcome the presenter renders, so @@ -26,14 +24,14 @@ var _ OperationResult = StatusResult{} func (r StatusResult) HumanText() string { var b strings.Builder - // 1. short revisions. Go v1 has one metadata topology (the fixed orphan - // `docket` branch), so there is no mode line; the metadata branch line is - // explanatory identity rendered unconditionally from the fixed branch name - // and the pinned revision (change 0363). + // 1. short revisions. Go v1 has one metadata topology (an orphan metadata + // branch), so there is no mode line; the metadata branch line is + // explanatory identity rendered from the pinned layout's branch name and + // the pinned revision (change 0363). fmt.Fprintf(&b, "default branch: %s @ %s\n", r.Context.DefaultBranch, shortRevision(r.Context.DefaultBranchRevision)) fmt.Fprintf(&b, "integration branch: %s @ %s\n", r.Context.IntegrationBranch, shortRevision(r.Context.IntegrationRevision)) if r.Context.MetadataRevision != "" { - fmt.Fprintf(&b, "metadata branch: %s @ %s\n", reposetup.MetadataBranchName, shortRevision(r.Context.MetadataRevision)) + fmt.Fprintf(&b, "metadata branch: %s @ %s\n", r.Context.MetadataBranch, shortRevision(r.Context.MetadataRevision)) } // Failure results carry no report body — surface the classification instead. diff --git a/internal/app/status_human_test.go b/internal/app/status_human_test.go index 0d2241c07..5250039aa 100644 --- a/internal/app/status_human_test.go +++ b/internal/app/status_human_test.go @@ -21,6 +21,7 @@ func healthyStatusResult() StatusResult { IntegrationBranch: "develop", IntegrationRevision: "b2c3d4e5f6a1FEEDFACE", MetadataRevision: "c3d4e5f6a1b2CAFED00D", + MetadataBranch: "docket", }, Summary: StatusSummary{ TotalChanges: 5, ActiveChanges: 3, DisplayedChanges: 3, @@ -116,6 +117,7 @@ func TestStatusHumanTextFilteredEmptyProjection(t *testing.T) { IntegrationBranch: "develop", IntegrationRevision: "a1a1a1a1a1a1", MetadataRevision: "b2b2b2b2b2b2", + MetadataBranch: "docket", }, Summary: StatusSummary{ TotalChanges: 4, ActiveChanges: 3, DisplayedChanges: 0, diff --git a/internal/app/status_result.go b/internal/app/status_result.go index 9806ed843..e74608360 100644 --- a/internal/app/status_result.go +++ b/internal/app/status_result.go @@ -16,6 +16,12 @@ type StatusContext struct { IntegrationBranch string `json:"integration_branch"` IntegrationRevision string `json:"integration_revision"` MetadataRevision string `json:"metadata_revision,omitempty"` + // MetadataBranch is the pinned layout's metadata branch, rendered on the + // human report's metadata line. It is deliberately not serialized: change + // 0363 removed metadata_branch from the status protocol + // (TestStatusContextProtocolOmitsModeFields), and skills key on + // repository.prepare's context for the branch instead. + MetadataBranch string `json:"-"` } // StatusSummary carries the counts a reader would otherwise recompute from the diff --git a/internal/app/status_test.go b/internal/app/status_test.go index cf197723c..a72e8f42a 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -10,6 +10,7 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/repository" ) @@ -82,6 +83,10 @@ func testConfig(t *testing.T) config.Snapshot { return *snap } +// testSharedLayout is the shared-mode layout every hand-built test pin carries, +// as PinContext would resolve it for a repository without /dckt. +func testSharedLayout() layout.Layout { return layout.SharedLayout("/repo/.git", "/repo") } + func docketPin(t *testing.T) StatusPin { t.Helper() return StatusPin{ @@ -91,6 +96,7 @@ func docketPin(t *testing.T) StatusPin { IntegrationRevision: "2222222222222222222222222222222222222222", MetadataRevision: "3333333333333333333333333333333333333333", Config: testConfig(t), + Layout: testSharedLayout(), } } @@ -103,6 +109,7 @@ func mainPin(t *testing.T) StatusPin { IntegrationRevision: "4444444444444444444444444444444444444444", MetadataRevision: "4444444444444444444444444444444444444444", Config: testConfig(t), + Layout: testSharedLayout(), } } diff --git a/internal/app/sweep_session.go b/internal/app/sweep_session.go index b7a5673fb..4760cdead 100644 --- a/internal/app/sweep_session.go +++ b/internal/app/sweep_session.go @@ -7,7 +7,6 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/reposetup" ) // This file is the maintenance sweep's metadata-observation seam. The sweep no @@ -56,12 +55,13 @@ func newSweepSession(client *gitcli.Client, repo gitcli.Repository, base StatusP } // Prepare produces one fresh metadata observation: exactly one fetch of the -// fixed metadata branch, a pin that copies the captured setup with only its -// metadata tip replaced, and one corpus read at that revision. No pre-fetch +// captured pin's resolved metadata branch (through its metadata remote), a pin +// that copies the captured setup with only its metadata tip replaced, and one +// corpus read at that revision. No pre-fetch // probe, no cache, no TTL — the bounded failure-classification behavior is // FetchBranch's own (its diagnostic probe rides inside the same call). func (s *sweepSession) Prepare(ctx context.Context) (*sweepObservation, error) { - rev, err := fetchPinnedRevision(ctx, s.client, s.repo, gitcli.RefName(branchRefPrefix+reposetup.MetadataBranchName)) + rev, err := fetchPinnedRevision(ctx, s.client, s.repo, metadataRemote(s.base.Layout), metadataRef(s.base.Layout)) if err != nil { // A failed metadata fetch is the classified error, never a stale // fallback and never an inferred absence. diff --git a/internal/config/config.go b/internal/config/config.go index 76a40ab03..54e05c6c2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -117,7 +117,8 @@ const ( // Diagnostics, never here. type Effective struct { // metadata_branch is gone (change 0363): it is an obsolete tombstone, never - // resolved policy. The metadata branch is fixed at reposetup.MetadataBranchName. + // resolved policy. The metadata branch comes from the repository's layout + // (layout.Layout.MetadataBranch), decided from state, never from config. IntegrationBranch Value[string] `json:"integration_branch"` // auto already resolved ChangesDir Value[string] `json:"changes_dir"` ADRsDir Value[string] `json:"adrs_dir"` diff --git a/internal/render/link.go b/internal/render/link.go index 27cfba69c..d0f0c7fa7 100644 --- a/internal/render/link.go +++ b/internal/render/link.go @@ -21,6 +21,11 @@ type LinkContext struct { // rows); empty falls back to MetadataBranch at the BlobURLOnBranch // boundary, so a malformed URL is unrepresentable. IntegrationBranch string + // PrivateMetadata marks a private repository, whose metadata branch is + // published to a local bare remote rather than the web host: no web page + // exists for it, so every blob link on MetadataBranch renders "" (the + // repo-relative fallback). Links on other branches are unaffected. + PrivateMetadata bool } // BlobURL returns the blob URL on the metadata branch — correct for records @@ -33,7 +38,8 @@ func (l LinkContext) BlobURL(repoRelPath string) string { // BlobURLOnBranch returns RepoWebURL + "/blob/" + branch + "/" + repoRelPath, // or "" when RepoWebURL is empty. An empty branch falls back to // MetadataBranch: the defensive default for a caller whose branch is -// unresolvable, never a malformed "/blob//" URL. +// unresolvable, never a malformed "/blob//" URL. A private repository's +// metadata branch (after that fallback) has no web page, so it also yields "". func (l LinkContext) BlobURLOnBranch(repoRelPath, branch string) string { if l.RepoWebURL == "" { return "" @@ -41,5 +47,8 @@ func (l LinkContext) BlobURLOnBranch(repoRelPath, branch string) string { if branch == "" { branch = l.MetadataBranch } + if l.PrivateMetadata && branch == l.MetadataBranch { + return "" + } return l.RepoWebURL + "/blob/" + branch + "/" + repoRelPath } diff --git a/internal/render/link_test.go b/internal/render/link_test.go index 2ed4fdd4a..e71e09462 100644 --- a/internal/render/link_test.go +++ b/internal/render/link_test.go @@ -46,6 +46,33 @@ func TestBlobURLOnBranch(t *testing.T) { } } +// TestPrivateMetadataRendersNoMetadataURL: a private repository's metadata +// branch is published to a local bare remote, never to the web host, so a blob +// URL on it would point at nothing. Metadata-branch links (explicit or via the +// empty-branch fallback) render "" — the repo-relative fallback — while a link +// on the integration branch, which does live on the web host, stays absolute. +func TestPrivateMetadataRendersNoMetadataURL(t *testing.T) { + l := render.LinkContext{ + RepoWebURL: "https://github.com/o/r", + MetadataBranch: "dckt", + IntegrationBranch: "main", + PrivateMetadata: true, + } + const p = "docs/changes/active/0001-x.md" + if got := l.BlobURL(p); got != "" { + t.Errorf("BlobURL = %q, want empty for a private metadata branch", got) + } + if got := l.BlobURLOnBranch(p, ""); got != "" { + t.Errorf("BlobURLOnBranch(empty) = %q, want empty for a private metadata branch", got) + } + if got := l.BlobURLOnBranch(p, "dckt"); got != "" { + t.Errorf("BlobURLOnBranch(dckt) = %q, want empty for a private metadata branch", got) + } + if got, want := l.BlobURLOnBranch(p, "main"), "https://github.com/o/r/blob/main/"+p; got != want { + t.Errorf("BlobURLOnBranch(main) = %q, want %q", got, want) + } +} + func TestBlobURLOnBranchWithoutRepoWebURL(t *testing.T) { l := render.LinkContext{RepoWebURL: "", MetadataBranch: "docket", IntegrationBranch: "main"} if got := l.BlobURLOnBranch("docs/x.md", "fix/some-change"); got != "" { diff --git a/internal/repoguard/integration_push_test.go b/internal/repoguard/integration_push_test.go index 4f1e5a573..7c5d1b047 100644 --- a/internal/repoguard/integration_push_test.go +++ b/internal/repoguard/integration_push_test.go @@ -5,8 +5,10 @@ package repoguard // every non-test Go file under internal/app: // // 1. Every transaction.Request composite literal's TargetRef expression must -// mention MetadataBranchName — the engine may only commit to the metadata -// branch. +// be a call of metadataRef — the accessor over the pinned layout's +// metadata branch — so the engine may only commit to the metadata branch. +// Its Remote twin is TestTransactionRequestsUseResolvedMetadataRemote +// (layout_guard_test.go). // 2. Every direct PushLease / PushCreateLease call must sit in a function on // pushAllowlist. Each entry names why it may push; only migrateExecute // touches the integration branch, for the one-time human-run legacy @@ -82,9 +84,10 @@ func analyzeIntegrationPush(fset *token.FileSet, files map[string]*ast.File) int rep.violations = append(rep.violations, rel+": a transaction.Request literal at line "+strconv.Itoa(pos.Line)+" sets no TargetRef, so its target cannot be proven to be the metadata branch") return true } - var buf bytes.Buffer - if err := printer.Fprint(&buf, fset, target); err != nil || !strings.Contains(buf.String(), "MetadataBranchName") { - rep.violations = append(rep.violations, rel+": a transaction.Request targets "+buf.String()+", not the metadata branch (MetadataBranchName)") + if !isMetadataRefCall(target) { + var buf bytes.Buffer + _ = printer.Fprint(&buf, fset, target) + rep.violations = append(rep.violations, rel+": a transaction.Request targets "+buf.String()+", not the metadata branch (metadataRef())") } return true }) @@ -116,6 +119,16 @@ func analyzeIntegrationPush(fset *token.FileSet, files map[string]*ast.File) int return rep } +// isMetadataRefCall reports whether e is a call of the metadataRef accessor. +func isMetadataRefCall(e ast.Expr) bool { + call, ok := e.(*ast.CallExpr) + if !ok { + return false + } + fn, ok := call.Fun.(*ast.Ident) + return ok && fn.Name == "metadataRef" +} + // isTransactionRequest reports whether a composite literal's type is the // selector transaction.Request. func isTransactionRequest(e ast.Expr) bool { @@ -166,7 +179,7 @@ func TestNoIntegrationPushOutsidePRMergeDetects(t *testing.T) { src := `package app func good() { - _ = transaction.Request{TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName)} + _ = transaction.Request{TargetRef: metadataRef(pin.Layout)} } func bad() { diff --git a/internal/repoguard/layout_guard_test.go b/internal/repoguard/layout_guard_test.go index ab7e35d10..27e585cbe 100644 --- a/internal/repoguard/layout_guard_test.go +++ b/internal/repoguard/layout_guard_test.go @@ -16,8 +16,10 @@ package repoguard // Mutation-tested: re-inlining "docket" in workspace.workspacesRoot reddens it. import ( + "bytes" "go/ast" "go/parser" + "go/printer" "go/token" "io/fs" "path/filepath" @@ -81,6 +83,110 @@ func isPathJoin(call *ast.CallExpr) bool { return ok && (pkg.Name == "filepath" || pkg.Name == "path") } +// TestTransactionRequestsUseResolvedMetadataRemote pins that every metadata +// transaction pushes to the RESOLVED metadata remote. In a private repository +// the metadata branch lives on the bare dckt remote, not origin, so a +// transaction.Request that names origin (or any other fixed spelling) would +// lease-push metadata to the wrong place. +// +// Shape: every transaction.Request composite literal in a non-test .go file +// under internal/app must set its Remote field to a call of metadataRemote — +// the one accessor over the pinned layout. Keyed on that positive shape, so +// originRemote, setupRemote(), a string literal, and an omitted Remote all +// redden alike. Its TargetRef twin is shape 1 of +// TestNoIntegrationPushOutsidePRMerge (integration_push_test.go). +// +// Mutation-tested: putting `Remote: originRemote` back in ChangeClaim reddens +// it (and TestIntegrationRecordOpsClaimTransactionTargetsPinnedMetadataLayout). +func TestTransactionRequestsUseResolvedMetadataRemote(t *testing.T) { + root := guardRoot(t) + fset := token.NewFileSet() + literals := 0 + var violations []string + walkGo(t, root, func(rel string) { + if dirOf(rel) != "internal/app" { + return + } + file, err := parser.ParseFile(fset, filepath.Join(root, filepath.FromSlash(rel)), nil, parser.SkipObjectResolution) + if err != nil { + t.Fatalf("parse %s: %v", rel, err) + } + n, v := metadataRemoteViolations(fset, rel, file) + literals += n + violations = append(violations, v...) + }) + // Population floor: an empty enumeration passes every negative. + if literals < 20 { + t.Fatalf("population floor: found %d transaction.Request literals under internal/app (want >= 20); the literal detector drifted", literals) + } + for _, v := range violations { + t.Error(v) + } +} + +// metadataRemoteViolations reports each transaction.Request literal in file +// whose Remote is not a metadataRemote(...) call, and how many literals it saw. +func metadataRemoteViolations(fset *token.FileSet, rel string, file *ast.File) (int, []string) { + literals := 0 + var violations []string + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok || !isTransactionRequest(lit.Type) { + return true + } + literals++ + pos := rel + ":" + strconv.Itoa(fset.Position(lit.Pos()).Line) + var remote ast.Expr + for _, el := range lit.Elts { + if kv, ok := el.(*ast.KeyValueExpr); ok { + if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "Remote" { + remote = kv.Value + } + } + } + if remote == nil { + violations = append(violations, pos+": a transaction.Request sets no Remote; it must be metadataRemote()") + return true + } + if call, ok := remote.(*ast.CallExpr); ok { + if fn, ok := call.Fun.(*ast.Ident); ok && fn.Name == "metadataRemote" { + return true + } + } + var buf bytes.Buffer + _ = printer.Fprint(&buf, fset, remote) + violations = append(violations, pos+": a transaction.Request pushes to Remote "+buf.String()+", not the resolved metadata remote (metadataRemote())") + return true + }) + return literals, violations +} + +// TestTransactionRequestsUseResolvedMetadataRemoteDetects is the guard's own +// negative control: originRemote, a string literal, and an omitted Remote each +// produce a violation, and the metadataRemote shape produces none. +func TestTransactionRequestsUseResolvedMetadataRemoteDetects(t *testing.T) { + src := `package app + +func good() { _ = transaction.Request{Remote: metadataRemote(pin.Layout)} } +func origin() { _ = transaction.Request{Remote: originRemote} } +func literal() { _ = transaction.Request{Remote: "dckt"} } +func missing() { _ = transaction.Request{TargetRef: metadataRef(pin.Layout)} } +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "internal/app/x.go", src, 0) + if err != nil { + t.Fatal(err) + } + n, v := metadataRemoteViolations(fset, "internal/app/x.go", f) + if n != 4 { + t.Errorf("literals = %d, want 4", n) + } + joined := strings.Join(v, "\n") + if len(v) != 3 || !strings.Contains(joined, "originRemote") || !strings.Contains(joined, `"dckt"`) || !strings.Contains(joined, "sets no Remote") { + t.Errorf("violations = %v, want exactly originRemote, the literal, and the omitted Remote", v) + } +} + func TestNoInlineStateFolderSpelling(t *testing.T) { root := guardRoot(t) fset := token.NewFileSet() diff --git a/internal/reposetup/branch.go b/internal/reposetup/branch.go deleted file mode 100644 index 6c3e53b04..000000000 --- a/internal/reposetup/branch.go +++ /dev/null @@ -1,9 +0,0 @@ -package reposetup - -// MetadataBranchName is the ONE spelling of the fixed metadata branch (change -// 0363). Go v1 supports a single repository topology: planning metadata lives -// on this orphan branch while code lands on the independently resolved -// integration_branch. The constant is owned beside the topology classifier — -// the authority on what the branch means — and is the only spelling production -// code may use; no configuration selects another value. -const MetadataBranchName = "docket" diff --git a/internal/reposetup/initplan.go b/internal/reposetup/initplan.go index 77f9392cf..490c30c58 100644 --- a/internal/reposetup/initplan.go +++ b/internal/reposetup/initplan.go @@ -7,6 +7,7 @@ import ( "path/filepath" "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposeed" ) @@ -62,8 +63,8 @@ func PlanInit(cfg config.Effective, f Facts, tree TestTree, primaryRoot string) return InitPlan{ RootSubject: initRootSubject, RootTrailers: Receipt{Operation: OpInitRoot}.Trailers(), - MetadataRef: "refs/heads/" + MetadataBranchName, - WorktreePath: filepath.Join(primaryRoot, ".docket"), + MetadataRef: "refs/heads/" + layout.SharedName, + WorktreePath: filepath.Join(primaryRoot, layout.SharedWorktreeDir), GitignorePath: ".gitignore", SeedInput: reposeed.PlanInput{ WorktreeRoot: primaryRoot, diff --git a/internal/reposetup/initplan_test.go b/internal/reposetup/initplan_test.go index edccf9bf2..d3d0dd958 100644 --- a/internal/reposetup/initplan_test.go +++ b/internal/reposetup/initplan_test.go @@ -23,8 +23,7 @@ func freshFacts() Facts { func initCfg() config.Effective { // metadata_branch is gone from config.Effective (obsolete tombstone, 0363); - // PlanInit pins the fixed metadata branch itself (bridged in initplan.go until - // Task 3 sources it from reposetup.MetadataBranchName). + // PlanInit pins the shared metadata branch itself (layout.SharedName). return config.Effective{ ChangesDir: config.Value[string]{Value: "docs/changes"}, ADRsDir: config.Value[string]{Value: "docs/adrs"}, From b18d1eecd5897e9097a3f28f0a53289f49d07bc3 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:08:18 -0400 Subject: [PATCH 05/20] refactor(repository): resolve metadata remote, branch, and worktree through the layout The repository command family (check, init, prepare, repair, migrate) and the shared fact gatherer now read the metadata remote, branch ref, and worktree path from setupContext.layout, resolved from state by gatherSetupFacts right after Discover (a resolution failure is an external failure). loadOperationalContext hands its pinned layout to gatherRepoFacts. - delete docketWorktreeName; docketWorktreeFact takes the worktree path - reposetup.Facts gains MetadataWorktreeRef and Private; every worktree finding names the resolved ref (.docket when shared) - InterruptedFastForwardMessage/Remedy become functions of the worktree - PrepareContext gains metadata_remote and metadata_tracking_ref - publishOrAdoptMetadataRoot and publishSeed take the metadata remote - prepareAttachFresh creates the worktree's parent first - integration-branch work (migrate prune, primary sync) stays on origin --- internal/app/operational_context.go | 1 + .../app/repoinplaceff_integration_test.go | 2 +- internal/app/repository_check.go | 11 +- internal/app/repository_check_test.go | 6 +- internal/app/repository_facts.go | 76 +++++++++---- internal/app/repository_facts_test.go | 56 ++++++++++ internal/app/repository_init.go | 14 +-- internal/app/repository_migrate.go | 29 ++--- internal/app/repository_prepare.go | 101 +++++++++++------- internal/app/repository_prepare_test.go | 28 ++++- internal/app/repository_repair.go | 14 +-- internal/reposetup/classify_test.go | 1 + internal/reposetup/health.go | 86 ++++++++------- internal/reposetup/health_test.go | 44 +++++++- internal/reposetup/probe.go | 6 +- 15 files changed, 330 insertions(+), 145 deletions(-) diff --git a/internal/app/operational_context.go b/internal/app/operational_context.go index 83738f3b9..d2eee2429 100644 --- a/internal/app/operational_context.go +++ b/internal/app/operational_context.go @@ -195,6 +195,7 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir defaultTip: defaultRev, integrationTip: oc.integrationRevision, cfg: eff, + layout: oc.layout, }) oc.classification = reposetup.Classify(facts) if rerr := operationalRefusal(oc.classification, facts); rerr != nil { diff --git a/internal/app/repoinplaceff_integration_test.go b/internal/app/repoinplaceff_integration_test.go index e9e09ba21..531b036c2 100644 --- a/internal/app/repoinplaceff_integration_test.go +++ b/internal/app/repoinplaceff_integration_test.go @@ -218,7 +218,7 @@ func TestIntegrationRepoInPlaceFFInterruptedReadsDirty(t *testing.T) { wantInterrupted := func(where string, f reposetup.Finding) { t.Helper() - if f.Message != reposetup.InterruptedFastForwardMessage || f.Remedy != reposetup.InterruptedFastForwardRemedy { + if f.Message != reposetup.InterruptedFastForwardMessage(".docket") || f.Remedy != reposetup.InterruptedFastForwardRemedy(".docket") { t.Errorf("%s: dirty finding %+v, want the interrupted fast-forward message and remedy", where, f) } if strings.HasPrefix(f.Remedy, "Commit") || !strings.Contains(f.Remedy, "git -C .docket reset --merge HEAD") { diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index 7d8a14156..af38e21cf 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -14,7 +14,6 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -168,7 +167,7 @@ func checkRevisions(facts reposetup.Facts) map[string]string { // never to a false absence — so a probe that could not run can never let the // classifier read healthy. func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) { - metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + metaRef := metadataRef(sc.layout) // Metadata root shape: the shared ownership verifier decides, at the FETCHED // remote docket tip, whether the tip's sole parentless-root lineage is a @@ -186,7 +185,7 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac // local. Fetch the branch first so both the ownership probe and // gatherFrontmatterFindings read a local object. The fetch updates only a // remote-tracking ref (the read-only contract excludes refs/remotes/*). - rev, ferr := git.FetchBranch(ctx, sc.repo, setupRemote(), metaRef) + rev, ferr := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), metaRef) if ferr != nil { // A fetch error is unknown even if an older object happens to be available // locally: never fall back to the ls-remote tip and never prove ownership @@ -208,11 +207,11 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac f.LocalMetadata.Presence = reposetup.PresenceAbsent } // any other error leaves LocalMetadata Unknown - // The .docket metadata worktree: clean and hooks disabled; its synchronized fact + // The metadata worktree: clean and hooks disabled; its synchronized fact // comes from applyLocalMetadataSync. Probed only when the worktree is actually // present. if f.DocketWorktree.Presence == reposetup.PresencePresent { - worktreeDir := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) + worktreeDir := sc.layout.MetadataWorktree f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation, f.DocketWorktree.InterruptedFastForward = worktreeCleanState(ctx, git, worktreeDir) f.DocketWorktree.HooksOff = hooksOffPresence(ctx, git, worktreeDir) } @@ -523,7 +522,7 @@ func readCheckCorpus(ctx context.Context, git *gitcli.Client, sc setupContext) ( corpus.link = linkContextOf(StatusPin{ RepoWebURL: githubWebURL(remoteURL), IntegrationBranch: sc.integrationBranch, - Layout: layout.SharedLayout(sc.repo.CommonDir, sc.repo.PrimaryWorktree), // Task 4: resolve through sc.layout + Layout: sc.layout, }) return corpus, nil } diff --git a/internal/app/repository_check_test.go b/internal/app/repository_check_test.go index e629f27cc..16f343f2b 100644 --- a/internal/app/repository_check_test.go +++ b/internal/app/repository_check_test.go @@ -248,7 +248,7 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { } got := reposetup.EvaluateHealth( reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{"metadata-worktree-dirty"}}, - reposetup.Facts{DocketWorktree: reposetup.WorktreeFact{UnfinishedOperation: op}}, nil) + reposetup.Facts{MetadataWorktreeRef: ".docket", DocketWorktree: reposetup.WorktreeFact{UnfinishedOperation: op}}, nil) if len(got) != 1 { t.Fatalf("dirty (operation=%v): check findings = %+v", op, got) } @@ -268,14 +268,14 @@ func TestLocalMetadataRefusalsMatchPrepare(t *testing.T) { } got := reposetup.EvaluateHealth( reposetup.Classification{State: reposetup.StateConflict, Reasons: []string{"metadata-worktree-dirty"}}, - reposetup.Facts{DocketWorktree: reposetup.WorktreeFact{InterruptedFastForward: true}}, nil) + reposetup.Facts{MetadataWorktreeRef: ".docket", DocketWorktree: reposetup.WorktreeFact{InterruptedFastForward: true}}, nil) if len(got) != 1 { t.Fatalf("interrupted fast-forward: check findings = %+v", got) } if got[0].Code != v.finding.Code || got[0].Message != v.finding.Message || got[0].Remedy != v.finding.Remedy { t.Errorf("interrupted fast-forward: check %+v != prepare %+v", got[0], *v.finding) } - if got[0].Message != reposetup.InterruptedFastForwardMessage || got[0].Remedy != reposetup.InterruptedFastForwardRemedy { + if got[0].Message != reposetup.InterruptedFastForwardMessage(".docket") || got[0].Remedy != reposetup.InterruptedFastForwardRemedy(".docket") { t.Errorf("interrupted fast-forward: finding %+v, want the interrupted message and remedy", got[0]) } } diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index baea866db..14c7b3b1f 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -96,6 +96,9 @@ type setupProber interface { ChangedPaths(ctx context.Context, dir string) ([]gitcli.PathChange, error) ResolveRef(ctx context.Context, repo gitcli.Repository, ref gitcli.RefName) (gitcli.ObjectID, error) ListWorktrees(ctx context.Context, repo gitcli.Repository) ([]gitcli.WorktreeInfo, error) + // RemoteURL reads origin's URL, which locates a private repository's + // metadata store (resolveLayout); a shared repository never calls it. + RemoteURL(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName) (string, error) } // setupDiag records one probe that could not be resolved: the probe name and the @@ -109,23 +112,23 @@ type setupDiag struct { // setupContext carries everything the later init/migrate/check phases need that // is not part of the pure Facts value: the resolved configuration, the -// discovered repository, the resolved branch names, and the exact pinned -// revisions captured at gather time. The pinned integration revision is the -// authoritative source revision every later phase reads and keys on. +// discovered repository, its resolved layout (where the metadata branch is +// published, how it is spelled, and where its worktree sits), the resolved +// branch names, and the exact pinned revisions captured at gather time. The +// pinned integration revision is the authoritative source revision every later +// phase reads and keys on. type setupContext struct { cfg config.Effective repo gitcli.Repository + layout layout.Layout defaultBranch string integrationBranch string sourceRevision string // pinned authoritative integration tip - metadataTip string // remote docket tip when present, else "" + metadataTip string // remote metadata tip when present, else "" primaryHead string // primary worktree HEAD read by primaryAtTipPresence, else "" diagnostics []setupDiag } -// docketWorktreeName is the fixed root-level metadata worktree directory. -const docketWorktreeName = ".docket" - // GatherSetupFacts discovers the canonical repository, resolves configuration // (a mutation preflight for init/migrate, a read preflight for check), fetches // the authoritative remote default/integration/metadata refs, and fills a @@ -150,6 +153,17 @@ func gatherSetupFacts(ctx context.Context, p setupProber, repoDir string, forMut } sc.repo = repo + // The layout is decided from state right after discovery: every metadata + // probe below reads its remote, branch, and worktree path from it. A failure + // (an unprobeable mode, or a private repository whose store cannot be + // located) is an external failure, never a guessed shared layout. + lay, err := resolveLayout(ctx, p, repo) + if err != nil { + return f, sc, err + } + sc.layout = lay + applyLayoutFacts(&f, lay) + // The remote default branch is the first authoritative read. A failure here // means the remote is not configured or not reachable; both leave the // remote-configured and default-branch probes Unknown (safe), and the @@ -179,6 +193,7 @@ func gatherSetupFacts(ctx context.Context, p setupProber, repoDir string, forMut repo: repo, defaultBranch: defaultBranch, cfg: eff, + layout: lay, }) return f, sc, nil } @@ -194,6 +209,19 @@ type repoFactsInput struct { defaultTip string // pre-pinned default tip; "" → fetched here integrationTip string // pre-pinned integration tip; "" → fetched here cfg config.Effective + layout layout.Layout // the caller's resolved layout; every metadata probe reads it +} + +// applyLayoutFacts records the layout facts the pure classifier and its +// findings read: how findings name the metadata worktree (the shared +// `.docket` spelling, or the private checkout path) and whether the layout is +// private. +func applyLayoutFacts(f *reposetup.Facts, l layout.Layout) { + f.Private = l.Mode == layout.Private + f.MetadataWorktreeRef = layout.SharedWorktreeDir + if f.Private { + f.MetadataWorktreeRef = l.MetadataWorktree + } } // gatherRepoFacts is the ONE set of Git topology probes behind both the @@ -207,6 +235,8 @@ func gatherRepoFacts(ctx context.Context, p setupProber, in repoFactsInput) (rep var sc setupContext sc.repo = in.repo sc.cfg = in.cfg + sc.layout = in.layout + applyLayoutFacts(&f, in.layout) f.RemoteConfigured = reposetup.PresencePresent f.RemoteDefaultBranch.Presence = reposetup.PresencePresent @@ -238,14 +268,16 @@ func gatherRepoFacts(ctx context.Context, p setupProber, in repoFactsInput) (rep } } - // The remote docket branch presence is an authoritative ls-remote probe. Its + // The remote metadata branch presence is an authoritative ls-remote probe on + // the layout's metadata remote (origin when shared, the bare dckt remote when + // private). Its // root shape is deliberately NOT inspected here: init publishes with // create-only protection and re-reads the exact remote shape at that boundary // (adopt on the expected empty orphan, refuse on anything foreign), so the // authoritative adopt/conflict decision keys on the promised remote state, not // a gather-time proxy. - metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout - rr, merr := p.ProbeRemoteBranch(ctx, in.repo, setupRemote(), metaRef) + metaRef := metadataRef(in.layout) + rr, merr := p.ProbeRemoteBranch(ctx, in.repo, metadataRemote(in.layout), metaRef) if merr != nil { sc.diagnostics = append(sc.diagnostics, setupDiag{Probe: "remote-metadata-branch", Err: merr}) } else { @@ -276,9 +308,9 @@ func gatherRepoFacts(ctx context.Context, p setupProber, in repoFactsInput) (rep f.PrimaryAtRemoteTip = primaryAtTipPresence(ctx, p, in.repo, sc.sourceRevision, &sc) } - // The .docket path: absent, a correctly registered owned worktree on the - // metadata branch, or a foreign directory / conflicting registration. - f.DocketWorktree = docketWorktreeFact(ctx, p, in.repo, metaRef) + // The metadata worktree path: absent, a correctly registered owned worktree + // on the metadata branch, or a foreign directory / conflicting registration. + f.DocketWorktree = docketWorktreeFact(ctx, p, in.repo, in.layout.MetadataWorktree, metaRef) return f, sc } @@ -372,8 +404,8 @@ func isDocketManagedWorktreePath(rel string) bool { if docketManagedWorktreePaths[rel] { return true } - return rel == docketWorktreeName || - strings.HasPrefix(rel, docketWorktreeName+"/") || + return rel == layout.SharedWorktreeDir || + strings.HasPrefix(rel, layout.SharedWorktreeDir+"/") || strings.HasPrefix(rel, ".worktrees/") } @@ -404,12 +436,12 @@ func primaryAtTipPresence(ctx context.Context, p setupProber, repo gitcli.Reposi return reposetup.PresenceUnknown } -// docketWorktreeFact probes the .docket path: absent, a correctly registered -// owned worktree on the metadata branch, or a foreign directory / conflicting -// registration. A list error leaves the fact at its safe zero value (Unknown / -// not-foreign) with no false foreign flag. -func docketWorktreeFact(ctx context.Context, p setupProber, repo gitcli.Repository, metaRef gitcli.RefName) reposetup.WorktreeFact { - worktreePath := filepath.Join(repo.PrimaryWorktree, docketWorktreeName) +// docketWorktreeFact probes the metadata worktree path (the layout's +// MetadataWorktree): absent, a correctly registered owned worktree on the +// metadata branch, or a foreign directory / conflicting registration. A list +// error leaves the fact at its safe zero value (Unknown / not-foreign) with no +// false foreign flag. +func docketWorktreeFact(ctx context.Context, p setupProber, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName) reposetup.WorktreeFact { info, err := os.Lstat(worktreePath) if err != nil { if os.IsNotExist(err) { @@ -441,7 +473,7 @@ func docketWorktreeFact(ctx context.Context, p setupProber, repo gitcli.Reposito fact.Foreign = true return fact } - // A .docket directory git does not know as a worktree of this repo is foreign. + // A directory git does not know as a worktree of this repo is foreign. fact.Foreign = true return fact } diff --git a/internal/app/repository_facts_test.go b/internal/app/repository_facts_test.go index b4c1086dd..e4a82316a 100644 --- a/internal/app/repository_facts_test.go +++ b/internal/app/repository_facts_test.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -29,6 +30,11 @@ type fakeSetupProber struct { head gitcli.ObjectID resolveErr error worktrees []gitcli.WorktreeInfo + + // probedRemote/probedRef record the metadata ls-remote probe's target so a + // test can prove the resolved layout (not a shared default) reached it. + probedRemote gitcli.RemoteName + probedRef gitcli.RefName } func (f *fakeSetupProber) Discover(ctx context.Context, opts gitcli.DiscoverOptions) (gitcli.Repository, error) { @@ -41,8 +47,12 @@ func (f *fakeSetupProber) FetchBranch(ctx context.Context, repo gitcli.Repositor return f.fetchRev, f.fetchErr } func (f *fakeSetupProber) ProbeRemoteBranch(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName, ref gitcli.RefName) (gitcli.RemoteRef, error) { + f.probedRemote, f.probedRef = remote, ref return f.probe, f.probeErr } +func (f *fakeSetupProber) RemoteURL(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName) (string, error) { + return "git@github.com:o/r.git", nil +} func (f *fakeSetupProber) OpenObjectSource(ctx context.Context, repo gitcli.Repository, rev gitcli.Revision) (gitcli.ObjectSource, error) { return nil, errors.New("fake: OpenObjectSource unreachable in this test") } @@ -154,6 +164,52 @@ func TestGatherSetupFactsAbsentHarnessesDoNotAuthorize(t *testing.T) { } } +// TestGatherSetupFactsThreadsResolvedLayout proves the gatherer resolves the +// layout from state and threads a NON-default one through every metadata probe +// (learning defaulted-param-hides-caller-wiring): with a /dckt directory +// the metadata branch is probed on the dckt remote, the worktree fact names the +// private checkout, and setupContext carries the private layout. +func TestGatherSetupFactsThreadsResolvedLayout(t *testing.T) { + root := setupProberRepoDir(t, "integration_branch: main\n") + data := testsupport.TempDir(t) + t.Setenv("XDG_DATA_HOME", data) + common := filepath.Join(root, ".git") + if err := os.MkdirAll(filepath.Join(common, "dckt"), 0o755); err != nil { + t.Fatal(err) + } + p := &fakeSetupProber{ + repo: gitcli.Repository{PrimaryWorktree: root, CommonDir: common}, + defaultRef: gitcli.RefName("refs/heads/main"), + probe: gitcli.RemoteRef{State: gitcli.RemoteRefAbsent}, + } + f, sc, err := gatherSetupFacts(context.Background(), p, root, true) + if err != nil { + t.Fatalf("gatherSetupFacts: %v", err) + } + want := layout.PrivateLayout(common, root, data, "o-r") + if sc.layout != want { + t.Errorf("sc.layout = %+v, want %+v", sc.layout, want) + } + if p.probedRemote != "dckt" || p.probedRef != "refs/heads/dckt" { + t.Errorf("metadata probe = %s %s, want dckt refs/heads/dckt", p.probedRemote, p.probedRef) + } + if !f.Private || f.MetadataWorktreeRef != want.MetadataWorktree { + t.Errorf("facts Private=%v MetadataWorktreeRef=%q, want true %q", f.Private, f.MetadataWorktreeRef, want.MetadataWorktree) + } + + // The shared layout records the `.docket` spelling and probes origin. + if err := os.Remove(filepath.Join(common, "dckt")); err != nil { + t.Fatal(err) + } + f, sc, err = gatherSetupFacts(context.Background(), p, root, true) + if err != nil { + t.Fatalf("gatherSetupFacts (shared): %v", err) + } + if sc.layout != layout.SharedLayout(common, root) || p.probedRemote != "origin" || p.probedRef != "refs/heads/docket" || f.Private || f.MetadataWorktreeRef != ".docket" { + t.Errorf("shared: layout %+v probe %s %s Private=%v ref=%q", sc.layout, p.probedRemote, p.probedRef, f.Private, f.MetadataWorktreeRef) + } +} + func hasDiag(diags []setupDiag, probe string) bool { for _, d := range diags { if d.Probe == probe && d.Err != nil { diff --git a/internal/app/repository_init.go b/internal/app/repository_init.go index 322e46e7a..4419e33b8 100644 --- a/internal/app/repository_init.go +++ b/internal/app/repository_init.go @@ -14,7 +14,6 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/harness" "github.com/danielhanold/docket/internal/install" - "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -105,8 +104,8 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { // verified init-equivalent lineage AT ITS TIP (descendants preserved), or // refuse a migration-seeded, foreign, or unreadable branch — the remote is // never overwritten or reset to the seed. - metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout - metadataTip, createdRemote, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, metaRef, sc.sourceRevision, sc.defaultBranch) + metaRef := metadataRef(sc.layout) + metadataTip, createdRemote, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, metadataRemote(sc.layout), metaRef, sc.sourceRevision, sc.defaultBranch) if refusal != nil { return *refusal } @@ -114,7 +113,7 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { // Effect 3: create or adopt the local branch and attach the persistent // root-level .docket worktree. Idempotent — a re-run finds it already // registered and does nothing. - worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) + worktreePath := sc.layout.MetadataWorktree createdWorktree, err := ensureMetadataWorktree(ctx, d.Git, sc.repo, worktreePath, metaRef, metadataTip) if err != nil { return repositoryExternalFailure(OperationRepositoryInit, cls.State, "attaching the .docket worktree", err) @@ -304,9 +303,10 @@ func initGuard(facts reposetup.Facts) (reposetup.Classification, *RepositoryOpRe // foreign, or unreadable branch. The create-only push is never widened to an // overwriting lease — that is the guard the create-only protection mutation probe // strips — and adoption of the reread tip never re-pushes or resets to the seed. +// remote and metaRef are the layout's metadata remote and branch ref. // sourceRevision (the pinned integration tip) and defaultBranch thread into the // shared ownership verifier for the lost-lease inspection. -func publishOrAdoptMetadataRoot(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, metaRef gitcli.RefName, sourceRevision, defaultBranch string) (gitcli.ObjectID, bool, *RepositoryOpResult) { +func publishOrAdoptMetadataRoot(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, metaRef gitcli.RefName, sourceRevision, defaultBranch string) (gitcli.ObjectID, bool, *RepositoryOpResult) { emptyTree, err := git.EmptyTreeOID(ctx, repo) if err != nil { r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateFresh, "resolving the empty tree", err) @@ -319,7 +319,7 @@ func publishOrAdoptMetadataRoot(ctx context.Context, git *gitcli.Client, repo gi return "", false, &r } - outcome, err := git.PushCreateLease(ctx, repo, setupRemote(), metaRef, root) + outcome, err := git.PushCreateLease(ctx, repo, remote, metaRef, root) if err != nil { r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateFresh, "publishing the metadata branch", err) return "", false, &r @@ -334,7 +334,7 @@ func publishOrAdoptMetadataRoot(ctx context.Context, git *gitcli.Client, repo gi // adopt a verified init-equivalent lineage (descendants preserved) or refuse // a migration-seeded, foreign, or unreadable branch. The create-only push // never overwrote it, and adoption of the reread tip must not either. - if _, ferr := git.FetchBranch(ctx, repo, setupRemote(), metaRef); ferr != nil { + if _, ferr := git.FetchBranch(ctx, repo, remote, metaRef); ferr != nil { r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateConflict, "reading the published metadata branch", ferr) return "", false, &r } diff --git a/internal/app/repository_migrate.go b/internal/app/repository_migrate.go index 083d32e45..dab869962 100644 --- a/internal/app/repository_migrate.go +++ b/internal/app/repository_migrate.go @@ -12,7 +12,6 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" ) @@ -251,7 +250,7 @@ func migrateRoute(ctx context.Context, git *gitcli.Client, facts reposetup.Facts // Fetch the published metadata branch so its object is local, then re-read // its tip authoritatively (ls-remote gave only the id at gather time). The // branch decision keys on this re-read, never a local proxy. - rev, ferr := git.FetchBranch(ctx, sc.repo, setupRemote(), gitcli.RefName(branchRefPrefix+layout.SharedName)) // Task 4: resolve through sc.layout + rev, ferr := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), metadataRef(sc.layout)) if ferr != nil { r := migrateExternalFailure(reposetup.StateConflict, "re-reading the published metadata branch", ferr) return phaseRefuse, &r @@ -485,7 +484,7 @@ func repairedCandidateErrors(cfg config.Effective, mr migrationRepairs) []string func migrateExecute(ctx context.Context, git *gitcli.Client, hooks setupHooks, facts reposetup.Facts, sc setupContext, plan reposetup.MigrationPlan, mr migrationRepairs, phase migratePhase) RepositoryMigrateResult { sourceRevision := sc.sourceRevision sourceOID := gitcli.ObjectID(sourceRevision) - docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + docketRef := metadataRef(sc.layout) integrationRef := gitcli.RefName(branchRefPrefix + sc.integrationBranch) // Full-corpus validation BEFORE any branch change. A non-repairable error in @@ -544,7 +543,7 @@ func migrateExecute(ctx context.Context, git *gitcli.Client, hooks setupHooks, f if err != nil { return migrateExternalFailure(reposetup.StateLegacy, "creating the metadata seed commit", err) } - tip, refusal := publishSeed(ctx, git, sc.repo, docketRef, seedCommit) + tip, refusal := publishSeed(ctx, git, sc.repo, metadataRemote(sc.layout), docketRef, seedCommit) if refusal != nil { return *refusal } @@ -645,7 +644,7 @@ func migrateExecute(ctx context.Context, git *gitcli.Client, hooks setupHooks, f // the tree disagrees — a tampered seed) it refuses as a conflict, destroying // nothing. func reconcileResumeSeed(ctx context.Context, git *gitcli.Client, hooks setupHooks, sc setupContext, docketRef gitcli.RefName, seedReceipt reposetup.Receipt, seedTree gitcli.ObjectID) (gitcli.ObjectID, *RepositoryMigrateResult) { - rev, err := git.FetchBranch(ctx, sc.repo, setupRemote(), docketRef) + rev, err := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), docketRef) if err != nil { r := migrateExternalFailure(reposetup.StateConflict, "re-reading the published metadata seed", err) return "", &r @@ -722,7 +721,7 @@ func reconcileResumeSeed(ctx context.Context, git *gitcli.Client, hooks setupHoo r := migrateExternalFailure(reposetup.StateLegacy, "updating the metadata seed under its owned lease (interrupted before publication)", err) return "", &r } - out, perr := git.PushLease(ctx, sc.repo, setupRemote(), docketRef, newSeed, metadataTip) + out, perr := git.PushLease(ctx, sc.repo, metadataRemote(sc.layout), docketRef, newSeed, metadataTip) if perr != nil { r := migrateExternalFailure(reposetup.StateLegacy, "updating the metadata seed under its owned lease", perr) return "", &r @@ -750,7 +749,7 @@ func migrateResumeLocal(ctx context.Context, git *gitcli.Client, hooks setupHook } metadataTip := gitcli.ObjectID(sc.metadataTip) integrationTip := gitcli.ObjectID(sc.sourceRevision) - docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + docketRef := metadataRef(sc.layout) pendingLocal := migrateLocalFinish(ctx, git, facts, sc, docketRef, metadataTip, integrationTip, integrationTip) return migrateApplied(sc, metadataTip, integrationTip, sc.sourceRevision, []string{}, []string{}, nil, pendingLocal) } @@ -779,8 +778,9 @@ func mergeMigrateDebris(res RepositoryMigrateResult, debris setupDebrisReport) R // the create-only push is never widened to an overwriting lease (this is the // create-only protection mutation probe target). Resume-time adoption of an // already-published seed is owned by reconcileResumeSeed, not by this function. -func publishSeed(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, docketRef gitcli.RefName, seedCommit gitcli.ObjectID) (gitcli.ObjectID, *RepositoryMigrateResult) { - outcome, err := git.PushCreateLease(ctx, repo, setupRemote(), docketRef, seedCommit) +// remote is the layout's metadata remote. +func publishSeed(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, docketRef gitcli.RefName, seedCommit gitcli.ObjectID) (gitcli.ObjectID, *RepositoryMigrateResult) { + outcome, err := git.PushCreateLease(ctx, repo, remote, docketRef, seedCommit) if err != nil { r := migrateExternalFailure(reposetup.StateLegacy, "publishing the metadata seed", err) return "", &r @@ -801,7 +801,7 @@ func publishSeed(ctx context.Context, git *gitcli.Client, repo gitcli.Repository // verifySeedPublished re-reads the metadata branch from the remote and proves it // carries exactly the composed seed tree at exactly metadataTip before any prune. func verifySeedPublished(ctx context.Context, git *gitcli.Client, sc setupContext, docketRef gitcli.RefName, metadataTip, seedTree gitcli.ObjectID, prefixes []string) *RepositoryMigrateResult { - rev, err := git.FetchBranch(ctx, sc.repo, setupRemote(), docketRef) + rev, err := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), docketRef) if err != nil { r := migrateExternalFailure(reposetup.StateLegacy, "re-reading the published metadata seed", err) return &r @@ -914,7 +914,7 @@ func migrateLocalFinish(ctx context.Context, git *gitcli.Client, facts reposetup if remedy := migratePrimarySyncRemedy(ctx, git, sc, sourceOID, integrationTip); remedy != "" { pending = append(pending, remedy) } - worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) + worktreePath := sc.layout.MetadataWorktree if _, err := ensureMetadataWorktree(ctx, git, sc.repo, worktreePath, docketRef, metadataTip); err != nil { pending = append(pending, "attach the .docket worktree: `docket repository check` then re-run `docket repository migrate` ("+err.Error()+")") } else if herr := git.DisableWorktreeHooks(ctx, worktreePath); herr != nil { @@ -967,15 +967,16 @@ func sortedRepairedPaths(m map[string][]byte) []string { } // migratePreviewText renders the full confirmation plan: the resolved repo, -// remote, exact integration revision, destination branch, copy set, removal set, -// config edit, and the complete repair diff. +// integration remote, exact integration revision, destination metadata branch and +// the metadata remote it is published to, copy set, removal set, config edit, and +// the complete repair diff. func migratePreviewText(sc setupContext, plan reposetup.MigrationPlan, mr migrationRepairs, sourceRevision string) string { var b strings.Builder fmt.Fprintf(&b, "docket repository migrate — plan\n") fmt.Fprintf(&b, " repository: %s\n", sc.repo.PrimaryWorktree) fmt.Fprintf(&b, " remote: %s\n", setupRemote()) fmt.Fprintf(&b, " integration: %s @ %s\n", sc.integrationBranch, sourceRevision) - fmt.Fprintf(&b, " destination: %s (orphan metadata branch)\n", layout.SharedName) // Task 4: resolve through sc.layout + fmt.Fprintf(&b, " destination: %s on %s (orphan metadata branch)\n", sc.layout.MetadataBranch, metadataRemote(sc.layout)) fmt.Fprintf(&b, " copy set: %s\n", strings.Join(plan.Copy.Prefixes, ", ")) fmt.Fprintf(&b, " removal set: %s/, %s, %s\n", plan.Removal.ActiveDir, plan.Removal.BoardPath, plan.Removal.ReadmePath) fmt.Fprintf(&b, " config edit: %s\n", migrateConfigEditText(plan.ConfigEdit)) diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 0ca30753c..c3dbb2588 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -4,12 +4,12 @@ import ( "context" "errors" "fmt" + "os" "path/filepath" "strings" "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -58,7 +58,11 @@ import ( // - repository root / origin identity → PrepareContext.RepoRoot / OriginURL // - default / integration / metadata branch → PrepareContext.{Default,Integration,Metadata}Branch // names plus their pinned …Revision fields -// - fixed `.docket` worktree path → PrepareContext.MetadataWorktreePath +// - metadata worktree path (`.docket` when → PrepareContext.MetadataWorktreePath +// shared, the store checkout when private) +// - metadata remote and its tracking ref → PrepareContext.MetadataRemote / +// (resolved from the layout, never assumed MetadataTrackingRef +// to be origin) // - resolved changes / ADR / results dirs → PrepareContext.{ChangesDir,AdrsDir,ResultsDir} // - supported finalize/test configuration → PrepareContext.Finalize (mirrors the // supported config.Effective finalize fields) @@ -149,6 +153,12 @@ type PrepareContext struct { MetadataBranchRevision string `json:"metadata_branch_revision,omitempty"` MetadataWorktreePath string `json:"metadata_worktree_path"` + // MetadataRemote is the remote the metadata branch is fetched from and + // pushed to (origin when shared, the bare dckt remote when private), and + // MetadataTrackingRef its remote-tracking ref, so a workflow names the + // resolved remote instead of assuming origin. + MetadataRemote string `json:"metadata_remote"` + MetadataTrackingRef string `json:"metadata_tracking_ref"` ChangesDir string `json:"changes_dir"` AdrsDir string `json:"adrs_dir"` @@ -256,9 +266,9 @@ func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCDocketDirForeign), Severity: reposetup.SeverityError, - Ref: docketWorktreeName, - Message: "The .docket path is a foreign directory or a conflicting worktree registration.", - Remedy: "Inspect the .docket path and resolve it manually with a human, then run `docket repository check`.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s path is a foreign directory or a conflicting worktree registration.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Inspect the %s path and resolve it manually with a human, then run `docket repository check`.", f.MetadataWorktreeRef), }) } @@ -326,7 +336,7 @@ func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict case reposetup.PresenceAbsent: return prepareAttachRoute(f, heldElsewhere) case reposetup.PresenceUnknown: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } // Present: it must be registered to this repository on the metadata branch. @@ -334,9 +344,9 @@ func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCDocketWorktreeAmbiguousRegistration), Severity: reposetup.SeverityError, - Ref: docketWorktreeName, - Message: "The .docket worktree is present but not provably registered to this repository on the metadata branch.", - Remedy: "Inspect the .docket worktree registration and resolve it manually with a human, then run `docket repository check`.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s worktree is present but not provably registered to this repository on the metadata branch.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Inspect the %s worktree registration and resolve it manually with a human, then run `docket repository check`.", f.MetadataWorktreeRef), }) } @@ -344,23 +354,24 @@ func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict // resets, or stashes local content. switch f.DocketWorktree.Clean { case reposetup.PresenceAbsent: - msg := "The .docket metadata worktree has uncommitted or untracked changes." - remedy := "Commit or set aside the changes in the .docket metadata worktree, then re-run." + wt := f.MetadataWorktreeRef + msg := fmt.Sprintf("The %s metadata worktree has uncommitted or untracked changes.", wt) + remedy := fmt.Sprintf("Commit or set aside the changes in the %s metadata worktree, then re-run.", wt) switch { case f.DocketWorktree.UnfinishedOperation: - msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + msg = fmt.Sprintf("The %s metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect).", wt) case f.DocketWorktree.InterruptedFastForward: - msg, remedy = reposetup.InterruptedFastForwardMessage, reposetup.InterruptedFastForwardRemedy + msg, remedy = reposetup.InterruptedFastForwardMessage(wt), reposetup.InterruptedFastForwardRemedy(wt) } return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCMetadataWorktreeDirty), Severity: reposetup.SeverityError, - Ref: docketWorktreeName, + Ref: wt, Message: msg, Remedy: remedy, }) case reposetup.PresenceUnknown: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } // 6. Clean, registered worktree: synchronize by the pinned remote revision, @@ -376,7 +387,7 @@ func prepareRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareVerdict case reposetup.SyncDiverged: return prepareRefuseVerdict(reposetup.StateConflict, prepareDivergedFinding()) default: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } } @@ -422,7 +433,7 @@ func prepareAttachRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareV case reposetup.PresenceAbsent: return prepareApplyVerdict(prepareActionAttach, target, "") case reposetup.PresenceUnknown: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } switch f.LocalMetadataSync { case reposetup.SyncCurrent, reposetup.SyncBehind: @@ -430,32 +441,34 @@ func prepareAttachRoute(f reposetup.Facts, heldElsewhere prepareHolder) prepareV case reposetup.PresenceAbsent: return prepareApplyVerdict(prepareActionAttach, target, f.LocalMetadata.Tip) case reposetup.PresencePresent: - return prepareRefuseVerdict(reposetup.StateConflict, prepareHeldElsewhereFinding(heldElsewhere.stale)) + return prepareRefuseVerdict(reposetup.StateConflict, prepareHeldElsewhereFinding(f.MetadataWorktreeRef, heldElsewhere.stale)) default: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } case reposetup.SyncAhead: return prepareRefuseVerdict(reposetup.StateConflict, prepareAheadFinding()) case reposetup.SyncDiverged: return prepareRefuseVerdict(reposetup.StateConflict, prepareDivergedFinding()) default: - return prepareLocalUnknownVerdict() + return prepareLocalUnknownVerdict(f.MetadataWorktreeRef) } } -// prepareHeldElsewhereFinding refuses an attach whose local docket branch another -// worktree registration holds. A stale registration names `git worktree prune`, the -// plain-Git cleanup; a live one is a human's to resolve. -func prepareHeldElsewhereFinding(stale bool) reposetup.Finding { +// prepareHeldElsewhereFinding refuses an attach whose local metadata branch another +// worktree registration holds; worktree names the metadata worktree (the +// shared `.docket` spelling or the private checkout path). A stale registration +// names `git worktree prune`, the plain-Git cleanup; a live one is a human's to +// resolve. +func prepareHeldElsewhereFinding(worktree string, stale bool) reposetup.Finding { f := reposetup.Finding{ Code: string(FCDocketWorktreeAmbiguousRegistration), Severity: reposetup.SeverityError, - Ref: docketWorktreeName, - Message: "The local docket branch is checked out in another worktree, so it cannot be attached at .docket.", + Ref: worktree, + Message: fmt.Sprintf("The local docket branch is checked out in another worktree, so it cannot be attached at %s.", worktree), Remedy: "Inspect the worktree holding the docket branch and resolve it manually with a human, then run `docket repository check`.", } if stale { - f.Message = "The local docket branch is still registered to a worktree whose directory is gone, so it cannot be attached at .docket." + f.Message = fmt.Sprintf("The local docket branch is still registered to a worktree whose directory is gone, so it cannot be attached at %s.", worktree) f.Remedy = "Run `git worktree prune` to drop the stale worktree registration, then run `docket repository prepare`." } return f @@ -487,13 +500,15 @@ func prepareUnresolvedTopology(f reposetup.Facts) []string { // prepareLocalUnknownVerdict is the shared refusal for a healthy-topology // repository whose local worktree/sync state could not be proven. It refuses // rather than guessing: an unproven local state never attaches or fast-forwards. -func prepareLocalUnknownVerdict() prepareVerdict { +// worktree names the metadata worktree (the shared `.docket` spelling or the +// private checkout path). +func prepareLocalUnknownVerdict(worktree string) prepareVerdict { return prepareRefuseVerdict(reposetup.StateConflict, reposetup.Finding{ Code: string(FCPrepareLocalStateUnknown), Severity: reposetup.SeverityError, - Ref: docketWorktreeName, - Message: "The local .docket worktree or its synchronization with the remote metadata branch could not be resolved.", - Remedy: "Run `docket repository check` after ensuring the .docket worktree is readable and the remote is reachable.", + Ref: worktree, + Message: fmt.Sprintf("The local %s worktree or its synchronization with the remote metadata branch could not be resolved.", worktree), + Remedy: fmt.Sprintf("Run `docket repository check` after ensuring the %s worktree is readable and the remote is reachable.", worktree), }) } @@ -528,9 +543,11 @@ func buildPrepareContext(cfg config.Effective, sc setupContext, f reposetup.Fact DefaultBranchRevision: f.RemoteDefaultBranch.Tip, IntegrationBranch: sc.integrationBranch, IntegrationBranchRevision: f.RemoteIntegration.Tip, - MetadataBranch: layout.SharedName, // Task 4: resolve through sc.layout + MetadataBranch: sc.layout.MetadataBranch, MetadataBranchRevision: f.RemoteMetadata.Tip, - MetadataWorktreePath: filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName), + MetadataRemote: sc.layout.MetadataRemote, + MetadataTrackingRef: sc.layout.TrackingRef(), + MetadataWorktreePath: sc.layout.MetadataWorktree, ChangesDir: cfg.ChangesDir.Value, AdrsDir: cfg.ADRsDir.Value, ResultsDir: cfg.ResultsDir.Value, @@ -609,7 +626,7 @@ func RunRepositoryPrepare(ctx context.Context, d SetupDeps, o PrepareOptions) Re // PresenceUnknown and never read. Every probe maps its own error to the safe Unknown // value so a probe that could not run can never let the router read healthy. func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, sc setupContext) prepareHolder { - metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + metaRef := metadataRef(sc.layout) // Metadata root shape at the FETCHED remote docket tip (fetch it first so the // object is local on a clone that never fetched docket). The shared ownership @@ -627,7 +644,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // single authority the ownership proof is computed at. metaTip := sc.metadataTip if metaTip != "" { - rev, ferr := git.FetchBranch(ctx, sc.repo, setupRemote(), metaRef) + rev, ferr := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), metaRef) if ferr != nil { f.MetadataRoot = reposetup.RootUnknown } else { @@ -648,7 +665,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // The .docket worktree clean state, probed only when the worktree is present. if f.DocketWorktree.Presence == reposetup.PresencePresent { - f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation, f.DocketWorktree.InterruptedFastForward = worktreeCleanState(ctx, git, filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName)) + f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation, f.DocketWorktree.InterruptedFastForward = worktreeCleanState(ctx, git, sc.layout.MetadataWorktree) } applyLocalMetadataSync(ctx, git, sc.repo, f) @@ -660,7 +677,7 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, if err != nil { return prepareHolder{presence: reposetup.PresenceUnknown} } - worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) + worktreePath := sc.layout.MetadataWorktree for _, wt := range wts { if wt.Branch == metaRef { return prepareHolder{ @@ -683,8 +700,8 @@ func prepareAugment(ctx context.Context, git *gitcli.Client, f *reposetup.Facts, // not re-applied on that path because nothing removed it. Both effects key on the // re-read remote state so a lost-response retry converges by re-reading topology. func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, verdict prepareVerdict) error { - worktreePath := filepath.Join(sc.repo.PrimaryWorktree, docketWorktreeName) - metaRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + worktreePath := sc.layout.MetadataWorktree + metaRef := metadataRef(sc.layout) switch verdict.action { case prepareActionAttach: return prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict) @@ -713,6 +730,12 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve // hooks-off is applied right after the attach in every case. func prepareAttachFresh(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, worktreePath string, metaRef gitcli.RefName, v prepareVerdict) error { target := gitcli.ObjectID(v.targetRev) + // A private checkout sits under the data-home store, whose checkouts + // directory may not exist yet; a shared `.docket` parent is the primary + // worktree, so this is a no-op there. + if err := os.MkdirAll(filepath.Dir(worktreePath), 0o755); err != nil { + return err + } if v.observedTip == "" { if err := git.AddBranchWorktreeNoHooks(ctx, repo, worktreePath, metaRef, target); err != nil { return err diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index d9a468476..97516b337 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -28,6 +28,7 @@ func preparableFacts() reposetup.Facts { MetadataRoot: reposetup.RootParentless, LiveSurface: reposetup.PresenceAbsent, LocalMetadata: reposetup.BranchFact{Presence: reposetup.PresencePresent, Tip: "m9"}, + MetadataWorktreeRef: ".docket", DocketWorktree: reposetup.WorktreeFact{ Presence: reposetup.PresencePresent, Registered: reposetup.PresencePresent, @@ -455,6 +456,9 @@ func (p *countingSetupProber) ListWorktrees(ctx context.Context, repo gitcli.Rep p.worktrees++ return nil, nil } +func (p *countingSetupProber) RemoteURL(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName) (string, error) { + return "git@github.com:o/r.git", nil +} // TestRepositoryPrepareInvalidConfigRefusesBeforeSync — an invalid configuration // refuses, and no synchronization/topology probe is reached: the gather fails at @@ -510,9 +514,10 @@ func TestRepositoryPrepareContextFieldsTyped(t *testing.T) { sc := setupContext{ cfg: cfg, - repo: gitcli.Repository{PrimaryWorktree: "/repo"}, + repo: gitcli.Repository{PrimaryWorktree: "/repo", CommonDir: "/repo/.git"}, defaultBranch: "develop", integrationBranch: "develop", + layout: layout.SharedLayout("/repo/.git", "/repo"), } f := preparableFacts() @@ -543,6 +548,8 @@ func TestRepositoryPrepareContextFieldsTyped(t *testing.T) { MetadataBranch string `json:"metadata_branch"` MetadataBranchRevision string `json:"metadata_branch_revision"` MetadataWorktreePath string `json:"metadata_worktree_path"` + MetadataRemote string `json:"metadata_remote"` + MetadataTrackingRef string `json:"metadata_tracking_ref"` ChangesDir string `json:"changes_dir"` AdrsDir string `json:"adrs_dir"` ResultsDir string `json:"results_dir"` @@ -575,8 +582,23 @@ func TestRepositoryPrepareContextFieldsTyped(t *testing.T) { if c.MetadataBranchRevision != "m9" { t.Errorf("metadata_branch_revision = %q, want the pinned m9", c.MetadataBranchRevision) } - if c.MetadataWorktreePath != filepath.Join("/repo", docketWorktreeName) { - t.Errorf("metadata_worktree_path = %q, want the fixed .docket path", c.MetadataWorktreePath) + if c.MetadataWorktreePath != filepath.Join("/repo", layout.SharedWorktreeDir) { + t.Errorf("metadata_worktree_path = %q, want the shared .docket path", c.MetadataWorktreePath) + } + if c.MetadataRemote != "origin" { + t.Errorf("metadata_remote = %q, want origin", c.MetadataRemote) + } + if c.MetadataTrackingRef != "refs/remotes/origin/docket" { + t.Errorf("metadata_tracking_ref = %q, want refs/remotes/origin/docket", c.MetadataTrackingRef) + } + // A non-default (private) layout arrives field-for-field: no metadata field + // is a shared-spelling default (learning defaulted-param-hides-caller-wiring). + priv := layout.PrivateLayout("/repo/.git", "/repo", "/d", "o-r") + psc := sc + psc.layout = priv + ppc := buildPrepareContext(cfg, psc, f, "") + if ppc.MetadataBranch != "dckt" || ppc.MetadataRemote != "dckt" || ppc.MetadataTrackingRef != "refs/remotes/dckt/dckt" || ppc.MetadataWorktreePath != priv.MetadataWorktree { + t.Errorf("private layout not wired through: %+v", ppc) } if c.ChangesDir != "planning/changes" || c.AdrsDir != "planning/adrs" || c.ResultsDir != "planning/results" { t.Errorf("resolved dirs not wired through: %+v", c) diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index 4037dd711..b505938bc 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -10,7 +10,6 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" ) @@ -264,7 +263,7 @@ func manualReviewLine(code, path, message string) string { // carried forward from the pinned tree. func executeRepositoryRepair(ctx context.Context, git *gitcli.Client, sc setupContext, metadataTip string, plan repositoryRepairPlan) RepositoryRepairResult { tipOID := gitcli.ObjectID(metadataTip) - docketRef := gitcli.RefName(branchRefPrefix + layout.SharedName) // Task 4: resolve through sc.layout + docketRef := metadataRef(sc.layout) ops := make([]gitcli.TreeOp, 0, len(plan.files)) for _, f := range plan.files { @@ -278,7 +277,7 @@ func executeRepositoryRepair(ctx context.Context, git *gitcli.Client, sc setupCo if err != nil { return repairExternalFailure(reposetup.StateHealthy, "creating the repair commit", err) } - out, err := git.PushLease(ctx, sc.repo, setupRemote(), docketRef, commit, tipOID) + out, err := git.PushLease(ctx, sc.repo, metadataRemote(sc.layout), docketRef, commit, tipOID) if err != nil { return repairExternalFailure(reposetup.StateHealthy, "publishing the repair", err) } @@ -290,7 +289,7 @@ func executeRepositoryRepair(ctx context.Context, git *gitcli.Client, sc setupCo default: return repairExternalFailure(reposetup.StateHealthy, "publishing the repair", errors.New("docket lease push failed")) } - rev, err := git.FetchBranch(ctx, sc.repo, setupRemote(), docketRef) + rev, err := git.FetchBranch(ctx, sc.repo, metadataRemote(sc.layout), docketRef) if err != nil { return repairExternalFailure(reposetup.StateHealthy, "re-reading the repaired metadata branch", err) } @@ -387,15 +386,16 @@ func newRepairResult(result Result, out RepositoryRepairResult) RepositoryRepair return out } -// repairPreviewText renders the confirmation preview: repo, remote, exact pinned +// repairPreviewText renders the confirmation preview: repo, the metadata remote +// the repair publishes to, exact pinned // metadata revision, each repair as `[code] path` (frontmatter entries with their // patch preview), and the manual-review list. func repairPreviewText(sc setupContext, metadataTip string, plan repositoryRepairPlan) string { var b strings.Builder fmt.Fprintf(&b, "docket repository repair — preview\n") fmt.Fprintf(&b, " repository: %s\n", sc.repo.PrimaryWorktree) - fmt.Fprintf(&b, " remote: %s\n", setupRemote()) - fmt.Fprintf(&b, " metadata: %s @ %s\n", layout.SharedName, metadataTip) // Task 4: resolve through sc.layout + fmt.Fprintf(&b, " remote: %s\n", metadataRemote(sc.layout)) + fmt.Fprintf(&b, " metadata: %s @ %s\n", sc.layout.MetadataBranch, metadataTip) fmt.Fprintf(&b, " repairs:\n") for _, f := range plan.frontmatter { fmt.Fprintf(&b, " [%s] %s\n", f.Code, f.Path) diff --git a/internal/reposetup/classify_test.go b/internal/reposetup/classify_test.go index 168e37e34..7fb099931 100644 --- a/internal/reposetup/classify_test.go +++ b/internal/reposetup/classify_test.go @@ -21,6 +21,7 @@ func healthyFacts() Facts { LiveSurface: PresenceAbsent, LegacyConfigKey: PresenceAbsent, CommittedIgnoreBlock: PresencePresent, + MetadataWorktreeRef: ".docket", DocketWorktree: WorktreeFact{ Presence: PresencePresent, Registered: PresencePresent, diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index b2e711b24..595c38535 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -110,16 +110,21 @@ func EvaluateHealth(c Classification, f Facts, fm []RepairFinding) []Finding { return out } -// InterruptedFastForwardMessage and InterruptedFastForwardRemedy describe a .docket -// worktree left by an interrupted in-place fast-forward: the branch moved to the -// remote revision but the index and files did not, so `git status` shows staged -// changes that would undo the remote update. Check and prepare share these texts -// (TestLocalMetadataRefusalsMatchPrepare). Committing those changes would record a -// revert of remote metadata, so the remedy names the plain-Git finish instead. -const ( - InterruptedFastForwardMessage = "The .docket metadata worktree holds an interrupted fast-forward: its branch moved to the new revision but its files did not, so the staged changes would undo the remote update." - InterruptedFastForwardRemedy = "Finish the update inside .docket with plain Git — `git -C .docket reset --merge HEAD` (it refuses rather than overwrite a local edit) — and never commit the staged changes; then re-run." -) +// InterruptedFastForwardMessage and InterruptedFastForwardRemedy describe a +// metadata worktree (named by worktree: `.docket` when shared, the checkout path +// when private) left by an interrupted in-place fast-forward: the branch moved +// to the remote revision but the index and files did not, so `git status` shows +// staged changes that would undo the remote update. Check and prepare share +// these texts (TestLocalMetadataRefusalsMatchPrepare). Committing those changes +// would record a revert of remote metadata, so the remedy names the plain-Git +// finish instead. +func InterruptedFastForwardMessage(worktree string) string { + return fmt.Sprintf("The %s metadata worktree holds an interrupted fast-forward: its branch moved to the new revision but its files did not, so the staged changes would undo the remote update.", worktree) +} + +func InterruptedFastForwardRemedy(worktree string) string { + return fmt.Sprintf("Finish the update inside %s with plain Git — `git -C %s reset --merge HEAD` (it refuses rather than overwrite a local edit) — and never commit the staged changes; then re-run.", worktree, worktree) +} // findingFor builds the single finding for one classifier reason token. The // remedy is branched on the reason (and, for needs-review, on the pending @@ -175,23 +180,24 @@ func findingFor(reason string, f Facts) Finding { return Finding{ Code: "docket-dir-foreign", Severity: SeverityError, - Ref: ".docket", - Message: "The .docket path is a foreign directory or a conflicting worktree registration.", - Remedy: "Inspect the .docket path and resolve it manually with a human before any repository operation.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s path is a foreign directory or a conflicting worktree registration.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Inspect the %s path and resolve it manually with a human before any repository operation.", f.MetadataWorktreeRef), } case "metadata-worktree-dirty": - msg := "The .docket metadata worktree has uncommitted or untracked changes." - remedy := "Commit or inspect the changes in the .docket metadata worktree before any repository operation; leave them in place." + wt := f.MetadataWorktreeRef + msg := fmt.Sprintf("The %s metadata worktree has uncommitted or untracked changes.", wt) + remedy := fmt.Sprintf("Commit or inspect the changes in the %s metadata worktree before any repository operation; leave them in place.", wt) switch { case f.DocketWorktree.UnfinishedOperation: - msg = "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)." + msg = fmt.Sprintf("The %s metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect).", wt) case f.DocketWorktree.InterruptedFastForward: - msg, remedy = InterruptedFastForwardMessage, InterruptedFastForwardRemedy + msg, remedy = InterruptedFastForwardMessage(wt), InterruptedFastForwardRemedy(wt) } return Finding{ Code: "metadata-worktree-dirty", Severity: SeverityError, - Ref: ".docket", + Ref: wt, Message: msg, Remedy: remedy, } @@ -437,7 +443,7 @@ func integrationResolved(f Facts) bool { return f.RemoteIntegration.Presence == PresencePresent && f.RemoteIntegration.Tip != "" } -// worktreeInspectable reports whether the .docket worktree's dependent facts +// worktreeInspectable reports whether the metadata worktree's dependent facts // (registration, cleanliness, synchronization, hooks) were meaningfully // probed: a missing or foreign path is itself the blocking observation. func worktreeInspectable(f Facts) bool { @@ -486,17 +492,17 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return &Finding{ Code: "docket-worktree-missing", Severity: SeverityError, - Ref: ".docket", - Message: "The .docket metadata worktree is missing; its registration, cleanliness, and hooks state cannot be established until it exists.", - Remedy: "Run `docket repository migrate` to restore the .docket worktree attachment; it is idempotent.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s metadata worktree is missing; its registration, cleanliness, and hooks state cannot be established until it exists.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Run `docket repository migrate` to restore the %s worktree attachment; it is idempotent.", f.MetadataWorktreeRef), } } return &Finding{ Code: "docket-worktree-unverified", Severity: SeverityWarning, - Ref: ".docket", - Message: "The .docket path could not be inspected (unverified, not proven absent); its dependent state cannot be established.", - Remedy: "Restore read access to the .docket path, then re-run `docket repository check`.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s path could not be inspected (unverified, not proven absent); its dependent state cannot be established.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Restore read access to the %s path, then re-run `docket repository check`.", f.MetadataWorktreeRef), } case CondWorktreeNotForeign: return nil // always explained by the docket-dir-foreign conflict reason @@ -508,16 +514,16 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return &Finding{ Code: "docket-worktree-unregistered", Severity: SeverityError, - Ref: ".docket", - Message: "The .docket path exists but is not a registered worktree of this repository.", - Remedy: "Inspect the .docket path and resolve its registration manually with a human; leave its contents in place.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s path exists but is not a registered worktree of this repository.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Inspect the %s path and resolve its registration manually with a human; leave its contents in place.", f.MetadataWorktreeRef), } } return &Finding{ Code: "docket-worktree-registration-unverified", Severity: SeverityWarning, - Ref: ".docket", - Message: "The .docket worktree registration could not be resolved (unverified, not proven foreign).", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s worktree registration could not be resolved (unverified, not proven foreign).", f.MetadataWorktreeRef), Remedy: "Re-run `docket repository check` once `git worktree list` succeeds.", } case CondWorktreeClean: @@ -533,9 +539,9 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return &Finding{ Code: "docket-worktree-clean-unverified", Severity: SeverityWarning, - Ref: ".docket", - Message: "The .docket worktree's cleanliness could not be resolved (unverified, not proven dirty).", - Remedy: "Re-run `docket repository check` once the .docket status read succeeds.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s worktree's cleanliness could not be resolved (unverified, not proven dirty).", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Re-run `docket repository check` once the %s status read succeeds.", f.MetadataWorktreeRef), } case CondWorktreeSynchronized: if !worktreeInspectable(f) { @@ -550,7 +556,7 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return &Finding{ Code: "local-metadata-sync-unverified", Severity: SeverityWarning, - Ref: ".docket", + Ref: f.MetadataWorktreeRef, Message: "Could not determine how the local docket branch relates to the remote docket branch (unverified, not proven diverged).", Remedy: "Re-run `docket repository check` once local Git reads succeed.", } @@ -562,17 +568,17 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { return &Finding{ Code: "docket-worktree-hooks-enabled", Severity: SeverityError, - Ref: ".docket", - Message: "Git hooks are not disabled on the .docket metadata worktree (per-worktree core.hooksPath is not set to an existing directory).", - Remedy: "Point the .docket worktree's per-worktree core.hooksPath at an existing empty directory, as init leaves it, then re-run `docket repository check`.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("Git hooks are not disabled on the %s metadata worktree (per-worktree core.hooksPath is not set to an existing directory).", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Point the %s worktree's per-worktree core.hooksPath at an existing empty directory, as init leaves it, then re-run `docket repository check`.", f.MetadataWorktreeRef), } } return &Finding{ Code: "docket-worktree-hooks-unverified", Severity: SeverityWarning, - Ref: ".docket", - Message: "The .docket worktree's hooks configuration could not be resolved (unverified, not proven enabled).", - Remedy: "Re-run `docket repository check` once the .docket config read succeeds.", + Ref: f.MetadataWorktreeRef, + Message: fmt.Sprintf("The %s worktree's hooks configuration could not be resolved (unverified, not proven enabled).", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Re-run `docket repository check` once the %s config read succeeds.", f.MetadataWorktreeRef), } case CondCommittedIgnoreValid: if !integrationResolved(f) { diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index 36fa4be88..c5235bb6d 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -826,7 +826,7 @@ func TestHealthDirtyMessageNamesTheCause(t *testing.T) { }{ "files": {files, "The .docket metadata worktree has uncommitted or untracked changes."}, "operation": {op, "The .docket metadata worktree has an unfinished Git operation (a merge, cherry-pick, revert, rebase, am, or bisect)."}, - "fastforward": {ff, InterruptedFastForwardMessage}, + "fastforward": {ff, InterruptedFastForwardMessage(".docket")}, } { var msg string for _, fn := range EvaluateHealth(Classify(tc.f), tc.f, nil) { @@ -1077,3 +1077,45 @@ func TestHealthPrimaryTipFindingByRelationship(t *testing.T) { } } } + +// TestFindingsUseResolvedWorktreeRef: every metadata-worktree finding names the +// resolved worktree ref the gatherer recorded (a private checkout path here), +// never the shared `.docket` spelling, in its Ref, Message, and Remedy. +func TestFindingsUseResolvedWorktreeRef(t *testing.T) { + const ref = "/d/dckt/o-r/checkouts/r-12345678" + states := map[string]func(*Facts){ + "missing": func(f *Facts) { f.DocketWorktree = WorktreeFact{Presence: PresenceAbsent} }, + "unverified": func(f *Facts) { f.DocketWorktree = WorktreeFact{Presence: PresenceUnknown} }, + "foreign": func(f *Facts) { f.DocketWorktree.Foreign = true }, + "unregistered": func(f *Facts) { f.DocketWorktree.Registered = PresenceAbsent }, + "registration-unk": func(f *Facts) { f.DocketWorktree.Registered = PresenceUnknown }, + "dirty": func(f *Facts) { f.DocketWorktree.Clean = PresenceAbsent }, + "unfinished": func(f *Facts) { f.DocketWorktree.Clean, f.DocketWorktree.UnfinishedOperation = PresenceAbsent, true }, + "interrupted-ff": func(f *Facts) { f.DocketWorktree.Clean, f.DocketWorktree.InterruptedFastForward = PresenceAbsent, true }, + "clean-unverified": func(f *Facts) { f.DocketWorktree.Clean = PresenceUnknown }, + "sync-unverified": func(f *Facts) { f.DocketWorktree.Synchronized, f.LocalMetadataSync = PresenceUnknown, SyncUnknown }, + "hooks-enabled": func(f *Facts) { f.DocketWorktree.HooksOff = PresenceAbsent }, + "hooks-unverified": func(f *Facts) { f.DocketWorktree.HooksOff = PresenceUnknown }, + } + for name, mutate := range states { + f := healthyFacts() + f.MetadataWorktreeRef = ref + mutate(&f) + got := EvaluateHealth(Classify(f), f, nil) + var worktreeFindings int + for _, fn := range got { + if fn.Ref != "" { + worktreeFindings++ + if fn.Ref != ref { + t.Errorf("%s: finding %s Ref = %q, want %q", name, fn.Code, fn.Ref, ref) + } + } + if strings.Contains(fn.Message, ".docket") || strings.Contains(fn.Remedy, ".docket") { + t.Errorf("%s: finding %s still spells .docket: %q / %q", name, fn.Code, fn.Message, fn.Remedy) + } + } + if worktreeFindings == 0 { + t.Errorf("%s: no worktree finding produced (findings %+v)", name, got) + } + } +} diff --git a/internal/reposetup/probe.go b/internal/reposetup/probe.go index fdf298e98..1414bc042 100644 --- a/internal/reposetup/probe.go +++ b/internal/reposetup/probe.go @@ -61,8 +61,8 @@ func (r SyncRelation) Synchronized() Presence { } } -// WorktreeFact carries the probed state of the persistent .docket/ metadata -// worktree. +// WorktreeFact carries the probed state of the persistent metadata worktree +// (`.docket/` in a shared repository, the store checkout in a private one). type WorktreeFact struct { Presence Presence // .docket/ path state: absent, or present-and-probed Registered Presence // registered as a linked worktree of THIS repo on the metadata branch @@ -90,6 +90,8 @@ type Facts struct { CommittedIgnoreBlock Presence // managed block valid in the integration COMMIT tree CommittedIgnoreDetail IgnoreDetail // why the committed block failed; zero when valid or never probed DocketWorktree WorktreeFact + MetadataWorktreeRef string // how findings name the metadata worktree: ".docket" when shared, the checkout path when private + Private bool // the repository's layout is private (decided from state by the gatherer) PrimaryClean Presence PrimaryOnIntegration Presence PrimaryAtRemoteTip Presence From 690a9a1a75291e18f70193c09bae801365d60c51 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:12:41 -0400 Subject: [PATCH 06/20] feat(config): add the visibility key and the private .git/dckt/config.yml repository layer --- .docket.example.yml | 10 ++ internal/app/config.go | 1 + .../app/operational_config_sources_test.go | 66 ++++++++++++ internal/app/operational_context.go | 35 ++++-- internal/assets/embedded/manifest.json | 6 +- .../assets/embedded/tree/.docket.example.yml | 10 ++ internal/config/config.go | 1 + internal/config/decode_test.go | 2 + internal/config/defaults.go | 1 + internal/config/defaults_test.go | 1 + internal/config/fs.go | 73 +++++++++++++ internal/config/private_source_test.go | 100 ++++++++++++++++++ internal/config/resolve.go | 24 ++++- internal/config/resolve_test.go | 2 + internal/config/schema.go | 7 ++ internal/config/schema_test.go | 4 +- internal/config/setting_paths_test.go | 2 +- 17 files changed, 332 insertions(+), 13 deletions(-) create mode 100644 internal/app/operational_config_sources_test.go create mode 100644 internal/config/private_source_test.go diff --git a/.docket.example.yml b/.docket.example.yml index 3cb84469b..2f599014d 100644 --- a/.docket.example.yml +++ b/.docket.example.yml @@ -43,6 +43,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. diff --git a/internal/app/config.go b/internal/app/config.go index 7fd0dffad..9cf8f2049 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -237,6 +237,7 @@ func effectiveLines(eff *config.Effective) []effectiveLine { // orphan `docket` branch), so metadata_branch is not effective // configuration — it is a decode-only obsolete tombstone (change 0363). leafLine("integration_branch", textValue(eff.IntegrationBranch.Value), eff.IntegrationBranch.Provenance), + leafLine("visibility", textValue(eff.Visibility.Value), eff.Visibility.Provenance), leafLine("changes_dir", textValue(eff.ChangesDir.Value), eff.ChangesDir.Provenance), leafLine("adrs_dir", textValue(eff.ADRsDir.Value), eff.ADRsDir.Provenance), leafLine("results_dir", textValue(eff.ResultsDir.Value), eff.ResultsDir.Provenance), diff --git a/internal/app/operational_config_sources_test.go b/internal/app/operational_config_sources_test.go new file mode 100644 index 000000000..7461298a8 --- /dev/null +++ b/internal/app/operational_config_sources_test.go @@ -0,0 +1,66 @@ +package app + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" +) + +// privateOperationalRepo is a primary worktree whose common dir carries the +// private config. The global layer is pinned to an empty temp home. +func privateOperationalRepo(t *testing.T) (gitcli.Repository, layout.Layout) { + t.Helper() + home := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", home) + t.Setenv("HOME", home) + primary := t.TempDir() + common := filepath.Join(primary, ".git") + if err := os.MkdirAll(filepath.Join(common, layout.PrivateName), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(layout.PrivateConfigPath(common), []byte("changes_dir: local-private\n"), 0o644); err != nil { + t.Fatal(err) + } + repo := gitcli.Repository{PrimaryWorktree: primary, CommonDir: common} + return repo, layout.PrivateLayout(common, primary, filepath.Join(home, "data"), "owner-repo") +} + +// TestOperationalConfigSourcesPrivateReadsPrivateConfig: a private layout +// reads .git/dckt/config.yml as the repository layer and ignores the pinned +// .docket.yml bytes it is handed (the caller skips that read in private mode). +func TestOperationalConfigSourcesPrivateReadsPrivateConfig(t *testing.T) { + repo, lay := privateOperationalRepo(t) + sources, err := operationalConfigSources(repo, lay, []byte("changes_dir: committed\n")) + if err != nil { + t.Fatalf("operationalConfigSources: %v", err) + } + if len(sources) != 1 || sources[0].Layer != config.LayerRepository || sources[0].Name != layout.PrivateConfigDisplay { + t.Fatalf("sources = %+v, want exactly the private repository layer", sources) + } + if string(sources[0].Data) != "changes_dir: local-private\n" { + t.Errorf("repository layer data = %q, want the private config", sources[0].Data) + } +} + +// TestOperationalConfigSourcesPrivateLocalConflictIsInvalidConfiguration: a +// .docket.local.yml beside a private config is a configuration refusal. +func TestOperationalConfigSourcesPrivateLocalConflictIsInvalidConfiguration(t *testing.T) { + repo, lay := privateOperationalRepo(t) + if err := os.WriteFile(filepath.Join(repo.PrimaryWorktree, ".docket.local.yml"), []byte("finalize: {gate: off}\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := operationalConfigSources(repo, lay, nil) + var eic *errInvalidConfiguration + if !errors.As(err, &eic) || !errors.Is(err, ErrStatusInvalidInput) { + t.Fatalf("err = %v, want an errInvalidConfiguration classified as invalid input", err) + } + var conflict *config.ConflictingLocalConfigError + if !errors.As(err, &conflict) { + t.Errorf("err = %v, want it to wrap *config.ConflictingLocalConfigError", err) + } +} diff --git a/internal/app/operational_context.go b/internal/app/operational_context.go index d2eee2429..de3971a5b 100644 --- a/internal/app/operational_context.go +++ b/internal/app/operational_context.go @@ -2,6 +2,7 @@ package app import ( "context" + "errors" "fmt" "os" "path/filepath" @@ -154,12 +155,17 @@ func loadOperationalContext(ctx context.Context, client *gitcli.Client, repoDir oc.repoWebURL = githubWebURL(remoteURL) // Configuration is read from the pinned default-branch source (never the - // working tree), then layered under the filesystem-only machine layers. - docketYML, err := readPinnedOptionalBlob(ctx, client, repo, defaultRev, ".docket.yml") - if err != nil { - return oc, err + // working tree), then layered under the filesystem-only machine layers. A + // private repository never reads .docket.yml, so the pinned blob read is + // skipped there. + var docketYML []byte + if oc.layout.Mode != layout.Private { + docketYML, err = readPinnedOptionalBlob(ctx, client, repo, defaultRev, ".docket.yml") + if err != nil { + return oc, err + } } - sources, err := operationalConfigSources(repo, docketYML) + sources, err := operationalConfigSources(repo, oc.layout, docketYML) if err != nil { return oc, err } @@ -250,7 +256,12 @@ func readPinnedOptionalBlob(ctx context.Context, client *gitcli.Client, repo git // LoadFilesystemSources is not called wholesale because its repository // .docket.yml read would come from the working tree, which the // read-from-pinned-Git contract forbids. -func operationalConfigSources(repo gitcli.Repository, docketYML []byte) ([]config.Source, error) { +// +// A private repository (lay.Mode == layout.Private) has no committed file: its +// stack is the global layer plus config.LoadPrivateRepositorySource, and +// docketYML is ignored. A .docket.local.yml beside the private config is a +// configuration refusal (errInvalidConfiguration), not an external failure. +func operationalConfigSources(repo gitcli.Repository, lay layout.Layout, docketYML []byte) ([]config.Source, error) { var sources []config.Source globalSources, err := config.LoadGlobalSource("") @@ -259,6 +270,18 @@ func operationalConfigSources(repo gitcli.Repository, docketYML []byte) ([]confi } sources = append(sources, globalSources...) + if lay.Mode == layout.Private { + private, err := config.LoadPrivateRepositorySource(repo.CommonDir, repo.PrimaryWorktree) + var conflict *config.ConflictingLocalConfigError + switch { + case errors.As(err, &conflict): + return nil, &errInvalidConfiguration{err: err} + case err != nil: + return nil, fmt.Errorf("%w: %v", ErrStatusExternal, err) + } + return append(sources, private...), nil + } + if docketYML != nil { sources = append(sources, config.Source{Layer: config.LayerRepository, Name: ".docket.yml", Data: docketYML}) } diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 186bfc8d6..37e4a4f1f 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,14 +1,14 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:ad12306465402b8f7866e91bcc7985ea3be1f4adcfd4b340012ba175b04bc598", + "asset_set_id": "sha256:fb1378bb638922eb8ed790b8b8c4fb0d83bc785045a6d4fbdb687a477ed6af52", "entries": [ { "path": ".docket.example.yml", "role": "config-schema", "mode": 420, - "size": 18635, - "sha256": "891b5a0faf361834dc2f61e98aa52c02213410252358b06d97406ffd8725f643" + "size": 19297, + "sha256": "a394e2b24fb102dff61052da362067efa553e0bc9bce039f9b7ecdf2552ea951" }, { "path": "agents/docket-adr.md", diff --git a/internal/assets/embedded/tree/.docket.example.yml b/internal/assets/embedded/tree/.docket.example.yml index 3cb84469b..2f599014d 100644 --- a/internal/assets/embedded/tree/.docket.example.yml +++ b/internal/assets/embedded/tree/.docket.example.yml @@ -43,6 +43,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. diff --git a/internal/config/config.go b/internal/config/config.go index 54e05c6c2..b666af406 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -120,6 +120,7 @@ type Effective struct { // resolved policy. The metadata branch comes from the repository's layout // (layout.Layout.MetadataBranch), decided from state, never from config. IntegrationBranch Value[string] `json:"integration_branch"` // auto already resolved + Visibility Value[string] `json:"visibility"` // the mode a new repository is set up in; never moves one ChangesDir Value[string] `json:"changes_dir"` ADRsDir Value[string] `json:"adrs_dir"` ResultsDir Value[string] `json:"results_dir"` diff --git a/internal/config/decode_test.go b/internal/config/decode_test.go index d493f584f..0c80f58c5 100644 --- a/internal/config/decode_test.go +++ b/internal/config/decode_test.go @@ -51,6 +51,8 @@ func decodeAcceptanceCases() []decodeCase { {row: "metadata_branch", path: "metadata_branch", block: "metadata_branch: main\n", obsolete: true}, + {row: "visibility", path: "visibility", + block: "visibility: private\n", value: "private"}, {row: "integration_branch", path: "integration_branch", block: "integration_branch: develop\n", value: "develop"}, {row: "changes_dir", path: "changes_dir", diff --git a/internal/config/defaults.go b/internal/config/defaults.go index d3705934a..913bda2b0 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -27,6 +27,7 @@ func builtinEffective() Effective { // No metadata_branch: it is an obsolete tombstone (change 0363), never a // resolved default. IntegrationBranch: builtinValue("auto"), + Visibility: builtinValue("shared"), ChangesDir: builtinValue("docs/changes"), ADRsDir: builtinValue("docs/adrs"), ResultsDir: builtinValue("docs/results"), diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index 8cfe5f780..8a39f6c84 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -140,6 +140,7 @@ func TestBuiltinEffectiveMatchesRegistryDefaults(t *testing.T) { eff := builtinEffective() leaves := map[string]any{ // metadata_branch is gone from Effective (obsolete tombstone, 0363). + "visibility": eff.Visibility.Value, "integration_branch": eff.IntegrationBranch.Value, "changes_dir": eff.ChangesDir.Value, "adrs_dir": eff.ADRsDir.Value, diff --git a/internal/config/fs.go b/internal/config/fs.go index f36988d4f..09e65644d 100644 --- a/internal/config/fs.go +++ b/internal/config/fs.go @@ -5,6 +5,8 @@ import ( "fmt" "os" "path/filepath" + + "github.com/danielhanold/docket/internal/layout" ) // FSOptions selects the files the adapter reads. It performs no Git discovery @@ -17,6 +19,11 @@ type FSOptions struct { // LoadFilesystemSources reads DIR/.docket.yml (repository), DIR/.docket.local.yml // (repository-local), and the global config, returning present layers in // low→high order. A missing file is an absent layer, not an error. +// +// A private repository (DIR/.git resolves to a common dir that layout.Detect +// calls private) reads the global config plus LoadPrivateRepositorySource +// instead: it never reads .docket.yml. The mode probe needs no git process; +// any probe error is returned, never guessed. func LoadFilesystemSources(opts FSOptions) ([]Source, error) { if opts.RepoDir == "" { return nil, errors.New("config: FSOptions.RepoDir is required") @@ -48,6 +55,20 @@ func LoadFilesystemSources(opts FSOptions) ([]Source, error) { globalPath = defaultGlobalPath() } + common, ok, err := layout.CommonDirOf(repoDir) + if err != nil { + return nil, fmt.Errorf("config: %w", err) + } + if ok { + mode, err := layout.Detect(common) + if err != nil { + return nil, fmt.Errorf("config: %w", err) + } + if mode == layout.Private { + return loadPrivateSources(globalPath, common, repoDir) + } + } + type candidate struct { layer LayerKind name string @@ -113,3 +134,55 @@ func defaultGlobalPath() string { } return "" } + +// ConflictingLocalConfigError is a private repository that also carries a +// .docket.local.yml: a private repository reads only its private config. +type ConflictingLocalConfigError struct{ LocalPath, PrivatePath string } + +func (e *ConflictingLocalConfigError) Error() string { + return fmt.Sprintf("config: both %s and %s exist; a private repository reads only %s — move any settings you need into it and delete %s", + e.LocalPath, e.PrivatePath, e.PrivatePath, e.LocalPath) +} + +// LoadPrivateRepositorySource reads a private repository's repository layer, +// layout.PrivateConfigPath(commonDir), returning zero or one Source named +// layout.PrivateConfigDisplay. A missing file is an absent layer. A +// /.docket.local.yml beside it is a *ConflictingLocalConfigError: +// a private repository reads only its private config, and silently ignoring +// the local file would drop settings the user believes are in force. +func LoadPrivateRepositorySource(commonDir, primaryWorktree string) ([]Source, error) { + privatePath := layout.PrivateConfigPath(commonDir) + localPath := filepath.Join(primaryWorktree, ".docket.local.yml") + if _, err := os.Stat(localPath); err == nil { + return nil, &ConflictingLocalConfigError{LocalPath: localPath, PrivatePath: privatePath} + } else if !os.IsNotExist(err) { + return nil, fmt.Errorf("config: inspecting %s: %w", localPath, err) + } + + data, err := os.ReadFile(privatePath) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("config: reading %s: %w", privatePath, err) + } + return []Source{{Layer: LayerRepository, Name: layout.PrivateConfigDisplay, Data: data}}, nil +} + +// loadPrivateSources is a private repository's full layer stack: the global +// config, then its private repository layer. +func loadPrivateSources(globalPath, commonDir, primaryWorktree string) ([]Source, error) { + var sources []Source + if globalPath != "" { + global, err := LoadGlobalSource(globalPath) + if err != nil { + return nil, err + } + sources = append(sources, global...) + } + repo, err := LoadPrivateRepositorySource(commonDir, primaryWorktree) + if err != nil { + return nil, err + } + return append(sources, repo...), nil +} diff --git a/internal/config/private_source_test.go b/internal/config/private_source_test.go new file mode 100644 index 000000000..7762084b0 --- /dev/null +++ b/internal/config/private_source_test.go @@ -0,0 +1,100 @@ +package config + +import ( + "errors" + "path/filepath" + "strings" + "testing" +) + +// privateFixture is a temp working-tree root whose .git common dir carries the +// dckt/ folder that marks a private repository. +func privateFixture(t *testing.T) string { + t.Helper() + repo := t.TempDir() + writeFile(t, filepath.Join(repo, ".git", "dckt", "config.yml"), + "visibility: private\nchanges_dir: local-private\n") + return repo +} + +func TestLoadFilesystemSourcesPrivateReadsDcktConfigNotDocketYML(t *testing.T) { + pinEnv(t) + repo := privateFixture(t) + writeFile(t, filepath.Join(repo, ".docket.yml"), "changes_dir: committed\n") + + got, err := LoadFilesystemSources(FSOptions{RepoDir: repo}) + if err != nil { + t.Fatalf("LoadFilesystemSources: %v", err) + } + if len(got) != 1 || got[0].Layer != LayerRepository || got[0].Name != ".git/dckt/config.yml" { + t.Fatalf("sources = %+v, want exactly [{repository .git/dckt/config.yml}]", got) + } + + snap, _, err := Resolve(got, mainCtx) + if err != nil { + t.Fatalf("Resolve: %v", err) + } + if v := snap.Effective.ChangesDir.Value; v != "local-private" { + t.Errorf("changes_dir = %q, want %q (an identity key honored from the private config)", v, "local-private") + } + if v := snap.Effective.Visibility.Value; v != "private" { + t.Errorf("visibility = %q, want %q", v, "private") + } +} + +func TestLoadFilesystemSourcesPrivateRefusesBothLocalFiles(t *testing.T) { + pinEnv(t) + repo := privateFixture(t) + writeFile(t, filepath.Join(repo, ".docket.local.yml"), "finalize: {gate: off}\n") + + _, err := LoadFilesystemSources(FSOptions{RepoDir: repo}) + var conflict *ConflictingLocalConfigError + if !errors.As(err, &conflict) { + t.Fatalf("err = %v, want a *ConflictingLocalConfigError", err) + } + msg := err.Error() + for _, want := range []string{".docket.local.yml", ".git/dckt/config.yml"} { + if !strings.Contains(msg, want) { + t.Errorf("error %q does not name %s", msg, want) + } + } +} + +func TestLoadFilesystemSourcesSharedUnchanged(t *testing.T) { + pinEnv(t) + repo := t.TempDir() + writeFile(t, filepath.Join(repo, ".git", "HEAD"), "ref: refs/heads/main\n") + writeFile(t, filepath.Join(repo, ".docket.yml"), "changes_dir: committed\n") + + got, err := LoadFilesystemSources(FSOptions{RepoDir: repo}) + if err != nil { + t.Fatalf("LoadFilesystemSources: %v", err) + } + if len(got) != 1 || got[0].Layer != LayerRepository || got[0].Name != ".docket.yml" { + t.Fatalf("sources = %+v, want exactly [{repository .docket.yml}]", got) + } +} + +func TestGlobalVisibilityIsOrdinary(t *testing.T) { + res := mustResolve(t, []Source{srcG("visibility: private\n")}, mainCtx) + if v := res.effective.Visibility; v.Value != "private" || v.Provenance.Layer != LayerGlobal { + t.Errorf("visibility = %+v, want private from %q", v, LayerGlobal) + } + for _, d := range res.diags { + if d.Severity == SeverityWarning || d.Severity == SeverityError { + t.Errorf("unexpected diagnostic %s/%s/%s", d.Severity, d.Code, d.Path) + } + } +} + +func TestSharedSettingRemedyNamesPrivateConfig(t *testing.T) { + private := Source{Layer: LayerRepository, Name: ".git/dckt/config.yml", Data: []byte("visibility: private\n")} + res := mustResolve(t, []Source{srcG("integration_branch: trunk\n"), private}, mainCtx) + guarded := diagsWithCode(res, CodeSharedSettingIgnored) + if len(guarded) != 1 { + t.Fatalf("guarded = %v, want one shared-setting-ignored warning", diagSummary(res)) + } + if r := guarded[0].Remedy; !strings.Contains(r, ".git/dckt/config.yml") || strings.Contains(r, ".docket.yml") { + t.Errorf("remedy = %q, want it to name .git/dckt/config.yml and not .docket.yml", r) + } +} diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 048d18331..2a1e6268d 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -52,6 +52,11 @@ type resolution struct { allDecls []leafDecl diags []Diagnostic + + // repoSourceName is the LayerRepository source's Name: ".docket.yml" in a + // shared repository, layout.PrivateConfigDisplay in a private one. The + // shared-setting guard's remedies name it (committedConfigName). + repoSourceName string } // Resolve parses and resolves sources supplied in low-to-high precedence order @@ -89,6 +94,9 @@ func resolve(sources []Source, rctx ResolveContext) (*resolution, error) { // Every layer is parsed and decoded before any is judged: one broken layer // must not hide what the others say. for _, src := range sources { + if src.Layer == LayerRepository { + res.repoSourceName = src.Name + } root, diags := parseLayer(src) res.diags = append(res.diags, diags...) if root == nil { @@ -220,7 +228,7 @@ func (r *resolution) applySharedSettingGuard(decl leafDecl) (leafDecl, bool) { case decl.spec.scope == scopeRepoOnly && isMachineLayer(decl.prov.Layer): r.ignoreSharedSetting(decl, decl.path, "coordinates the whole repository and may only be declared in the committed configuration", - fmt.Sprintf("move %s to the committed .docket.yml, or remove it here", decl.path)) + fmt.Sprintf("move %s to %s, or remove it here", decl.path, r.committedConfigName())) return decl, false } @@ -246,7 +254,7 @@ func (r *resolution) keepUnguardedSurfaces(decl leafDecl) []string { } r.ignoreSharedSetting(decl, decl.path, fmt.Sprintf("names the %q surface, which coordinates the whole repository and may only be requested by the committed configuration", boardSurfaceGitHub), - fmt.Sprintf("declare %q in the committed .docket.yml, or drop the token here", boardSurfaceGitHub)) + fmt.Sprintf("declare %q in %s, or drop the token here", boardSurfaceGitHub, r.committedConfigName())) } return kept } @@ -263,6 +271,17 @@ func (r *resolution) ignoreSharedSetting(decl leafDecl, path, why, remedy string }) } +// committedConfigName is the file a guarded remedy sends a setting to: "the +// committed .docket.yml" (byte-identical shared text) unless the repository +// layer came from a differently named source, such as a private repository's +// .git/dckt/config.yml. +func (r *resolution) committedConfigName() string { + if r.repoSourceName == "" || r.repoSourceName == ".docket.yml" { + return "the committed .docket.yml" + } + return r.repoSourceName +} + func isMachineLayer(layer LayerKind) bool { return layer == LayerGlobal || layer == LayerRepositoryLocal } @@ -282,6 +301,7 @@ func (r *resolution) assemble(byLayer map[LayerKind]map[string]leafDecl) (Effect // metadata_branch has no effective leaf: it is an obsolete tombstone // (change 0363), excluded at decode and never assigned here. set(assign(&eff.IntegrationBranch, r.declared, "integration_branch")) + set(assign(&eff.Visibility, r.declared, "visibility")) set(assign(&eff.ChangesDir, r.declared, "changes_dir")) set(assign(&eff.ADRsDir, r.declared, "adrs_dir")) set(assign(&eff.ResultsDir, r.declared, "results_dir")) diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index 9c12eb546..77c27df73 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -60,6 +60,8 @@ func diagsWithCode(res *resolution, code string) []Diagnostic { func effectiveLeaf(t *testing.T, eff Effective, path string) (any, Provenance, bool) { t.Helper() switch path { + case "visibility": + return eff.Visibility.Value, eff.Visibility.Provenance, eff.Visibility.Explicit case "integration_branch": return eff.IntegrationBranch.Value, eff.IntegrationBranch.Provenance, eff.IntegrationBranch.Explicit case "changes_dir": diff --git a/internal/config/schema.go b/internal/config/schema.go index c4fd18f74..87af44e58 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -232,6 +232,13 @@ func buildRegistry() []pathSpec { {path: "metadata_branch", kind: kindString, merge: mergeScalar, disp: dispObsolete, validate: stringLeaf(false, false, false)}, + // visibility is an ordinary key (any layer, normal precedence): it picks + // the mode `docket repository init` sets a NEW repository up in. The mode + // of an existing repository is its state (layout.Detect), never config. + {path: "visibility", kind: kindString, enum: []string{"shared", "private"}, def: "shared", + merge: mergeScalar, scope: scopeAny, disp: dispSupported, + validate: enumLeaf("shared", "private")}, + // 3-6: repository identity — shared-setting-guarded, so a machine layer // cannot silently relocate one clone's planning surfaces. {path: "integration_branch", kind: kindString, def: "auto", diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index 9a20c47c5..4ec107566 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -15,7 +15,7 @@ import ( // loop would miss the addition). func TestRegistryPathSetMatchesV092(t *testing.T) { want := []string{ - "runtime.bash", "metadata_branch", "integration_branch", + "runtime.bash", "metadata_branch", "visibility", "integration_branch", "changes_dir", "adrs_dir", "results_dir", "finalize.gate", "finalize.test_command", "finalize.require_pr_approval", "finalize.resolver_max_attempts", "finalize.repair_max_attempts", @@ -108,6 +108,7 @@ func TestRegistryRepoOnlySet(t *testing.T) { func TestRegistryDefaults(t *testing.T) { want := map[string]any{ // metadata_branch has no default: it is an obsolete tombstone (change 0363). + "visibility": "shared", "integration_branch": "auto", "changes_dir": "docs/changes", "adrs_dir": "docs/adrs", @@ -174,6 +175,7 @@ func TestRegistryDefaults(t *testing.T) { func TestRegistryEnumRows(t *testing.T) { want := map[string][]string{ // metadata_branch carries no enum now: it is an obsolete tombstone (0363). + "visibility": {"shared", "private"}, "finalize.gate": {"local", "ci", "both", "off"}, "build.gate": {"local", "off"}, "review.min_fix_severity": {"minor", "important", "blocker"}, diff --git a/internal/config/setting_paths_test.go b/internal/config/setting_paths_test.go index b2652f21b..76833b8fd 100644 --- a/internal/config/setting_paths_test.go +++ b/internal/config/setting_paths_test.go @@ -24,7 +24,7 @@ func TestSettingPathsSupportSplit(t *testing.T) { } } want := []string{ - "integration_branch", "changes_dir", "adrs_dir", "results_dir", + "visibility", "integration_branch", "changes_dir", "adrs_dir", "results_dir", "finalize.gate", "finalize.test_command", "finalize.require_pr_approval", "finalize.resolver_max_attempts", "finalize.repair_max_attempts", "build.gate", "build.test_command", "build.max_attempts", From b81e947319f20aca91a9e2885ca49acb238e93b4 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:13:43 -0400 Subject: [PATCH 07/20] feat(gitcli): add InitBare and AddRemote for the private metadata remote --- internal/gitcli/remoteadmin.go | 80 +++++++++++++++++++ .../gitcli/remoteadmin_integration_test.go | 53 ++++++++++++ 2 files changed, 133 insertions(+) create mode 100644 internal/gitcli/remoteadmin.go create mode 100644 internal/gitcli/remoteadmin_integration_test.go diff --git a/internal/gitcli/remoteadmin.go b/internal/gitcli/remoteadmin.go new file mode 100644 index 000000000..bfb59f735 --- /dev/null +++ b/internal/gitcli/remoteadmin.go @@ -0,0 +1,80 @@ +package gitcli + +import ( + "context" + "os" + "path/filepath" + "strings" +) + +const ( + initBareOp Operation = "init-bare" + addRemoteOp Operation = "add-remote" +) + +// InitBare creates a bare repository at path, creating missing parent +// directories. It is idempotent on an existing bare repository and refuses an +// existing path that is not one. path must be absolute: GIT_DIR is scrubbed +// from the environment, so the target is always passed as an argument. +func (c *Client) InitBare(ctx context.Context, path string) error { + if !filepath.IsAbs(path) { + return newFailure(initBareOp, KindInvalidRequest, "bare repository path must be absolute", nil) + } + if info, err := os.Stat(path); err == nil { + if !info.IsDir() { + return newFailure(initBareOp, KindInvalidRequest, "not a bare repository", nil) + } + res, f := c.run(ctx, runRequest{ + op: initBareOp, + dir: path, + args: []string{"rev-parse", "--is-bare-repository"}, + }) + if f != nil { + return f + } + if res.exitCode != 0 || strings.TrimSpace(string(res.stdout)) != "true" { + return newFailure(initBareOp, KindInvalidRequest, "not a bare repository", nil) + } + return nil + } + parent := filepath.Dir(path) + if err := os.MkdirAll(parent, 0o755); err != nil { + return newFailure(initBareOp, KindCommandFailed, "create parent directory", err) + } + res, f := c.run(ctx, runRequest{ + op: initBareOp, + dir: parent, + args: []string{"init", "--bare", "-q", path}, + }) + if f != nil { + return f + } + if res.exitCode != 0 { + return newFailure(initBareOp, KindCommandFailed, "git init --bare failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + return nil +} + +// AddRemote registers remote name with url in repo. It never rewrites an +// existing remote: a name already configured fails as KindCommandFailed, and +// callers compare URLs (RemoteURL) first. +func (c *Client) AddRemote(ctx context.Context, repo Repository, name RemoteName, url string) error { + if err := validateRemoteName(name); err != nil { + return newFailure(addRemoteOp, KindInvalidRequest, "invalid remote name", err) + } + if url == "" || strings.HasPrefix(url, "-") || strings.ContainsAny(url, "\x00\n") { + return newFailure(addRemoteOp, KindInvalidRequest, "invalid remote url", nil) + } + res, f := c.run(ctx, runRequest{ + op: addRemoteOp, + dir: repo.PrimaryWorktree, + args: []string{"remote", "add", string(name), url}, + }) + if f != nil { + return f + } + if res.exitCode != 0 { + return newFailure(addRemoteOp, KindCommandFailed, "git remote add failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + return nil +} diff --git a/internal/gitcli/remoteadmin_integration_test.go b/internal/gitcli/remoteadmin_integration_test.go new file mode 100644 index 000000000..1666664a3 --- /dev/null +++ b/internal/gitcli/remoteadmin_integration_test.go @@ -0,0 +1,53 @@ +//go:build integration + +package gitcli + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestIntegrationRepoInitBareAndAddRemote proves InitBare creates missing +// parents, is idempotent on an existing bare repository, and refuses a +// non-bare one; and that AddRemote registers a remote RemoteURL reads back +// while refusing to overwrite an existing remote of the same name. +func TestIntegrationRepoInitBareAndAddRemote(t *testing.T) { + requireGit(t) + c := newRealClient(t) + ctx := context.Background() + r := newMainModeRepos(t) + repo := mustDiscover(t, c, r.Invocation) + + bare := filepath.Join(testsupport.TempDir(t), "store", "o-r", "remote.git") + if err := c.InitBare(ctx, bare); err != nil { + t.Fatalf("InitBare (fresh, missing parents): %v", err) + } + if err := c.InitBare(ctx, bare); err != nil { + t.Fatalf("InitBare (idempotent): %v", err) + } + if got := strings.TrimSpace(gitOut(t, bare, "rev-parse", "--is-bare-repository")); got != "true" { + t.Fatalf("is-bare-repository = %q, want true", got) + } + + if err := c.AddRemote(ctx, repo, "dckt", bare); err != nil { + t.Fatalf("AddRemote: %v", err) + } + url, err := c.RemoteURL(ctx, repo, "dckt") + if err != nil { + t.Fatalf("RemoteURL: %v", err) + } + if url != bare { + t.Fatalf("RemoteURL = %q, want %q", url, bare) + } + if err := c.AddRemote(ctx, repo, "dckt", bare); err == nil { + t.Fatalf("second AddRemote with the same name succeeded, want failure") + } + + if err := c.InitBare(ctx, r.Writer); err == nil { + t.Fatalf("InitBare on a non-bare repository succeeded, want failure") + } +} From b88bbeeec59833e9e06a6ee21643211fcbc20959 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:14:49 -0400 Subject: [PATCH 08/20] feat(reposetup): manage a neutral # dckt: block in .git/info/exclude --- internal/reposetup/exclude.go | 72 ++++++++++++++++++++++ internal/reposetup/exclude_test.go | 99 ++++++++++++++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 internal/reposetup/exclude.go create mode 100644 internal/reposetup/exclude_test.go diff --git a/internal/reposetup/exclude.go b/internal/reposetup/exclude.go new file mode 100644 index 000000000..0845a4765 --- /dev/null +++ b/internal/reposetup/exclude.go @@ -0,0 +1,72 @@ +package reposetup + +// exclude.go — emitter and marker validation for the neutral managed block a +// private repository keeps in .git/info/exclude. A private repository writes +// no committed root file, so its one ignore (the feature-worktree root) lives +// in the clone-local exclude file under neutral `# dckt:` markers. It reuses +// gitignore.go's marker helpers (gitignoreMarkersMalformed, +// stripGitignoreBlock) as they are; .gitignore behavior is untouched. +// +// No I/O: pure functions over byte slices. Callers own reading and atomically +// writing the file. + +import "bytes" + +// ExcludeStart / ExcludeEnd are the exact marker lines that delimit the +// managed exclude block. They are deliberately neutral: nothing docket-named. +const ( + ExcludeStart = "# dckt:start" + ExcludeEnd = "# dckt:end" +) + +// canonicalExcludeBytes is the block, markers inclusive, LF endings. +var canonicalExcludeBytes = []byte(ExcludeStart + "\n" + + ".worktrees/\n" + + ExcludeEnd + "\n") + +// MalformedExcludeError reports refusal to rewrite an exclude file whose +// `# dckt:` markers are dangling, out of order, or nested. +type MalformedExcludeError struct{} + +func (e *MalformedExcludeError) Error() string { + return "malformed " + ExcludeStart + " / " + ExcludeEnd + + " markers in .git/info/exclude: dangling, out-of-order, or nested — refusing to rewrite" +} + +// EnsureExcludeBlock returns the full-file bytes with the managed block +// present exactly once at the end: it replaces a stale block or appends the +// block when absent, preserving every byte outside it (trailing blank lines +// aside). changed is false when the input is already canonical. Malformed +// markers return *MalformedExcludeError with out==nil and the input untouched. +func EnsureExcludeBlock(current []byte) (out []byte, changed bool, err error) { + if gitignoreMarkersMalformed(current, ExcludeStart, ExcludeEnd) { + return nil, false, &MalformedExcludeError{} + } + out = buildExcludeFile(stripGitignoreBlock(current, ExcludeStart, ExcludeEnd)) + if bytes.Equal(out, current) { + return current, false, nil + } + return out, true, nil +} + +// ValidExcludeBlock reports whether current has well-formed markers and is +// exactly what EnsureExcludeBlock would produce from it. +func ValidExcludeBlock(current []byte) bool { + if gitignoreMarkersMalformed(current, ExcludeStart, ExcludeEnd) { + return false + } + return bytes.Equal(current, buildExcludeFile(stripGitignoreBlock(current, ExcludeStart, ExcludeEnd))) +} + +// buildExcludeFile trims trailing newlines from the outside content, adds one +// separating newline when that content is non-empty, and appends the block. +func buildExcludeFile(rest []byte) []byte { + trimmed := bytes.TrimRight(rest, "\n") + var b bytes.Buffer + if len(trimmed) > 0 { + b.Write(trimmed) + b.WriteByte('\n') + } + b.Write(canonicalExcludeBytes) + return b.Bytes() +} diff --git a/internal/reposetup/exclude_test.go b/internal/reposetup/exclude_test.go new file mode 100644 index 000000000..331615db7 --- /dev/null +++ b/internal/reposetup/exclude_test.go @@ -0,0 +1,99 @@ +package reposetup + +import ( + "bytes" + "errors" + "strings" + "testing" +) + +const canonicalExclude = "# dckt:start\n.worktrees/\n# dckt:end\n" + +func TestExcludeBlockKeepsUserLinesAndIsIdempotent(t *testing.T) { + in := []byte("# git ls-files --others --exclude-from=.git/info/exclude\n*.swp\n") + out, changed, err := EnsureExcludeBlock(in) + if err != nil { + t.Fatalf("EnsureExcludeBlock: %v", err) + } + if !changed { + t.Fatal("first call reported changed=false") + } + if !bytes.HasPrefix(out, in) { + t.Fatalf("user bytes not preserved as a prefix:\n%q", out) + } + if !bytes.Contains(out, []byte(canonicalExclude)) { + t.Fatalf("canonical block missing:\n%q", out) + } + if !ValidExcludeBlock(out) { + t.Fatalf("ValidExcludeBlock(out) = false for %q", out) + } + if ValidExcludeBlock(in) { + t.Fatal("ValidExcludeBlock(in) = true for a file with no block") + } + again, changed2, err := EnsureExcludeBlock(out) + if err != nil { + t.Fatalf("second EnsureExcludeBlock: %v", err) + } + if changed2 || !bytes.Equal(again, out) { + t.Fatalf("second call not idempotent: changed=%v\n%q", changed2, again) + } +} + +func TestExcludeBlockNilInput(t *testing.T) { + out, changed, err := EnsureExcludeBlock(nil) + if err != nil { + t.Fatalf("EnsureExcludeBlock(nil): %v", err) + } + if !changed || string(out) != canonicalExclude { + t.Fatalf("got changed=%v %q, want changed=true %q", changed, out, canonicalExclude) + } +} + +func TestExcludeBlockMalformedMarkersRefuse(t *testing.T) { + for _, in := range []string{ + "# dckt:start\n.worktrees/\n", + "# dckt:end\n# dckt:start\n", + "# dckt:start\n# dckt:start\n# dckt:end\n", + } { + orig := []byte(in) + buf := append([]byte(nil), orig...) + out, changed, err := EnsureExcludeBlock(buf) + var me *MalformedExcludeError + if !errors.As(err, &me) { + t.Errorf("%q: err = %v, want *MalformedExcludeError", in, err) + } + if out != nil || changed { + t.Errorf("%q: out=%q changed=%v, want nil/false", in, out, changed) + } + if !bytes.Equal(buf, orig) { + t.Errorf("%q: input mutated to %q", in, buf) + } + if ValidExcludeBlock(orig) { + t.Errorf("%q: ValidExcludeBlock = true for malformed markers", in) + } + } +} + +func TestExcludeBlockRewritesStaleBody(t *testing.T) { + out, changed, err := EnsureExcludeBlock([]byte("keep\n# dckt:start\nold/\n# dckt:end\n")) + if err != nil { + t.Fatalf("EnsureExcludeBlock: %v", err) + } + want := "keep\n" + canonicalExclude + if !changed || string(out) != want { + t.Fatalf("got changed=%v %q, want %q", changed, out, want) + } +} + +func TestExcludeBlockNeutralSpelling(t *testing.T) { + out, _, err := EnsureExcludeBlock([]byte("*.swp\n")) + if err != nil { + t.Fatalf("EnsureExcludeBlock: %v", err) + } + if strings.Contains(strings.ToLower(string(out)), "docket") { + t.Fatalf("exclude output names docket: %q", out) + } + if strings.Contains(strings.ToLower(ExcludeStart+ExcludeEnd), "docket") { + t.Fatal("exclude markers name docket") + } +} From ea09940eb4125801dad96825268adafdaf35fe56 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:24:20 -0400 Subject: [PATCH 09/20] feat(repository): init --private publishes the metadata branch to a local bare remote with neutral naming `docket repository init` decides the mode with decideInitMode: a fresh repository takes the effective `visibility` value, overridden by --private or --shared; an already-set-up repository keeps its mode and a switching flag refuses. A private init writes the `# dckt:` block into .git/info/exclude and .git/dckt/config.yml, creates the bare store (or uses --metadata-remote), adds the dckt remote (refusing a different existing URL), publishes or adopts the dckt branch, and attaches this clone's checkout under the store. Nothing is written into the working tree and nothing reaches origin. Every failure after the config write names the re-run recovery, and a re-run whose dckt remote was never added reads the metadata branch as absent so it can finish. The generated test policy writes through repoConfigTarget (private: .git/dckt/config.yml, not a pending path). Migrate refuses a private repository. resolveLayout spells the data home symlink-canonical, so a re-run recognizes its own checkout registration. --- internal/app/layout_resolve.go | 26 +- internal/app/layout_resolve_test.go | 27 +- internal/app/reposetup_integration_test.go | 4 +- .../app/reposetup_race_integration_test.go | 2 +- internal/app/repository_configure_tests.go | 44 ++- .../app/repository_configure_tests_test.go | 7 +- internal/app/repository_facts_test.go | 6 +- internal/app/repository_init.go | 87 +++-- internal/app/repository_init_mode_test.go | 104 ++++++ internal/app/repository_init_private.go | 275 ++++++++++++++ internal/app/repository_migrate.go | 5 + .../repository_private_integration_test.go | 349 ++++++++++++++++++ internal/cli/repository.go | 20 +- internal/cli/repository_test.go | 2 +- 14 files changed, 909 insertions(+), 49 deletions(-) create mode 100644 internal/app/repository_init_mode_test.go create mode 100644 internal/app/repository_init_private.go create mode 100644 internal/app/repository_private_integration_test.go diff --git a/internal/app/layout_resolve.go b/internal/app/layout_resolve.go index 061594117..bd18c8fbb 100644 --- a/internal/app/layout_resolve.go +++ b/internal/app/layout_resolve.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "os" + "path/filepath" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/layout" @@ -46,7 +47,30 @@ func resolveLayout(ctx context.Context, r remoteURLReader, repo gitcli.Repositor if err != nil { return layout.Layout{}, fmt.Errorf("%w: private repository: locating the metadata store: %v", ErrStatusExternal, err) } - return layout.PrivateLayout(repo.CommonDir, repo.PrimaryWorktree, dataHome, ownerRepo), nil + return layout.PrivateLayout(repo.CommonDir, repo.PrimaryWorktree, canonicalExistingPrefix(dataHome), ownerRepo), nil +} + +// canonicalExistingPrefix resolves every symlink in the longest existing +// ancestor of p and rejoins the not-yet-existing remainder. Git records +// worktree paths symlink-canonical (as Discover does the repository's own), so +// the store's checkout path must be spelled the same way for a re-run to +// recognize its own registration — on macOS the temp and data dirs sit behind +// the /var -> /private/var link. A path with no resolvable ancestor is returned +// cleaned. +func canonicalExistingPrefix(p string) string { + p = filepath.Clean(p) + rest := "" + for dir := p; ; { + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(resolved, rest) + } + parent := filepath.Dir(dir) + if parent == dir { + return p + } + rest = filepath.Join(filepath.Base(dir), rest) + dir = parent + } } // metadataRemote is the remote the metadata branch is fetched from and diff --git a/internal/app/layout_resolve_test.go b/internal/app/layout_resolve_test.go index 160c8acac..df253f8f9 100644 --- a/internal/app/layout_resolve_test.go +++ b/internal/app/layout_resolve_test.go @@ -61,7 +61,11 @@ func TestResolveLayoutPrivateUsesOriginAndDataHome(t *testing.T) { if err != nil { t.Fatalf("resolveLayout: %v", err) } - if want := layout.PrivateLayout(common, "/r", data, "o-r"); got != want { + canonicalData, err := filepath.EvalSymlinks(data) + if err != nil { + t.Fatal(err) + } + if want := layout.PrivateLayout(common, "/r", canonicalData, "o-r"); got != want { t.Fatalf("layout = %+v, want %+v", got, want) } if got := metadataRemote(got); got != "dckt" { @@ -103,3 +107,24 @@ func TestResolveLayoutUnprobeableStateFails(t *testing.T) { t.Fatalf("err = %v, want ErrStatusExternal", err) } } + +// TestCanonicalExistingPrefix proves the data home is spelled the way git +// records worktree paths: symlinks in the existing ancestor resolved, and a +// not-yet-existing remainder kept. +func TestCanonicalExistingPrefix(t *testing.T) { + real := testsupport.TempDir(t) + canonicalReal, err := filepath.EvalSymlinks(real) + if err != nil { + t.Fatal(err) + } + link := filepath.Join(testsupport.TempDir(t), "link") + if err := os.Symlink(real, link); err != nil { + t.Fatal(err) + } + if got := canonicalExistingPrefix(link); got != canonicalReal { + t.Errorf("existing link = %q, want %q", got, canonicalReal) + } + if got, want := canonicalExistingPrefix(filepath.Join(link, "a", "b")), filepath.Join(canonicalReal, "a", "b"); got != want { + t.Errorf("missing remainder = %q, want %q", got, want) + } +} diff --git a/internal/app/reposetup_integration_test.go b/internal/app/reposetup_integration_test.go index a7e1520a4..b8ea4ebc6 100644 --- a/internal/app/reposetup_integration_test.go +++ b/internal/app/reposetup_integration_test.go @@ -71,7 +71,7 @@ func newInitRepo(t *testing.T, docketYML string, integrationFiles map[string]str func (r *initRepo) runInit(t *testing.T) RepositoryOpResult { t.Helper() client := newGitClient(t) - return RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}) + return RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, InitOptions{}) } // gitDir returns the absolute git dir of the invocation clone. @@ -318,7 +318,7 @@ func TestIntegrationRepoSetupInitDoesNotPrompt(t *testing.T) { // The service signature carries no input reader, so it structurally cannot // prompt; run it and confirm it completes without consulting stdin. client := newGitClient(t) - res := RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}) + res := RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, InitOptions{}) if res.Result != ResultApplied { t.Fatalf("init did not complete without a prompt: %q (%s)", res.Result, res.HumanText()) } diff --git a/internal/app/reposetup_race_integration_test.go b/internal/app/reposetup_race_integration_test.go index 51bfe639e..f78b2aeaa 100644 --- a/internal/app/reposetup_race_integration_test.go +++ b/internal/app/reposetup_race_integration_test.go @@ -46,7 +46,7 @@ func TestRaceIntegrationRepoSetupConcurrentInitRace(t *testing.T) { go func() { defer wg.Done() <-start - results[i] = RunRepositoryInit(context.Background(), SetupDeps{Git: clients[i], RepoDir: dirs[i]}) + results[i] = RunRepositoryInit(context.Background(), SetupDeps{Git: clients[i], RepoDir: dirs[i]}, InitOptions{}) }() } close(start) diff --git a/internal/app/repository_configure_tests.go b/internal/app/repository_configure_tests.go index f74df857e..04f39e183 100644 --- a/internal/app/repository_configure_tests.go +++ b/internal/app/repository_configure_tests.go @@ -5,6 +5,7 @@ import ( "fmt" "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -82,21 +83,29 @@ func RunRepositoryConfigureTests(ctx context.Context, d SetupDeps, o ConfigureTe werr error ) if o.Command != nil { - pendingPath, wrote, werr = ensureExplicitTestCommand(sc.repo.PrimaryWorktree, explicit) + pendingPath, wrote, werr = ensureExplicitTestCommand(sc, explicit) } else { - pendingPath, wrote, discovery, werr = ensureTestPolicyConfig(sc.repo.PrimaryWorktree, sc.cfg) + pendingPath, wrote, discovery, werr = ensureTestPolicyConfig(sc, sc.cfg) } if werr != nil { return repositoryInternalFailure(OperationRepositoryConfigureTests, cls.State, "generating the test-policy config", werr) } + // A shared repository's .docket.yml edit is a pending review path that moves + // the state to needs-review; a private repository's .git/dckt/config.yml is + // clone-local, so it is written with nothing to review and the state stands. result := ResultNoOp state := cls.State var pending []string + written := pendingPath if wrote { result = ResultApplied - state = reposetup.StateNeedsReview - pending = []string{pendingPath} + if pendingPath != "" { + state = reposetup.StateNeedsReview + pending = []string{pendingPath} + } else { + _, written, _ = repoConfigTarget(sc) + } } out := newRepositoryOpResult(OperationRepositoryConfigureTests, result, RepositoryOpResult{ @@ -105,18 +114,29 @@ func RunRepositoryConfigureTests(ctx context.Context, d SetupDeps, o ConfigureTe SourceRevision: sc.sourceRevision, }) if o.Command != nil { - out.human = configureTestsExplicitText(state, wrote, pendingPath, explicit) + out.human = configureTestsExplicitText(state, wrote, written, explicit) } else { - out.human = configureTestsDiscoveryText(state, wrote, pendingPath, discovery, sc.cfg) + out.human = configureTestsDiscoveryText(state, wrote, written, discovery, sc.cfg) } return out } -// configureTestsExplicitText renders the --command outcome. -func configureTestsExplicitText(state reposetup.State, wrote bool, pendingPath, cmd string) string { +// configWrittenText names where a written test policy landed: the pending +// review path a shared repository commits, or the clone-local private config, +// which has nothing to review. +func configWrittenText(written string) string { + if written == layout.PrivateConfigDisplay { + return "wrote " + written + } + return "review and commit the pending path: " + written +} + +// configureTestsExplicitText renders the --command outcome. written is the +// path the policy was written to (configWrittenText). +func configureTestsExplicitText(state reposetup.State, wrote bool, written, cmd string) string { if wrote { - return fmt.Sprintf("test policy set: build and finalize gates `local` running `%s` (%s); review and commit the pending path: %s", - cmd, state, pendingPath) + return fmt.Sprintf("test policy set: build and finalize gates `local` running `%s` (%s); %s", + cmd, state, configWrittenText(written)) } return fmt.Sprintf("%s: %s (%s): build and finalize gates are already `local` running `%s`; nothing to write", OperationRepositoryConfigureTests, ResultNoOp, state, cmd) @@ -128,10 +148,10 @@ func configureTestsExplicitText(state reposetup.State, wrote bool, pendingPath, // so the gates are not assumed off); ambiguous names every candidate's command; // configured names both resolved commands plus any per-gate gap; detected with // no change names the command already in place. -func configureTestsDiscoveryText(state reposetup.State, wrote bool, pendingPath string, outcome reposetup.DiscoveryOutcome, cfg config.Effective) string { +func configureTestsDiscoveryText(state reposetup.State, wrote bool, written string, outcome reposetup.DiscoveryOutcome, cfg config.Effective) string { noneRemedy := fmt.Sprintf("re-run with `%s` to set both gates to `local` with your suite command", reposetup.ConfigureTestsCommandRemedy) if wrote { - text := fmt.Sprintf("test policy generated (%s); review and commit the pending path: %s", state, pendingPath) + text := fmt.Sprintf("test policy generated (%s); %s", state, configWrittenText(written)) if outcome.Kind == reposetup.DiscoveryNone { text += "\nno supported test suite was found, so no test command was written; after committing, " + noneRemedy } diff --git a/internal/app/repository_configure_tests_test.go b/internal/app/repository_configure_tests_test.go index 2a0e3ce5f..826fe1667 100644 --- a/internal/app/repository_configure_tests_test.go +++ b/internal/app/repository_configure_tests_test.go @@ -8,6 +8,8 @@ import ( "testing" "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/testsupport" ) @@ -233,7 +235,10 @@ func TestEnsureExplicitTestCommandRefusesUnsplicableFileUntouched(t *testing.T) if err := os.WriteFile(abs, orig, 0o644); err != nil { t.Fatal(err) } - pending, wrote, err := ensureExplicitTestCommand(dir, "make test") + pending, wrote, err := ensureExplicitTestCommand(setupContext{ + repo: gitcli.Repository{PrimaryWorktree: dir, CommonDir: filepath.Join(dir, ".git")}, + layout: layout.SharedLayout(filepath.Join(dir, ".git"), dir), + }, "make test") if err == nil { t.Fatalf("expected a refusal; got pending=%q wrote=%v", pending, wrote) } diff --git a/internal/app/repository_facts_test.go b/internal/app/repository_facts_test.go index e4a82316a..45d5acaae 100644 --- a/internal/app/repository_facts_test.go +++ b/internal/app/repository_facts_test.go @@ -186,7 +186,11 @@ func TestGatherSetupFactsThreadsResolvedLayout(t *testing.T) { if err != nil { t.Fatalf("gatherSetupFacts: %v", err) } - want := layout.PrivateLayout(common, root, data, "o-r") + canonicalData, err := filepath.EvalSymlinks(data) + if err != nil { + t.Fatal(err) + } + want := layout.PrivateLayout(common, root, canonicalData, "o-r") if sc.layout != want { t.Errorf("sc.layout = %+v, want %+v", sc.layout, want) } diff --git a/internal/app/repository_init.go b/internal/app/repository_init.go index 4419e33b8..cd1f4b9bd 100644 --- a/internal/app/repository_init.go +++ b/internal/app/repository_init.go @@ -14,6 +14,7 @@ import ( "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/harness" "github.com/danielhanold/docket/internal/install" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" ) @@ -80,11 +81,24 @@ func newRepositoryOpResult(operation string, result Result, out RepositoryOpResu // unstaged managed .gitignore edit plus (only when authorized) the parent-facing // dispatch surfaces, and the ownership record for the surfaces it owns. It never // prompts and never reads stdin. -func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { +// +// o selects the visibility a fresh repository is set up in (decideInitMode): +// the effective `visibility` value, overridden by --private / --shared. A +// private result runs runPrivateInit; an already-set-up repository keeps its +// mode, and a flag that would switch it refuses. +func RunRepositoryInit(ctx context.Context, d SetupDeps, o InitOptions) RepositoryOpResult { + // Validate the flags before any repository read. + if o.Private && o.Shared { + out := newRepositoryOpResult(OperationRepositoryInit, ResultInvalidInput, RepositoryOpResult{}) + out.human = fmt.Sprintf("%s: %s: --private and --shared are mutually exclusive", OperationRepositoryInit, ResultInvalidInput) + return out + } + facts, sc, err := GatherSetupFacts(ctx, d, true) if err != nil { return repositoryGatherFailure(OperationRepositoryInit, err) } + recoverUnconfiguredPrivateRemote(ctx, d.Git, &facts, sc) cls, refusal := initGuard(facts) if refusal != nil { @@ -97,6 +111,14 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { // rides back on the pending review paths. debris := sweepSetupDebris(ctx, d.Git, sc.repo) + mode, why := decideInitMode(sc.layout.Mode, cls.State, o, sc.cfg.Visibility.Value) + if why != "" { + return initRefusal(cls.State, why) + } + if mode == layout.Private { + return runPrivateInit(ctx, d, sc, cls, o, debris) + } + // Effects 1–2: build a parentless empty-tree root with the OpInitRoot receipt // and publish it under create-only protection. The create-only push is the // authoritative metadata operation: on success we created it; on a lost lease @@ -149,7 +171,7 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { // exactly the managed-.gitignore posture. Generated config is human-gated, so // init never stages it; an ambiguous outcome writes nothing and rides back as // a note rather than failing init. - docketYMLPending, wroteDocketYML, discovery, derr := ensureTestPolicyConfig(sc.repo.PrimaryWorktree, sc.cfg) + docketYMLPending, wroteDocketYML, discovery, derr := ensureTestPolicyConfig(sc, sc.cfg) if derr != nil { return repositoryInternalFailure(OperationRepositoryInit, cls.State, "generating the test-policy config", derr) } @@ -183,41 +205,56 @@ func RunRepositoryInit(ctx context.Context, d SetupDeps) RepositoryOpResult { return out } -// writePendingDocketYML reads the primary worktree's .docket.yml (nil when -// absent), asks render for the edited bytes, and writes them UNSTAGED when -// render returns non-nil — the managed-.gitignore posture: generated config is -// human-gated, never staged. It returns the pending review path ("" when nothing -// was written). A read fault, a render error (malformed config, probe fault, -// invalid command), or a write fault is an error with the file untouched. -func writePendingDocketYML(primaryWorktree string, render func(existing []byte) ([]byte, error)) (pendingPath string, wrote bool, err error) { - abs := filepath.Join(primaryWorktree, docketYMLRel) - existing, rerr := os.ReadFile(abs) +// writeRepoConfig reads the repository config file at absPath (nil when +// absent), asks render for the edited bytes, and writes them when render returns +// non-nil, creating the parent directory. For a shared repository that is the +// root .docket.yml, written UNSTAGED — the managed-.gitignore posture: generated +// config is human-gated, never staged. A read fault, a render error (malformed +// config, probe fault, invalid command), or a write fault is an error with the +// file untouched. +func writeRepoConfig(absPath string, render func(existing []byte) ([]byte, error)) (wrote bool, err error) { + existing, rerr := os.ReadFile(absPath) if rerr != nil { if !os.IsNotExist(rerr) { - return "", false, rerr + return false, rerr } existing = nil } edited, perr := render(existing) if perr != nil { - return "", false, perr + return false, perr } if edited == nil { - return "", false, nil + return false, nil + } + if merr := os.MkdirAll(filepath.Dir(absPath), 0o755); merr != nil { + return false, merr + } + if werr := os.WriteFile(absPath, edited, 0o644); werr != nil { + return false, werr } - if werr := os.WriteFile(abs, edited, 0o644); werr != nil { - return "", false, werr + return true, nil +} + +// writeTargetConfig writes through writeRepoConfig to the repository's config +// target (repoConfigTarget) and returns the pending review path: the target's +// display name when it is a pending path and was written, else "". +func writeTargetConfig(sc setupContext, render func(existing []byte) ([]byte, error)) (pendingPath string, wrote bool, err error) { + abs, display, pending := repoConfigTarget(sc) + wrote, err = writeRepoConfig(abs, render) + if err != nil || !wrote || !pending { + return "", wrote, err } - return docketYMLRel, true, nil + return display, true, nil } // ensureTestPolicyConfig discovers the suite from the primary worktree and -// writes the generated test-policy edit through writePendingDocketYML, +// writes the generated test-policy edit to the repository's config target, // returning the discovery outcome so the caller can report it. A malformed // existing config or a probe fault is an error with the file untouched. -func ensureTestPolicyConfig(primaryWorktree string, cfg config.Effective) (pendingPath string, wrote bool, outcome reposetup.DiscoveryOutcome, err error) { - tree := newOSTree(primaryWorktree) - pendingPath, wrote, err = writePendingDocketYML(primaryWorktree, func(existing []byte) ([]byte, error) { +func ensureTestPolicyConfig(sc setupContext, cfg config.Effective) (pendingPath string, wrote bool, outcome reposetup.DiscoveryOutcome, err error) { + tree := newOSTree(sc.repo.PrimaryWorktree) + pendingPath, wrote, err = writeTargetConfig(sc, func(existing []byte) ([]byte, error) { edited, oc, perr := reposetup.TestPolicyEdit(cfg, existing, tree) outcome = oc return edited, perr @@ -229,10 +266,10 @@ func ensureTestPolicyConfig(primaryWorktree string, cfg config.Effective) (pendi } // ensureExplicitTestCommand writes the explicit-command policy (both gates -// local, both commands cmd) through writePendingDocketYML. No discovery runs. -// cmd must already have passed reposetup.ExplicitTestCommand. -func ensureExplicitTestCommand(primaryWorktree, cmd string) (pendingPath string, wrote bool, err error) { - return writePendingDocketYML(primaryWorktree, func(existing []byte) ([]byte, error) { +// local, both commands cmd) to the repository's config target. No discovery +// runs. cmd must already have passed reposetup.ExplicitTestCommand. +func ensureExplicitTestCommand(sc setupContext, cmd string) (pendingPath string, wrote bool, err error) { + return writeTargetConfig(sc, func(existing []byte) ([]byte, error) { edited, changed, rerr := reposetup.RenderExplicitTestCommandEdit(existing, cmd) if rerr != nil || !changed { return nil, rerr diff --git a/internal/app/repository_init_mode_test.go b/internal/app/repository_init_mode_test.go new file mode 100644 index 000000000..bf0ba71a6 --- /dev/null +++ b/internal/app/repository_init_mode_test.go @@ -0,0 +1,104 @@ +package app + +import ( + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// TestDecideInitMode pins init's mode decision: a fresh repository takes the +// effective visibility value overridden by a flag; an already-set-up repository +// keeps its mode, and a flag that would switch it refuses; the two mode flags +// are mutually exclusive; and --metadata-remote applies only to a private +// result. +func TestDecideInitMode(t *testing.T) { + const refuse = layout.Mode("refuse") + cases := []struct { + name string + detected layout.Mode + state reposetup.State + opts InitOptions + effective string + want layout.Mode + }{ + {"fresh default shared", layout.Shared, reposetup.StateFresh, InitOptions{}, "shared", layout.Shared}, + {"fresh effective private", layout.Shared, reposetup.StateFresh, InitOptions{}, "private", layout.Private}, + {"fresh --shared overrides private", layout.Shared, reposetup.StateFresh, InitOptions{Shared: true}, "private", layout.Shared}, + {"fresh --private overrides shared", layout.Shared, reposetup.StateFresh, InitOptions{Private: true}, "shared", layout.Private}, + {"healthy shared ignores effective private", layout.Shared, reposetup.StateHealthy, InitOptions{}, "private", layout.Shared}, + {"healthy shared refuses --private", layout.Shared, reposetup.StateHealthy, InitOptions{Private: true}, "shared", refuse}, + {"private stays private", layout.Private, reposetup.StateHealthy, InitOptions{}, "shared", layout.Private}, + {"private refuses --shared", layout.Private, reposetup.StateHealthy, InitOptions{Shared: true}, "shared", refuse}, + {"both flags refuse", layout.Shared, reposetup.StateFresh, InitOptions{Private: true, Shared: true}, "shared", refuse}, + {"metadata-remote on a shared result refuses", layout.Shared, reposetup.StateFresh, InitOptions{MetadataRemote: "/x.git"}, "shared", refuse}, + {"healthy shared refuses --metadata-remote", layout.Shared, reposetup.StateHealthy, InitOptions{MetadataRemote: "/x.git"}, "private", refuse}, + {"fresh metadata-remote with --private", layout.Shared, reposetup.StateFresh, InitOptions{Private: true, MetadataRemote: "/x.git"}, "shared", layout.Private}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, refusal := decideInitMode(tc.detected, tc.state, tc.opts, tc.effective) + if tc.want == refuse { + if refusal == "" { + t.Fatalf("decideInitMode = %q with no refusal, want a refusal", got) + } + if strings.Contains(refusal, "set-visibility") { + t.Errorf("refusal %q names set-visibility, which does not exist yet", refusal) + } + return + } + if refusal != "" { + t.Fatalf("decideInitMode refused %q, want %q", refusal, tc.want) + } + if got != tc.want { + t.Errorf("decideInitMode = %q, want %q", got, tc.want) + } + }) + } +} + +// TestRepoConfigTarget pins where a generated test policy lands: a private +// repository writes its .git/dckt/config.yml with no pending review path, and a +// shared repository writes the root .docket.yml as a pending review path. +func TestRepoConfigTarget(t *testing.T) { + priv := setupContext{ + repo: gitcli.Repository{CommonDir: "/r/.git", PrimaryWorktree: "/r"}, + layout: layout.PrivateLayout("/r/.git", "/r", "/d", "o-r"), + } + abs, display, pending := repoConfigTarget(priv) + if abs != "/r/.git/dckt/config.yml" || display != layout.PrivateConfigDisplay || pending { + t.Errorf("private target = (%q, %q, %v), want (/r/.git/dckt/config.yml, %q, false)", abs, display, pending, layout.PrivateConfigDisplay) + } + shared := setupContext{ + repo: gitcli.Repository{CommonDir: "/r/.git", PrimaryWorktree: "/r"}, + layout: layout.SharedLayout("/r/.git", "/r"), + } + abs, display, pending = repoConfigTarget(shared) + if abs != "/r/.docket.yml" || display != ".docket.yml" || !pending { + t.Errorf("shared target = (%q, %q, %v), want (/r/.docket.yml, .docket.yml, true)", abs, display, pending) + } +} + +// TestRunRepositoryInitRefusesBothModeFlagsBeforeGather proves the mutually +// exclusive flags are an invalid-input refusal returned before any repository +// read: the deps carry no Git client, so a gather would panic. +func TestRunRepositoryInitRefusesBothModeFlagsBeforeGather(t *testing.T) { + res := RunRepositoryInit(t.Context(), SetupDeps{}, InitOptions{Private: true, Shared: true}) + if res.Result != ResultInvalidInput { + t.Fatalf("Result = %q (%s), want invalid-input", res.Result, res.HumanText()) + } + if !strings.Contains(res.HumanText(), "mutually exclusive") { + t.Errorf("text %q must say the flags are mutually exclusive", res.HumanText()) + } +} + +// TestConfigureTestsPrivateWrittenText proves a private configure-tests names +// the clone-local config it wrote and never a pending review path. +func TestConfigureTestsPrivateWrittenText(t *testing.T) { + got := configureTestsExplicitText(reposetup.StateHealthy, true, layout.PrivateConfigDisplay, "true") + if !strings.Contains(got, "wrote .git/dckt/config.yml") || strings.Contains(got, "pending") { + t.Errorf("private explicit text %q must say it wrote .git/dckt/config.yml with no pending path", got) + } +} diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go new file mode 100644 index 000000000..60b3cc0bb --- /dev/null +++ b/internal/app/repository_init_private.go @@ -0,0 +1,275 @@ +package app + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// This file is `docket repository init`'s private mode: the metadata branch +// `dckt` published to a bare `dckt` remote on this machine, its checkout under +// that store, and the repository's config in .git/dckt/config.yml — nothing +// docket-named in the repository and nothing pushed to origin. + +// InitOptions carries init's mode flags. Private and Shared override the +// effective `visibility` value for a fresh repository; MetadataRemote, valid +// only for a private result, names the git URL the dckt branch is pushed to in +// place of the default bare repository. +type InitOptions struct { + Private, Shared bool + MetadataRemote string +} + +// privateInitRecovery is appended to every private-init failure after the +// config write: from that point .git/dckt/ exists, so the repository reads as +// private, and the state itself names its recovery (presence-encoded state). +const privateInitRecovery = "`.git/dckt/` now exists, so this repository reads as private; re-run `docket repository init` to finish (every step is idempotent)." + +// decideInitMode decides the mode init sets a repository up in, or a refusal +// ("" = admitted). Config never moves a repository: a detected private +// repository stays private and an already-set-up shared one stays shared, and a +// flag that would switch either refuses. Only a fresh repository takes the +// effective visibility value, overridden by a flag. +func decideInitMode(detected layout.Mode, state reposetup.State, o InitOptions, effective string) (layout.Mode, string) { + if o.Private && o.Shared { + return "", "--private and --shared are mutually exclusive" + } + if detected == layout.Private { + if o.Shared { + return "", "this repository is already private; init never switches a repository's visibility" + } + return layout.Private, "" + } + if state != reposetup.StateFresh { + if o.Private || o.MetadataRemote != "" { + return "", "this repository is already set up as shared; init never switches a repository's visibility" + } + return layout.Shared, "" + } + mode := layout.Shared + if effective == string(layout.Private) { + mode = layout.Private + } + switch { + case o.Private: + mode = layout.Private + case o.Shared: + mode = layout.Shared + } + if mode != layout.Private && o.MetadataRemote != "" { + return "", "--metadata-remote applies only to a private repository" + } + return mode, "" +} + +// repoConfigTarget names where a generated repository config edit lands: a +// private repository's .git/dckt/config.yml (clone-local, never a pending +// review path), or a shared repository's root .docket.yml (a pending, unstaged +// review path). +func repoConfigTarget(sc setupContext) (absPath, display string, pending bool) { + if sc.layout.Mode == layout.Private { + return sc.layout.ConfigPath, layout.PrivateConfigDisplay, false + } + return filepath.Join(sc.repo.PrimaryWorktree, docketYMLRel), docketYMLRel, true +} + +// recoverUnconfiguredPrivateRemote turns a private repository's unprobeable +// metadata presence into a proven absence when the reason is that the dckt +// remote is not configured yet — the window a private init that died after its +// config write leaves. Init's create-only publish then creates the branch or +// adopts the one already in the store, so a re-run finishes. Any other probe +// failure stays Unknown. +func recoverUnconfiguredPrivateRemote(ctx context.Context, git *gitcli.Client, facts *reposetup.Facts, sc setupContext) { + if sc.layout.Mode != layout.Private || facts.RemoteMetadata.Presence != reposetup.PresenceUnknown { + return + } + if _, err := git.RemoteURL(ctx, sc.repo, metadataRemote(sc.layout)); isRemoteUnconfigured(err) { + facts.RemoteMetadata.Presence = reposetup.PresenceAbsent + } +} + +// isRemoteUnconfigured reports whether err is gitcli's "remote is not +// configured" failure. +func isRemoteUnconfigured(err error) bool { + fail, ok := gitcli.AsFailure(err) + return ok && fail.Kind == gitcli.KindRemoteUnavailable +} + +// runPrivateInit sets a repository up private. Every step is idempotent and +// writes nothing into the working tree, so `git status` stays clean. +func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls reposetup.Classification, o InitOptions, debris setupDebrisReport) RepositoryOpResult { + common := sc.repo.CommonDir + changed := false + + // 1. The neutral ignore block in the clone-local exclude file. A malformed + // block refuses with the file untouched. + wroteExclude, err := ensureExcludeFile(filepath.Join(common, "info", "exclude")) + if err != nil { + var mal *reposetup.MalformedExcludeError + if errors.As(err, &mal) { + return initRefusal(cls.State, err.Error()+"; fix the markers by hand, then re-run") + } + return repositoryExternalFailure(OperationRepositoryInit, cls.State, "writing .git/info/exclude", err) + } + changed = changed || wroteExclude + + // 2. The private config. From here the repository detects as private. + wroteConfig, err := ensurePrivateConfig(layout.PrivateConfigPath(common), sc.repo.PrimaryWorktree, sc.cfg) + if err != nil { + return repositoryInternalFailure(OperationRepositoryInit, cls.State, "writing "+layout.PrivateConfigDisplay, err) + } + changed = changed || wroteConfig + + fail := func(r RepositoryOpResult) RepositoryOpResult { + r.human += "\n" + privateInitRecovery + return r + } + + // 3. The layout, now resolved from state. + lay, err := resolveLayout(ctx, d.Git, sc.repo) + if err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "resolving the private layout", err)) + } + if lay.Mode != layout.Private { + return fail(repositoryInternalFailure(OperationRepositoryInit, cls.State, "resolving the private layout", errors.New("the repository does not detect as private after the config write"))) + } + + // 4. The bare remote: the flag URL as given, or the default store, created + // when absent. A flag URL never creates the default store. + want := o.MetadataRemote + if want == "" { + want = lay.DefaultBareRemote + _, statErr := os.Stat(want) + if err := d.Git.InitBare(ctx, want); err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "creating the bare metadata store", err)) + } + changed = changed || os.IsNotExist(statErr) + } + + // 5. The dckt git remote: added when absent, kept when it already points at + // want, refused otherwise. Docket never rewrites a remote. + remote := metadataRemote(lay) + got, err := d.Git.RemoteURL(ctx, sc.repo, remote) + switch { + case isRemoteUnconfigured(err): + if aerr := d.Git.AddRemote(ctx, sc.repo, remote, want); aerr != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "adding the dckt git remote", aerr)) + } + changed = true + case err != nil: + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt git remote", err)) + case got != want: + return fail(initRefusal(reposetup.StateConflict, fmt.Sprintf( + "the dckt git remote already points at %s, not %s; resolve it by hand (docket never rewrites a remote)", got, want))) + } + + // 6. The metadata branch: created create-only, or a verified init lineage + // adopted at its tip (the second-clone path). + metaRef := metadataRef(lay) + tip, createdBranch, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, remote, metaRef, sc.sourceRevision, sc.defaultBranch) + if refusal != nil { + return fail(*refusal) + } + changed = changed || createdBranch + + // 7. This clone's checkout under the store, hooks off. + if err := os.MkdirAll(lay.CheckoutsDir, 0o755); err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "creating the checkouts folder", err)) + } + createdWorktree, err := ensureMetadataWorktree(ctx, d.Git, sc.repo, lay.MetadataWorktree, metaRef, tip) + if err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "attaching the metadata checkout", err)) + } + changed = changed || createdWorktree + if err := d.Git.DisableWorktreeHooks(ctx, lay.MetadataWorktree); err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "disabling metadata-checkout hooks", err)) + } + + // 8. No .gitignore edit and no parent-facing dispatch surfaces: both are + // committed root files. Surfaces in a private repository belong to the + // follow-up change that keeps docket out of commits. + + // 9. Report the state the repository now classifies in. + facts, sc2, err := GatherSetupFacts(ctx, d, false) + if err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "re-reading the repository", err)) + } + if facts.RemoteMetadata.Presence == reposetup.PresencePresent { + augmentCheckFacts(ctx, d.Git, &facts, sc2) + } + state := reposetup.Classify(facts).State + + result := ResultNoOp + if changed { + result = ResultApplied + } + pending := debris.pending() + sort.Strings(pending) + out := newRepositoryOpResult(OperationRepositoryInit, result, RepositoryOpResult{ + RepositoryState: string(state), + PendingPaths: pending, + MetadataTip: string(tip), + SourceRevision: sc.sourceRevision, + }) + out.human = fmt.Sprintf("initialized private docket metadata: branch %s on %s, worktree %s", + lay.MetadataBranch, want, lay.MetadataWorktree) + if len(pending) > 0 { + out.human += "\n" + strings.Join(pending, "\n") + } + return out +} + +// ensureExcludeFile writes the neutral `# dckt:` block into the exclude file at +// path (absent reads as empty) when it is missing or stale, and reports whether +// it wrote. Malformed markers return *reposetup.MalformedExcludeError with the +// file untouched. +func ensureExcludeFile(path string) (bool, error) { + current, err := os.ReadFile(path) + if err != nil && !os.IsNotExist(err) { + return false, err + } + out, changed, err := reposetup.EnsureExcludeBlock(current) + if err != nil || !changed { + return false, err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return false, err + } + if err := os.WriteFile(path, out, 0o644); err != nil { + return false, err + } + return true, nil +} + +// privateConfigSeed is the first content of a new private config file. +const privateConfigSeed = "visibility: private\n" + +// ensurePrivateConfig writes the private config at path: the existing bytes, or +// privateConfigSeed for a new file, with the generated test policy applied. It +// reports whether it wrote. +func ensurePrivateConfig(path, primaryWorktree string, cfg config.Effective) (bool, error) { + tree := newOSTree(primaryWorktree) + return writeRepoConfig(path, func(existing []byte) ([]byte, error) { + base := existing + if base == nil { + base = []byte(privateConfigSeed) + } + edited, _, perr := reposetup.TestPolicyEdit(cfg, base, tree) + if perr != nil { + return nil, perr + } + if edited == nil && existing == nil { + return base, nil // a new file is written even with no test policy + } + return edited, nil + }) +} diff --git a/internal/app/repository_migrate.go b/internal/app/repository_migrate.go index dab869962..18c6c4487 100644 --- a/internal/app/repository_migrate.go +++ b/internal/app/repository_migrate.go @@ -12,6 +12,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" ) @@ -124,6 +125,10 @@ func RunRepositoryMigrate(ctx context.Context, d SetupDeps, o MigrateOptions) Re if err != nil { return migrateGatherFailure(err) } + if sc.layout.Mode == layout.Private { + return migrateRefusal(reposetup.Classify(facts).State, + "migrate converts a legacy repository to the shared layout; this repository is private and has nothing to migrate") + } // Before any phase logic, remove exactly the owned transient worktrees/refs an // abrupt death of a prior invocation may have left (recognized by ownership // shape; a user worktree or ambiguous registration is preserved and reported). diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go new file mode 100644 index 000000000..046f272e5 --- /dev/null +++ b/internal/app/repository_private_integration_test.go @@ -0,0 +1,349 @@ +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/testsupport" +) + +// This file is the private-visibility slice of the real-Git init/setup shard +// (prefix TestIntegrationRepoSetup). A private repository keeps the metadata +// branch `dckt` on a bare `dckt` remote under the data home, its checkout under +// that store, and its config in .git/dckt/config.yml — nothing docket-named in +// the repository and nothing pushed to origin. + +// newPrivateInitRepo builds the newInitRepo topology but commits NO .docket.yml +// (a private repository never reads one), points XDG_DATA_HOME at a fresh temp +// dir, and returns that data home. +func newPrivateInitRepo(t *testing.T, files map[string]string) (*initRepo, string) { + t.Helper() + requireRealGit(t) + root := testsupport.TempDir(t) + r := &initRepo{ + root: root, + origin: filepath.Join(root, "origin.git"), + writer: filepath.Join(root, "writer"), + invocation: filepath.Join(root, "invocation"), + } + runGit(t, root, "init", "--bare", "-b", "main", r.origin) + runGit(t, root, "init", "-b", "main", r.writer) + gitIdentity(t, r.writer) + writeRepoFile(t, r.writer, "README.md", "readme\n") + for rel, content := range files { + writeRepoFile(t, r.writer, rel, content) + } + runGit(t, r.writer, "add", "-A") + runGit(t, r.writer, "commit", "-q", "-m", "integration content") + runGit(t, r.writer, "remote", "add", "origin", r.origin) + runGit(t, r.writer, "push", "-q", "-u", "origin", "main") + + runGit(t, root, "clone", "-q", r.origin, r.invocation) + gitIdentity(t, r.invocation) + + data := testsupport.TempDir(t) + t.Setenv("XDG_DATA_HOME", data) + return r, data +} + +// runInitWith runs RunRepositoryInit against the invocation clone with o. +func (r *initRepo) runInitWith(t *testing.T, o InitOptions) RepositoryOpResult { + t.Helper() + client := newGitClient(t) + return RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, o) +} + +// runInitWithGlobal runs init with a global config layer holding globalYML. +// newGitClient isolates XDG_CONFIG_HOME, so the global layer is installed after +// the client is built. +func (r *initRepo) runInitWithGlobal(t *testing.T, globalYML string, o InitOptions) RepositoryOpResult { + t.Helper() + client := newGitClient(t) + cfgHome := testsupport.TempDir(t) + if err := os.MkdirAll(filepath.Join(cfgHome, "docket"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(cfgHome, "docket", "config.yml"), []byte(globalYML), 0o644); err != nil { + t.Fatal(err) + } + t.Setenv("XDG_CONFIG_HOME", cfgHome) + return RunRepositoryInit(context.Background(), SetupDeps{Git: client, RepoDir: r.invocation}, o) +} + +// privateLayoutOf resolves the clone's private layout from its origin URL and +// the test's data home, independently of the code under test's own resolution. +func privateLayoutOf(t *testing.T, dir, data string) layout.Layout { + t.Helper() + common := runGit(t, dir, "rev-parse", "--path-format=absolute", "--git-common-dir") + top := runGit(t, dir, "rev-parse", "--show-toplevel") + slug, err := layout.OwnerRepo(runGit(t, dir, "config", "--get", "remote.origin.url")) + if err != nil { + t.Fatal(err) + } + canonical, err := filepath.EvalSymlinks(data) + if err != nil { + t.Fatal(err) + } + return layout.PrivateLayout(common, top, canonical, slug) +} + +func TestIntegrationRepoSetupPrivateFreshInitCreatesNeutralLayout(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + res := r.runInitWith(t, InitOptions{Private: true}) + if res.Result != ResultApplied { + t.Fatalf("Result = %q (%s), want applied", res.Result, res.HumanText()) + } + lay := privateLayoutOf(t, r.invocation, data) + gitDir := r.gitDir(t) + + cfg, err := os.ReadFile(filepath.Join(gitDir, "dckt", "config.yml")) + if err != nil || !strings.Contains(string(cfg), "visibility: private") { + t.Errorf(".git/dckt/config.yml = %q (%v), want it to hold visibility: private", cfg, err) + } + exclude, err := os.ReadFile(filepath.Join(gitDir, "info", "exclude")) + if err != nil || !reposetup.ValidExcludeBlock(exclude) { + t.Errorf(".git/info/exclude = %q (%v), want a valid # dckt: block", exclude, err) + } + + url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url") + if url != lay.DefaultBareRemote || !strings.HasSuffix(url, filepath.Join("dckt", filepath.Base(lay.StoreDir), "remote.git")) { + t.Errorf("dckt remote URL = %q, want %q", url, lay.DefaultBareRemote) + } + if _, err := tryGit(url, "rev-parse", "--verify", "--quiet", "refs/heads/dckt"); err != nil { + t.Errorf("the bare store has no refs/heads/dckt: %v", err) + } + if r.remoteBranchExists(t, "docket") || r.remoteBranchExists(t, "dckt") { + t.Error("origin gained a docket or dckt branch; private metadata must never reach origin") + } + wts := runGit(t, r.invocation, "worktree", "list", "--porcelain") + if !strings.Contains(wts, "worktree "+lay.MetadataWorktree+"\n") || !strings.HasPrefix(lay.MetadataWorktree, filepath.Dir(lay.StoreDir)) { + t.Errorf("worktree list has no checkout %s under the data home:\n%s", lay.MetadataWorktree, wts) + } + for _, name := range []string{".docket", ".docket.yml", ".gitignore", ".docket.local.yml"} { + if _, err := os.Lstat(filepath.Join(r.invocation, name)); !os.IsNotExist(err) { + t.Errorf("%s exists in the repository root (err=%v); private init writes nothing there", name, err) + } + } + if st := runGit(t, r.invocation, "status", "--porcelain"); st != "" { + t.Errorf("git status --porcelain = %q, want empty", st) + } +} + +func TestIntegrationRepoSetupPrivateRerunIsNoOp(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) + } + res := r.runInitWith(t, InitOptions{}) + if res.Result != ResultNoOp { + t.Fatalf("re-run = %q (%s), want no-op", res.Result, res.HumanText()) + } + lay := privateLayoutOf(t, r.invocation, data) + prep := RunRepositoryPrepare(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation}, PrepareOptions{}) + if prep.Disposition != PrepareDispositionNoOp { + t.Fatalf("prepare disposition = %q (%s), want no-op", prep.Disposition, prep.HumanText()) + } + if prep.Context == nil || prep.Context.MetadataRemote != "dckt" || prep.Context.MetadataWorktreePath != lay.MetadataWorktree { + t.Errorf("prepare context = %+v, want MetadataRemote dckt and MetadataWorktreePath %q", prep.Context, lay.MetadataWorktree) + } +} + +// TestIntegrationRepoSetupPrivateInterruptedAfterConfigResumes proves the +// presence-encoded-state recovery: a run that died right after writing +// .git/dckt/ (so the repository reads as private) but before registering the +// dckt remote finishes on a plain re-run. +func TestIntegrationRepoSetupPrivateInterruptedAfterConfigResumes(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + gitDir := r.gitDir(t) + if err := os.MkdirAll(filepath.Join(gitDir, "dckt"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(gitDir, "dckt", "config.yml"), []byte("visibility: private\n"), 0o644); err != nil { + t.Fatal(err) + } + res := r.runInitWith(t, InitOptions{}) + if res.Result != ResultApplied { + t.Fatalf("resumed init = %q (%s), want applied", res.Result, res.HumanText()) + } + lay := privateLayoutOf(t, r.invocation, data) + if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != lay.DefaultBareRemote { + t.Errorf("dckt remote URL = %q, want %q", url, lay.DefaultBareRemote) + } +} + +func TestIntegrationRepoSetupPrivateSecondCloneAdoptsSharedStore(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("clone A init = %q (%s), want applied", res.Result, res.HumanText()) + } + node := planningDepsFor(t, r.invocation) + created := ChangeCreate(context.Background(), node.deps, r.invocation, validChangeCreateRequest()) + if created.Result != ResultApplied { + t.Fatalf("ChangeCreate in clone A = %q (%s)", created.Result, created.HumanText()) + } + layA := privateLayoutOf(t, r.invocation, data) + headA := runGit(t, layA.MetadataWorktree, "rev-parse", "HEAD") + + b := r.freshClone(t) + bRepo := &initRepo{root: r.root, origin: r.origin, writer: r.writer, invocation: b} + if res := bRepo.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("clone B init = %q (%s), want applied", res.Result, res.HumanText()) + } + layB := privateLayoutOf(t, b, data) + + urlA := runGit(t, r.invocation, "config", "--get", "remote.dckt.url") + urlB := runGit(t, b, "config", "--get", "remote.dckt.url") + if urlA != urlB { + t.Errorf("dckt URLs differ: A %q, B %q; both clones must share one store", urlA, urlB) + } + if layA.MetadataWorktree == layB.MetadataWorktree { + t.Errorf("both clones resolved the same checkout %q", layA.MetadataWorktree) + } + if _, err := os.Stat(filepath.Join(layB.MetadataWorktree, created.Path)); err != nil { + t.Errorf("clone B's checkout lacks A's change file %s: %v", created.Path, err) + } + if got := runGit(t, layA.MetadataWorktree, "rev-parse", "HEAD"); got != headA { + t.Errorf("clone A's checkout HEAD moved from %s to %s", headA, got) + } +} + +func TestIntegrationRepoSetupPrivateRemoteURLConflictRefuses(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + elsewhere := filepath.Join(r.root, "elsewhere.git") + runGit(t, r.invocation, "remote", "add", "dckt", elsewhere) + res := r.runInitWith(t, InitOptions{Private: true}) + if res.Result == ResultApplied || res.Result == ResultNoOp { + t.Fatalf("init = %q (%s), want a refusal", res.Result, res.HumanText()) + } + text := res.HumanText() + if !strings.Contains(text, elsewhere) || !strings.Contains(text, "remote.git") { + t.Errorf("refusal %q must name both the existing URL %q and the wanted store", text, elsewhere) + } + if !strings.Contains(text, "re-run `docket repository init`") { + t.Errorf("refusal %q must name the presence-encoded recovery", text) + } + if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != elsewhere { + t.Errorf("dckt remote rewritten to %q; docket never rewrites a remote", url) + } +} + +func TestIntegrationRepoSetupPrivateMetadataRemoteFlag(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + backup := filepath.Join(testsupport.TempDir(t), "backup.git") + runGit(t, r.root, "init", "--bare", "-q", backup) + res := r.runInitWith(t, InitOptions{Private: true, MetadataRemote: backup}) + if res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != backup { + t.Errorf("dckt remote URL = %q, want the flag %q", url, backup) + } + if _, err := tryGit(backup, "rev-parse", "--verify", "--quiet", "refs/heads/dckt"); err != nil { + t.Errorf("the flag remote has no refs/heads/dckt: %v", err) + } + lay := privateLayoutOf(t, r.invocation, data) + if _, err := os.Stat(lay.DefaultBareRemote); !os.IsNotExist(err) { + t.Errorf("default bare remote %s was created (err=%v); a flag URL never creates it", lay.DefaultBareRemote, err) + } +} + +func TestIntegrationRepoSetupInitModeFlagsRefuseSwitch(t *testing.T) { + r := newInitRepo(t, defaultSetupYML, nil) + if res := r.runInit(t); res.Result != ResultApplied { + t.Fatalf("shared init = %q (%s), want applied", res.Result, res.HumanText()) + } + res := r.runInitWith(t, InitOptions{Private: true}) + if res.Result != ResultInvalidState { + t.Fatalf("--private on a shared repository = %q (%s), want invalid-state", res.Result, res.HumanText()) + } + if _, err := os.Lstat(filepath.Join(r.gitDir(t), "dckt")); !os.IsNotExist(err) { + t.Errorf(".git/dckt exists after a refused switch (err=%v)", err) + } +} + +func TestIntegrationRepoSetupGlobalPrivateDefault(t *testing.T) { + const global = "visibility: private\n" + + t.Run("fresh with no flags inits private", func(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + res := r.runInitWithGlobal(t, global, InitOptions{}) + if res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + if _, err := os.Stat(filepath.Join(r.gitDir(t), "dckt")); err != nil { + t.Errorf("no .git/dckt after a global-private init: %v", err) + } + if r.remoteBranchExists(t, "docket") { + t.Error("origin gained a docket branch") + } + }) + + t.Run("committed visibility shared wins", func(t *testing.T) { + r := newInitRepo(t, defaultSetupYML+"visibility: shared\n", nil) + t.Setenv("XDG_DATA_HOME", testsupport.TempDir(t)) + res := r.runInitWithGlobal(t, global, InitOptions{}) + if res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + if !r.remoteBranchExists(t, "docket") { + t.Error("committed visibility: shared did not init shared") + } + if _, err := os.Lstat(filepath.Join(r.gitDir(t), "dckt")); !os.IsNotExist(err) { + t.Errorf(".git/dckt exists (err=%v)", err) + } + }) + + t.Run("--shared wins", func(t *testing.T) { + r := newInitRepo(t, defaultSetupYML, nil) + t.Setenv("XDG_DATA_HOME", testsupport.TempDir(t)) + res := r.runInitWithGlobal(t, global, InitOptions{Shared: true}) + if res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + if !r.remoteBranchExists(t, "docket") { + t.Error("--shared did not init shared") + } + }) + + t.Run("healthy shared stays shared", func(t *testing.T) { + t.Setenv("XDG_DATA_HOME", testsupport.TempDir(t)) + r := newHealthyRepo(t) + res := r.runInitWithGlobal(t, global, InitOptions{}) + if res.Result != ResultNoOp && res.Result != ResultApplied { + t.Fatalf("re-init = %q (%s), want it admitted as shared", res.Result, res.HumanText()) + } + if _, err := os.Lstat(filepath.Join(r.gitDir(t), "dckt")); !os.IsNotExist(err) { + t.Errorf(".git/dckt exists after re-init (err=%v); config never moves a repository", err) + } + chk := r.runCheck(t) + for _, f := range chk.Findings { + if f.Code == "visibility-mismatch" { + t.Errorf("check reported %+v; a global visibility never mismatches", f) + } + } + }) +} + +// TestIntegrationRepoSetupPrivateMigrateRefuses proves migrate refuses a +// private repository before any phase logic: it converts legacy repositories +// to the shared layout, and a private one has nothing to migrate. +func TestIntegrationRepoSetupPrivateMigrateRefuses(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + res := r.runMigrate(t, MigrateOptions{Authorized: true}) + if res.Result != ResultInvalidState || !strings.Contains(res.HumanText(), "this repository is private and has nothing to migrate") { + t.Fatalf("migrate = %q (%s), want the private refusal", res.Result, res.HumanText()) + } + if r.remoteBranchExists(t, "docket") { + t.Error("migrate published a docket branch to origin") + } +} diff --git a/internal/cli/repository.go b/internal/cli/repository.go index 32a87e256..6eb33a84d 100644 --- a/internal/cli/repository.go +++ b/internal/cli/repository.go @@ -31,8 +31,8 @@ import ( // the app entry points the repository subcommands dispatch to. They are package // variables so a test can stub them without a real repository. var ( - repositoryInitRunner = func(ctx context.Context, d app.SetupDeps) app.OperationResult { - return app.RunRepositoryInit(ctx, d) + repositoryInitRunner = func(ctx context.Context, d app.SetupDeps, o app.InitOptions) app.OperationResult { + return app.RunRepositoryInit(ctx, d, o) } repositoryCheckRunner = func(ctx context.Context, d app.SetupDeps) app.OperationResult { return app.RunRepositoryCheck(ctx, d) @@ -91,9 +91,13 @@ func newRepositoryCommand(setResult func(app.OperationResult)) *cobra.Command { } initCmd := repositorySubcommand("init", - "Initialize the docket metadata branch and persistent .docket worktree", + "Initialize the docket metadata branch and its metadata worktree (shared by default; --private keeps it on this machine)", func(c *cobra.Command, deps app.SetupDeps) { - setResult(repositoryInitRunner(c.Context(), deps)) + var o app.InitOptions + o.Private, _ = c.Flags().GetBool("private") + o.Shared, _ = c.Flags().GetBool("shared") + o.MetadataRemote, _ = c.Flags().GetString("metadata-remote") + setResult(repositoryInitRunner(c.Context(), deps, o)) }, // metadata-write (the parentless metadata root, published create-only to // the metadata branch) + local-write (local branch, .docket worktree, @@ -101,6 +105,14 @@ func newRepositoryCommand(setResult func(app.OperationResult)) *cobra.Command { // metadata-write, not external — external is only remote refs OUTSIDE the // metadata branch. EffectMetadataWrite, EffectLocalWrite) + // Bool usages carry no backticks: pflag reads a backticked word as the + // flag's value name, which a bool flag does not take. + initCmd.Flags().Bool("private", false, + "keep docket's metadata on this machine: a dckt branch pushed to a bare repository under ~/.local/share/dckt, with nothing docket-named in the repository (overrides the visibility setting)") + initCmd.Flags().Bool("shared", false, + "keep docket's metadata on a docket branch pushed to origin with a .docket/ worktree (overrides the visibility setting)") + initCmd.Flags().String("metadata-remote", "", + "with --private: push the dckt branch to this git `url` instead of the default bare repository") checkCmd := repositorySubcommand("check", "Report repository health with machine-readable findings (read-only)", func(c *cobra.Command, deps app.SetupDeps) { diff --git a/internal/cli/repository_test.go b/internal/cli/repository_test.go index 627dbb5c1..6a14b7790 100644 --- a/internal/cli/repository_test.go +++ b/internal/cli/repository_test.go @@ -51,7 +51,7 @@ func (r fakeInitResult) HumanText() string { return "human" } // transport for the repository command's result. func TestRepositoryInitJSONFlowsToPresenter(t *testing.T) { old := repositoryInitRunner - repositoryInitRunner = func(ctx context.Context, d app.SetupDeps) app.OperationResult { + repositoryInitRunner = func(ctx context.Context, d app.SetupDeps, o app.InitOptions) app.OperationResult { return fakeInitResult{Envelope: app.NewEnvelope("repository.init", app.ResultApplied)} } defer func() { repositoryInitRunner = old }() From 7a9245af41a01fa4f3574b239d1d2087800731bd Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:33:41 -0400 Subject: [PATCH 10/20] feat(repository): private-aware check/prepare/repair with visibility-mismatch and orphaned-checkout findings --- internal/app/repository_check.go | 66 +++++-- internal/app/repository_private_findings.go | 181 +++++++++++++++++ .../app/repository_private_findings_test.go | 150 ++++++++++++++ .../repository_private_integration_test.go | 187 ++++++++++++++++++ internal/app/repository_repair.go | 79 +++++++- internal/reposetup/health.go | 32 +++ internal/reposetup/health_test.go | 32 +++ 7 files changed, 710 insertions(+), 17 deletions(-) create mode 100644 internal/app/repository_private_findings.go create mode 100644 internal/app/repository_private_findings_test.go diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index af38e21cf..f7baecf1e 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "io/fs" "os" "path/filepath" "sort" @@ -14,6 +15,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/repository" @@ -97,8 +99,9 @@ func RunRepositoryCheck(ctx context.Context, d SetupDeps) RepositoryCheckResult // legacy `auto`) is a setup gap, not a topology fault: it surfaces here with // the `docket repository configure-tests` remedy. Read the committed // repository-layer `.docket.yml` bytes so the legacy-`auto` signal keys on - // exactly what the resolver read. - testConfig = reposetup.TestConfigFinding(sc.cfg, readCommittedDocketYML(sc.repo.PrimaryWorktree)) + // exactly what the resolver read (a private repository's + // .git/dckt/config.yml, through repoConfigTarget). + testConfig = reposetup.TestConfigFinding(sc.cfg, readRepoConfigBytes(sc)) } cls := reposetup.Classify(facts) @@ -107,16 +110,24 @@ func RunRepositoryCheck(ctx context.Context, d SetupDeps) RepositoryCheckResult if testConfig != nil { findings = append(findings, *testConfig) } + // The visibility findings explain; they never change the classified state. + // A mismatch is meaningful only once the repository is set up (a fresh + // repository's setting is what init will use). + if facts.RemoteMetadata.Presence == reposetup.PresencePresent { + findings = append(findings, privateCheckFindings(sc)...) + } return newCheckResult(cls, facts, findings) } -// readCommittedDocketYML returns the primary worktree's `.docket.yml` bytes — -// the repository-layer config the resolver reads — or nil when it is absent or +// readRepoConfigBytes returns the repository-layer config bytes the resolver +// read — the primary worktree's `.docket.yml`, or a private repository's +// .git/dckt/config.yml (repoConfigTarget) — or nil when it is absent or // unreadable. The health finding tolerates nil (it then rests on the resolved // config alone), so an absent or transiently unreadable file is never an error // here. -func readCommittedDocketYML(primaryWorktree string) []byte { - b, err := os.ReadFile(filepath.Join(primaryWorktree, docketYMLRel)) +func readRepoConfigBytes(sc setupContext) []byte { + abs, _, _ := repoConfigTarget(sc) + b, err := os.ReadFile(abs) if err != nil { return nil } @@ -234,8 +245,15 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac } // Committed guarantees and surface facts proven from the integration COMMIT - // tree, never the working tree. - if sc.sourceRevision != "" { + // tree, never the working tree. A private repository commits nothing: its + // ignore guarantee is the `# dckt:` block in the clone-local + // .git/info/exclude, and it never reads a .docket.yml, so it has no legacy + // key (the spec overrides gitignore-guarantee-must-be-committed there). + switch { + case sc.layout.Mode == layout.Private: + f.CommittedIgnoreBlock, f.CommittedIgnoreDetail = excludeBlockPresence(sc.repo.CommonDir) + f.LegacyConfigKey = reposetup.PresenceAbsent + case sc.sourceRevision != "": f.CommittedIgnoreBlock, f.CommittedIgnoreDetail = committedIgnorePresence(ctx, git, sc.repo, sc.sourceRevision) f.LegacyConfigKey = committedLegacyKeyPresence(ctx, git, sc.repo, sc.sourceRevision) } @@ -254,7 +272,7 @@ func augmentCheckFacts(ctx context.Context, git *gitcli.Client, f *reposetup.Fac if f.PrimaryAtRemoteTip == reposetup.PresenceAbsent && sc.sourceRevision != "" { f.PrimaryTipRelation = syncRelationship(ctx, git, sc.repo, sc.primaryHead, sc.sourceRevision) } - f.PendingReviewPaths = pendingReviewPaths(ctx, git, sc.repo, f.CommittedIgnoreBlock) + f.PendingReviewPaths = pendingReviewPaths(ctx, git, sc.repo, sc.layout.Mode, f.CommittedIgnoreBlock) // Authorized parent-facing surfaces: absent a drift probe (Task 11 owns the // surfaces-drift determination), an authorized declaration is reported as @@ -320,6 +338,25 @@ func committedIgnorePresence(ctx context.Context, git *gitcli.Client, repo gitcl return reposetup.CommittedIgnoreOutcome(blob, found, err) } +// excludeBlockPresence proves a private repository's ignore guarantee: the +// `# dckt:` block in /info/exclude. An absent file has no block; a read +// error is the safe Unknown, carried as an Unreadable detail. The other +// IgnoreDetail defects describe a committed .gitignore, so an absent block +// carries none (the private finding names the exclude file on its own). +func excludeBlockPresence(commonDir string) (reposetup.Presence, reposetup.IgnoreDetail) { + raw, err := os.ReadFile(filepath.Join(commonDir, "info", "exclude")) + switch { + case errors.Is(err, fs.ErrNotExist): + return reposetup.PresenceAbsent, reposetup.IgnoreDetail{} + case err != nil: + return reposetup.PresenceUnknown, reposetup.IgnoreDetail{Defect: reposetup.IgnoreDefectUnreadable} + case reposetup.ValidExcludeBlock(raw): + return reposetup.PresencePresent, reposetup.IgnoreDetail{} + default: + return reposetup.PresenceAbsent, reposetup.IgnoreDetail{} + } +} + // committedLegacyKeyPresence reports whether the integration COMMIT tree's // .docket.yml still carries the top-level metadata_branch key. A read or edit // error is the safe Unknown. @@ -391,13 +428,17 @@ func primaryOnIntegrationPresence(ctx context.Context, git *gitcli.Client, repo // the pending path too. The parent-facing dispatch surfaces are pending when they // differ from HEAD in the working tree. A status error yields no surface pending // paths (the safe empty), leaving the split to the other postconditions. -func pendingReviewPaths(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, committedIgnore reposetup.Presence) []string { +// +// A private repository writes neither .gitignore nor .docket.yml, so neither is +// ever one of its pending paths. +func pendingReviewPaths(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, mode layout.Mode, committedIgnore reposetup.Presence) []string { seen := map[string]bool{} var pending []string + private := mode == layout.Private // The .gitignore edit: pending iff its managed block is present in the working // tree but the integration commit does not yet carry it. - if committedIgnore != reposetup.PresencePresent { + if !private && committedIgnore != reposetup.PresencePresent { wt, err := os.ReadFile(filepath.Join(repo.PrimaryWorktree, gitignoreRel)) if err == nil && reposetup.ValidGitignoreBlock(wt) { seen[gitignoreRel] = true @@ -414,6 +455,9 @@ func pendingReviewPaths(ctx context.Context, git *gitcli.Client, repo gitcli.Rep if rel == gitignoreRel { continue // the .gitignore is decided by the committed-ignore fact above } + if private && rel == docketYMLRel { + continue // a private repository's config is .git/dckt/config.yml + } if docketManagedWorktreePaths[rel] && !seen[rel] { seen[rel] = true pending = append(pending, rel) diff --git a/internal/app/repository_private_findings.go b/internal/app/repository_private_findings.go new file mode 100644 index 000000000..a986b4770 --- /dev/null +++ b/internal/app/repository_private_findings.go @@ -0,0 +1,181 @@ +package app + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// This file holds the two check findings visibility adds: a repository-level +// config file whose `visibility` disagrees with the repository's actual mode +// (the setting never moves a repository), and a private store checkout whose +// clone moved or no longer exists. + +// The finding codes. +const ( + FindingVisibilityMismatch = "visibility-mismatch" + FindingOrphanedCheckout = "orphaned-checkout" +) + +// visibilityMismatchFinding reports a repository-level config file +// (.docket.yml, .docket.local.yml, or .git/dckt/config.yml) that explicitly +// sets a visibility other than the mode the repository is set up in. A global +// setting or the built-in default never mismatches: they only choose the mode +// a fresh repository is set up in. +func visibilityMismatchFinding(lay layout.Layout, eff config.Effective) *reposetup.Finding { + v := eff.Visibility + if !v.Explicit { + return nil + } + if v.Provenance.Layer != config.LayerRepository && v.Provenance.Layer != config.LayerRepositoryLocal { + return nil + } + if layout.Mode(v.Value) == lay.Mode { + return nil + } + src := v.Provenance.Source + return &reposetup.Finding{ + Code: FindingVisibilityMismatch, + Severity: reposetup.SeverityWarning, + Ref: src, + Message: fmt.Sprintf("%s sets visibility: %s, but this repository is set up %s; the setting does not move an existing repository.", + src, v.Value, lay.Mode), + Remedy: fmt.Sprintf("Change visibility in %s to %s, or switch the repository with `docket repository set-visibility %s`.", + src, lay.Mode, v.Value), + } +} + +// scanOrphanedCheckouts lists the private store's checkouts (other than this +// clone's own) whose .git file names a gitdir that no longer exists: the clone +// that owned them moved or was deleted. A shared layout, or a store with no +// checkouts folder, has none. A checkout whose .git file cannot be read or +// parsed is skipped — it is not proven ours. Any other probe error is +// returned, never read as "no orphans". +func scanOrphanedCheckouts(lay layout.Layout) ([]string, error) { + if lay.Mode != layout.Private || lay.CheckoutsDir == "" { + return nil, nil + } + entries, err := os.ReadDir(lay.CheckoutsDir) + if errors.Is(err, fs.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("listing %s: %w", lay.CheckoutsDir, err) + } + own := filepath.Clean(lay.MetadataWorktree) + var orphans []string + for _, e := range entries { + if !e.IsDir() { + continue + } + dir := filepath.Join(lay.CheckoutsDir, e.Name()) + if filepath.Clean(dir) == own { + continue + } + orphan, err := checkoutOrphaned(dir) + if err != nil { + return nil, err + } + if orphan { + orphans = append(orphans, dir) + } + } + sort.Strings(orphans) + return orphans, nil +} + +// checkoutOrphaned reports whether the checkout at dir names a gitdir that no +// longer exists. An unreadable or unparsable .git file is not an orphan. +func checkoutOrphaned(dir string) (bool, error) { + gitdir, ok := checkoutGitdir(dir) + if !ok { + return false, nil + } + _, err := os.Stat(gitdir) + switch { + case err == nil: + return false, nil + case errors.Is(err, fs.ErrNotExist): + return true, nil + default: + return false, fmt.Errorf("probing the gitdir of %s: %w", dir, err) + } +} + +// checkoutGitdir parses the gitdir a checkout's .git file names, resolving a +// relative path against the checkout. ok is false when the file cannot be +// read or names no gitdir. +func checkoutGitdir(dir string) (string, bool) { + raw, err := os.ReadFile(filepath.Join(dir, ".git")) + if err != nil { + return "", false + } + gitdir, ok := parseGitdirLine(raw) + if !ok { + return "", false + } + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(dir, gitdir) + } + return filepath.Clean(gitdir), true +} + +// parseGitdirLine returns the path on the first "gitdir:" line. +func parseGitdirLine(raw []byte) (string, bool) { + for _, line := range strings.Split(string(raw), "\n") { + if rest, ok := strings.CutPrefix(line, "gitdir:"); ok { + p := strings.TrimSpace(rest) + return p, p != "" + } + } + return "", false +} + +// orphanedCheckoutFinding is the warning one orphaned checkout yields. +func orphanedCheckoutFinding(path string) reposetup.Finding { + repairable := true + return reposetup.Finding{ + Code: FindingOrphanedCheckout, + Severity: reposetup.SeverityWarning, + Ref: path, + Message: "A metadata checkout whose clone moved or no longer exists.", + Remedy: "Run `docket repository repair` to remove it; its data lives in the metadata remote.", + Repairable: &repairable, + } +} + +// orphanScanUnverifiedFinding is the warning a failed orphan scan yields in +// place of the per-orphan findings: unverified, never "none". +func orphanScanUnverifiedFinding(err error) reposetup.Finding { + return reposetup.Finding{ + Code: FindingOrphanedCheckout + "-unverified", + Severity: reposetup.SeverityWarning, + Message: "The metadata store's checkouts could not be scanned for orphans (unverified, not proven absent): " + err.Error(), + Remedy: "Restore read access to the metadata store's checkouts folder, then re-run `docket repository check`.", + } +} + +// privateCheckFindings are the visibility findings check appends after +// classification. Neither changes the classified state. +func privateCheckFindings(sc setupContext) []reposetup.Finding { + var out []reposetup.Finding + if f := visibilityMismatchFinding(sc.layout, sc.cfg); f != nil { + out = append(out, *f) + } + orphans, err := scanOrphanedCheckouts(sc.layout) + if err != nil { + return append(out, orphanScanUnverifiedFinding(err)) + } + for _, p := range orphans { + out = append(out, orphanedCheckoutFinding(p)) + } + return out +} diff --git a/internal/app/repository_private_findings_test.go b/internal/app/repository_private_findings_test.go new file mode 100644 index 000000000..ca935a10e --- /dev/null +++ b/internal/app/repository_private_findings_test.go @@ -0,0 +1,150 @@ +package app + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/testsupport" +) + +// visibilityEff builds an Effective whose visibility value came from layer. +// explicit=false models the built-in default. +func visibilityEff(value string, layer config.LayerKind, source string, explicit bool) config.Effective { + var eff config.Effective + eff.Visibility = config.Value[string]{ + Value: value, + Explicit: explicit, + Provenance: config.Provenance{Layer: layer, Source: source}, + } + return eff +} + +func TestVisibilityMismatchOnlyFromRepositoryLevelFiles(t *testing.T) { + shared := layout.SharedLayout("/r/.git", "/r") + private := layout.PrivateLayout("/r/.git", "/r", "/data", "o-r") + cases := []struct { + name string + lay layout.Layout + eff config.Effective + want bool + source string + }{ + {"shared repo, committed file says private", shared, visibilityEff("private", config.LayerRepository, ".docket.yml", true), true, ".docket.yml"}, + {"private repo, private config says shared", private, visibilityEff("shared", config.LayerRepository, layout.PrivateConfigDisplay, true), true, layout.PrivateConfigDisplay}, + {"shared repo, local file says private", shared, visibilityEff("private", config.LayerRepositoryLocal, ".docket.local.yml", true), true, ".docket.local.yml"}, + {"shared repo, global says private", shared, visibilityEff("private", config.LayerGlobal, "/home/u/.config/docket/config.yml", true), false, ""}, + {"private repo, global says shared", private, visibilityEff("shared", config.LayerGlobal, "/home/u/.config/docket/config.yml", true), false, ""}, + {"shared repo, committed file agrees", shared, visibilityEff("shared", config.LayerRepository, ".docket.yml", true), false, ""}, + {"private repo, private config agrees", private, visibilityEff("private", config.LayerRepository, layout.PrivateConfigDisplay, true), false, ""}, + {"private repo, built-in default", private, visibilityEff("shared", config.LayerBuiltIn, "built-in", false), false, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := visibilityMismatchFinding(tc.lay, tc.eff) + if (got != nil) != tc.want { + t.Fatalf("visibilityMismatchFinding = %+v, want finding=%v", got, tc.want) + } + if got == nil { + return + } + if got.Code != FindingVisibilityMismatch || got.Ref != tc.source { + t.Errorf("finding = %+v, want code %q ref %q", got, FindingVisibilityMismatch, tc.source) + } + if !strings.Contains(got.Message, tc.source) || !strings.Contains(got.Message, "set up "+string(tc.lay.Mode)) { + t.Errorf("message %q must name the source and the repository's mode", got.Message) + } + if !strings.HasPrefix(got.Remedy, "Change visibility in "+tc.source+" to "+string(tc.lay.Mode)) { + t.Errorf("remedy %q must lead with the edit-the-file option", got.Remedy) + } + }) + } +} + +// writeCheckout makes a checkout dir under the layout's checkouts folder whose +// .git file names gitdir. +func writeCheckout(t *testing.T, dir, gitdir string) { + t.Helper() + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ".git"), []byte("gitdir: "+gitdir+"\n"), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestScanOrphanedCheckouts(t *testing.T) { + data := testsupport.TempDir(t) + common := testsupport.TempDir(t) + lay := layout.PrivateLayout(common, "/clones/mine", data, "o-r") + + liveGitdir := filepath.Join(common, "worktrees", "other") + if err := os.MkdirAll(liveGitdir, 0o755); err != nil { + t.Fatal(err) + } + missing := filepath.Join(testsupport.TempDir(t), "gone", ".git", "worktrees", "x") + + // The own checkout with a missing gitdir is never reported. + writeCheckout(t, lay.MetadataWorktree, missing) + other := filepath.Join(lay.CheckoutsDir, "other-11111111") + writeCheckout(t, other, liveGitdir) + moved := filepath.Join(lay.CheckoutsDir, "moved-22222222") + writeCheckout(t, moved, missing) + // No .git file, and an unparsable one: not proven ours, skipped. + if err := os.MkdirAll(filepath.Join(lay.CheckoutsDir, "bare-33333333"), 0o755); err != nil { + t.Fatal(err) + } + garbled := filepath.Join(lay.CheckoutsDir, "garbled-44444444") + if err := os.MkdirAll(garbled, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(garbled, ".git"), []byte("not a gitdir line\n"), 0o644); err != nil { + t.Fatal(err) + } + // A stray file beside the checkouts is not a checkout. + if err := os.WriteFile(filepath.Join(lay.CheckoutsDir, "note.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + got, err := scanOrphanedCheckouts(lay) + if err != nil { + t.Fatalf("scanOrphanedCheckouts: %v", err) + } + if want := []string{moved}; !reflect.DeepEqual(got, want) { + t.Errorf("orphans = %v, want %v", got, want) + } + + t.Run("relative gitdir resolves against the checkout", func(t *testing.T) { + rel := filepath.Join(lay.CheckoutsDir, "rel-55555555") + writeCheckout(t, rel, "../other-11111111") + got, err := scanOrphanedCheckouts(lay) + if err != nil { + t.Fatal(err) + } + if want := []string{moved}; !reflect.DeepEqual(got, want) { + t.Errorf("orphans = %v, want %v (a relative gitdir that exists is kept)", got, want) + } + if err := os.RemoveAll(rel); err != nil { + t.Fatal(err) + } + }) + + t.Run("shared mode", func(t *testing.T) { + got, err := scanOrphanedCheckouts(layout.SharedLayout(common, "/clones/mine")) + if got != nil || err != nil { + t.Errorf("shared scan = %v, %v; want nil, nil", got, err) + } + }) + + t.Run("missing checkouts dir", func(t *testing.T) { + empty := layout.PrivateLayout(common, "/clones/mine", testsupport.TempDir(t), "o-r") + got, err := scanOrphanedCheckouts(empty) + if got != nil || err != nil { + t.Errorf("scan of a missing checkouts dir = %v, %v; want nil, nil", got, err) + } + }) +} diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 046f272e5..045c0cbfc 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -347,3 +347,190 @@ func TestIntegrationRepoSetupPrivateMigrateRefuses(t *testing.T) { t.Error("migrate published a docket branch to origin") } } + +// initPrivateHealthy inits a private repository and sets its test policy with +// configure-tests --command true, the path to a healthy private repository. +func initPrivateHealthy(t *testing.T) (*initRepo, string) { + t.Helper() + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + cmd := "true" + ct := r.runConfigureTestsWith(t, ConfigureTestsOptions{Command: &cmd}) + if ct.Result != ResultApplied { + t.Fatalf("configure-tests = %q (%s), want applied", ct.Result, ct.HumanText()) + } + if len(ct.PendingPaths) != 0 || !strings.Contains(ct.HumanText(), "wrote "+layout.PrivateConfigDisplay) { + t.Errorf("private configure-tests = pending %v, %q; want no pending path and the private config named", ct.PendingPaths, ct.HumanText()) + } + return r, data +} + +// checkIn runs RunRepositoryCheck from dir. +func checkIn(t *testing.T, dir string) RepositoryCheckResult { + t.Helper() + return RunRepositoryCheck(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: dir}) +} + +// findingsWithCode returns the findings carrying code. +func findingsWithCode(fs []reposetup.Finding, code string) []reposetup.Finding { + var out []reposetup.Finding + for _, f := range fs { + if f.Code == code { + out = append(out, f) + } + } + return out +} + +func TestIntegrationRepoSetupPrivateCheckHealthy(t *testing.T) { + r, _ := initPrivateHealthy(t) + chk := r.runCheck(t) + if chk.RepositoryState != string(reposetup.StateHealthy) { + t.Fatalf("check state = %q, want healthy:\n%s", chk.RepositoryState, chk.HumanText()) + } + for _, f := range chk.Findings { + text := f.Ref + " " + f.Message + " " + f.Remedy + for _, name := range []string{".gitignore", ".docket.yml", ".docket"} { + if strings.Contains(text, name) { + t.Errorf("finding %+v names %s; a private repository has none", f, name) + } + } + } + if len(chk.Findings) != 0 || chk.CheckExitCode() != 0 { + t.Errorf("healthy private check = exit %d, findings %+v; want 0 and none", chk.CheckExitCode(), chk.Findings) + } +} + +func TestIntegrationRepoSetupPrivateCheckFlagsMissingExclude(t *testing.T) { + r, _ := initPrivateHealthy(t) + exclude := filepath.Join(r.gitDir(t), "info", "exclude") + if err := os.WriteFile(exclude, []byte("# a user line\n*.swp\n"), 0o644); err != nil { + t.Fatal(err) + } + chk := r.runCheck(t) + if chk.RepositoryState == string(reposetup.StateHealthy) { + t.Fatalf("check is healthy with the # dckt: block stripped:\n%s", chk.HumanText()) + } + got := findingsWithCode(chk.Findings, "committed-ignore-invalid") + if len(got) != 1 { + t.Fatalf("committed-ignore findings = %+v, want exactly one:\n%s", got, chk.HumanText()) + } + if got[0].Ref != ".git/info/exclude" || !strings.Contains(got[0].Message, ".git/info/exclude") || + !strings.Contains(got[0].Remedy, "docket repository init") { + t.Errorf("finding = %+v, want it to name .git/info/exclude and the init remedy", got[0]) + } +} + +func TestIntegrationRepoSetupPrivateVisibilityMismatch(t *testing.T) { + r, _ := initPrivateHealthy(t) + before := r.runCheck(t) + cfgPath := filepath.Join(r.gitDir(t), "dckt", "config.yml") + raw, err := os.ReadFile(cfgPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), "visibility: private\n") { + t.Fatalf("private config %q lacks visibility: private", raw) + } + edited := strings.Replace(string(raw), "visibility: private\n", "visibility: shared\n", 1) + if err := os.WriteFile(cfgPath, []byte(edited), 0o644); err != nil { + t.Fatal(err) + } + chk := r.runCheck(t) + got := findingsWithCode(chk.Findings, FindingVisibilityMismatch) + if len(got) != 1 || got[0].Ref != layout.PrivateConfigDisplay || !strings.Contains(got[0].Message, layout.PrivateConfigDisplay) { + t.Fatalf("mismatch findings = %+v, want one naming %s:\n%s", got, layout.PrivateConfigDisplay, chk.HumanText()) + } + if chk.RepositoryState != before.RepositoryState { + t.Errorf("state moved from %q to %q; the mismatch never changes the state", before.RepositoryState, chk.RepositoryState) + } + if _, err := os.Stat(filepath.Join(r.gitDir(t), "dckt")); err != nil { + t.Errorf(".git/dckt is gone (%v); config never moves a repository", err) + } +} + +func TestIntegrationRepoSetupPrivateBothLocalConfigsRefuse(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + local := filepath.Join(r.invocation, ".docket.local.yml") + if err := os.WriteFile(local, []byte("board_presentation: classic\n"), 0o644); err != nil { + t.Fatal(err) + } + privatePath := filepath.Join(r.gitDir(t), "dckt", "config.yml") + + chk := r.runCheck(t) + if chk.Result != ResultUnsupportedConfig { + t.Errorf("check result = %q (%s), want unsupported-config", chk.Result, chk.HumanText()) + } + if text := chk.HumanText(); !strings.Contains(text, local) || !strings.Contains(text, privatePath) { + t.Errorf("check text %q must name both %s and %s", text, local, privatePath) + } + + st := Status(context.Background(), NewGitStatusReader(newGitClient(t)), StatusOptions{RepoDir: r.invocation}) + if st.Result != ResultInvalidInput { + t.Errorf("status result = %q (%s), want invalid-input", st.Result, st.Message) + } + if !strings.Contains(st.Message, local) || !strings.Contains(st.Message, privatePath) { + t.Errorf("status message %q must name both %s and %s", st.Message, local, privatePath) + } +} + +// TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned moves a private clone: +// its old checkout under the store now points at a gitdir that no longer +// exists, so check reports it orphaned and repair removes it. +func TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("init = %q (%s), want applied", res.Result, res.HumanText()) + } + old := privateLayoutOf(t, r.invocation, data).MetadataWorktree + moved := filepath.Join(r.root, "moved") + if err := os.Rename(r.invocation, moved); err != nil { + t.Fatal(err) + } + + chk := checkIn(t, moved) + orphans := findingsWithCode(chk.Findings, FindingOrphanedCheckout) + if len(orphans) != 1 || orphans[0].Ref != old { + t.Fatalf("orphaned-checkout findings = %+v, want one for %s:\n%s", orphans, old, chk.HumanText()) + } + + d := SetupDeps{Git: newGitClient(t), RepoDir: moved} + preview := RunRepositoryRepair(context.Background(), d, RepairOptions{}) + if !preview.ConfirmationRequired() || !strings.Contains(preview.HumanText(), "remove orphaned checkout: "+old) { + t.Fatalf("repair preview = %q (%s), want it to list the orphan", preview.Result, preview.HumanText()) + } + if _, err := os.Stat(old); err != nil { + t.Fatalf("the preview touched the orphan: %v", err) + } + applied := RunRepositoryRepair(context.Background(), d, RepairOptions{Authorized: true, ExpectedSource: preview.SourceRevision}) + if applied.Result != ResultApplied { + t.Fatalf("repair = %q (%s), want applied", applied.Result, applied.HumanText()) + } + if _, err := os.Lstat(old); !os.IsNotExist(err) { + t.Errorf("the orphaned checkout %s survived the repair (err=%v)", old, err) + } + if again := findingsWithCode(checkIn(t, moved).Findings, FindingOrphanedCheckout); len(again) != 0 { + t.Errorf("re-check still reports orphans: %+v", again) + } + + // Prepare does NOT attach the new checkout directly: the moved clone's worktree + // registration still names the removed checkout, so the dckt branch is held + // by a now-prunable registration. Prepare refuses with the held-elsewhere + // finding and names `git worktree prune`; after the prune it attaches. + d = SetupDeps{Git: newGitClient(t), RepoDir: moved} + prep := RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) + if prep.Disposition != PrepareDispositionRefused || !strings.Contains(prep.HumanText(), "git worktree prune") { + t.Fatalf("prepare = %q (%s), want the held-elsewhere refusal naming git worktree prune", prep.Disposition, prep.HumanText()) + } + runGit(t, moved, "worktree", "prune") + newCheckout := privateLayoutOf(t, moved, data).MetadataWorktree + prep = RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) + if prep.Disposition != PrepareDispositionApplied || prep.Context == nil || prep.Context.MetadataWorktreePath != newCheckout { + t.Fatalf("prepare after prune = %q (%s), want the new checkout %s attached", prep.Disposition, prep.HumanText(), newCheckout) + } +} diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index b505938bc..19a20bb74 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -4,12 +4,14 @@ import ( "context" "errors" "fmt" + "os" "sort" "strings" "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/reposetup" ) @@ -68,6 +70,9 @@ type RepositoryRepairResult struct { RepairedFiles []string `json:"repaired_files,omitempty"` ManualReview []string `json:"manual_review,omitempty"` PendingLocal []string `json:"pending_local,omitempty"` + // OrphanCheckouts are the orphaned private-store checkouts the repair + // removes (on a preview) or removed. + OrphanCheckouts []string `json:"orphaned_checkouts,omitempty"` // PRBacklinks is the --pr-backlinks preview or report, one row per PR. PRBacklinks []PRBacklinkRepair `json:"pr_backlinks,omitempty"` // Findings carries a resolver diagnosis lifted from a gather failure. @@ -103,8 +108,12 @@ type repositoryRepairPlan struct { manual []string files []string // sorted, de-duplicated union of written paths contents map[string][]byte // final composed bytes per file in files + orphans []string // orphaned private-store checkouts to remove (scanOrphanedCheckouts) } +// empty reports whether the plan changes nothing. +func (p repositoryRepairPlan) empty() bool { return len(p.files) == 0 && len(p.orphans) == 0 } + // RunRepositoryRepair repairs every mechanically repairable finding `repository // check` reports on an already-migrated repository, under the two-pass // authorization model. @@ -124,7 +133,12 @@ func RunRepositoryRepair(ctx context.Context, d SetupDeps, o RepairOptions) Repo if err != nil { return repairInternalFailure(reposetup.StateHealthy, "planning the repairs", err) } - if len(plan.files) == 0 { + // The orphan prune: a scan that cannot complete refuses rather than reading + // as "no orphans". + if plan.orphans, err = scanOrphanedCheckouts(sc.layout); err != nil { + return repairExternalFailure(reposetup.StateHealthy, "scanning the metadata store for orphaned checkouts", err) + } + if plan.empty() { return repairNoOp(metadataTip, plan.manual) } if !o.Authorized { @@ -135,9 +149,41 @@ func RunRepositoryRepair(ctx context.Context, d SetupDeps, o RepairOptions) Repo if migrateSourceMoved(o.ExpectedSource, metadataTip) { return repairContended(metadataTip, o.ExpectedSource) } + // The orphan prune is independent of the metadata publish: it runs first and + // acts only on checkouts it re-proves orphaned now. + pruned, err := pruneOrphanedCheckouts(plan.orphans) + if err != nil { + return repairExternalFailure(reposetup.StateHealthy, "removing an orphaned checkout", err) + } + plan.orphans = pruned + if len(plan.files) == 0 { + return repairApplied("", metadataTip, plan) + } return executeRepositoryRepair(ctx, d.Git, sc, metadataTip, plan) } +// pruneOrphanedCheckouts removes each previewed checkout that is still an +// orphan, re-verifying it on the copy it acts on (learning +// decide-and-act-on-the-same-copy): a checkout whose gitdir reappeared, or that +// is already gone, is left alone. It returns the paths it removed. +func pruneOrphanedCheckouts(previewed []string) ([]string, error) { + var removed []string + for _, dir := range previewed { + orphan, err := checkoutOrphaned(dir) + if err != nil { + return removed, err + } + if !orphan { + continue + } + if err := os.RemoveAll(dir); err != nil { + return removed, fmt.Errorf("removing %s: %w", dir, err) + } + removed = append(removed, dir) + } + return removed, nil +} + // repairPreflight runs the shared gather and topology routing every repair mode // starts with and returns the setup context and the pinned metadata tip, or the // refusal to return verbatim. @@ -169,6 +215,13 @@ func repairPreflight(ctx context.Context, d SetupDeps) (setupContext, string, *R "an interrupted migration still has a live planning surface on the integration branch; run `docket repository migrate` to finish it, then re-run `docket repository repair`") return setupContext{}, "", &r case phaseResumeLocal: + // A private repository's metadata repairs publish from the repository + // itself, never through its checkout, and a moved clone's missing checkout + // is exactly what the orphan prune clears the way for, so an incomplete + // local attachment does not block it. + if sc.layout.Mode == layout.Private { + break + } r := repairRefusal(reposetup.StateNeedsReview, "the local .docket metadata attachment is incomplete; run `docket repository migrate` to finish attaching it, then re-run `docket repository repair`") return setupContext{}, "", &r @@ -404,6 +457,9 @@ func repairPreviewText(sc setupContext, metadataTip string, plan repositoryRepai for _, f := range plan.derived { fmt.Fprintf(&b, " [%s] %s\n", f.Code, f.Path) } + for _, o := range plan.orphans { + fmt.Fprintf(&b, " remove orphaned checkout: %s\n", o) + } writeManualReview(&b, plan.manual) return b.String() } @@ -430,6 +486,7 @@ func repairConfirmationRequired(sc setupContext, metadataTip string, plan reposi RepairedViews: derivedRepairFiles(plan.derived), RepairedFiles: plan.files, ManualReview: plan.manual, + OrphanCheckouts: plan.orphans, }) out.human = repairPreviewText(sc, metadataTip, plan) + "\nconfirmation required: re-run with --yes to authorize these repairs" @@ -438,10 +495,14 @@ func repairConfirmationRequired(sc setupContext, metadataTip string, plan reposi // repairApplied is the success document: new and prior tips, the repaired file // set, and the local sync remedy (the remote advanced; .docket fast-forwards on -// the next `docket repository prepare`). +// the next `docket repository prepare`), plus any orphaned checkouts removed. +// An orphan-only repair publishes nothing (newTip "") and needs no local sync. func repairApplied(newTip, priorTip string, plan repositoryRepairPlan) RepositoryRepairResult { - pending := []string{ - "fast-forward your local .docket metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision", + var pending []string + if len(plan.files) > 0 { + pending = []string{ + "fast-forward your local .docket metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision", + } } out := newRepairResult(ResultApplied, RepositoryRepairResult{ RepositoryState: string(reposetup.StateNeedsReview), @@ -452,10 +513,16 @@ func repairApplied(newTip, priorTip string, plan repositoryRepairPlan) Repositor RepairedFiles: plan.files, ManualReview: plan.manual, PendingLocal: pending, + OrphanCheckouts: plan.orphans, }) var b strings.Builder - fmt.Fprintf(&b, "repository repaired: metadata %s (%d file(s))\npending local sync: %s\n", - newTip, len(plan.files), strings.Join(pending, "; ")) + if len(plan.files) > 0 { + fmt.Fprintf(&b, "repository repaired: metadata %s (%d file(s))\npending local sync: %s\n", + newTip, len(plan.files), strings.Join(pending, "; ")) + } + for _, o := range plan.orphans { + fmt.Fprintf(&b, "removed orphaned checkout: %s\n", o) + } writeManualReview(&b, plan.manual) out.human = strings.TrimRight(b.String(), "\n") return out diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index 595c38535..3e0b3b4ad 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -581,6 +581,11 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { Remedy: fmt.Sprintf("Re-run `docket repository check` once the %s config read succeeds.", f.MetadataWorktreeRef), } case CondCommittedIgnoreValid: + if f.Private { + // A private repository's guarantee is the clone-local exclude block, + // read from the filesystem rather than the integration tree. + return excludeIgnoreFinding(f.CommittedIgnoreBlock) + } if !integrationResolved(f) { return nil // the unknown-authority diagnostic explains the skipped read } @@ -728,6 +733,33 @@ func withCanonicalBlock(instruction string) string { return instruction + "\n" + strings.TrimSuffix(string(GitignoreBlock()), "\n") } +// excludeFileRef is how a private repository's ignore findings name the file +// that carries its `# dckt:` block. +const excludeFileRef = ".git/info/exclude" + +// excludeIgnoreFinding is a private repository's ignore-guarantee finding: the +// `# dckt:` block in .git/info/exclude is missing (or malformed, or stale), or +// the file could not be read. Init writes the block idempotently, so re-running +// it is a remedy valid in exactly this state. +func excludeIgnoreFinding(p Presence) *Finding { + if p == PresenceUnknown { + return &Finding{ + Code: "committed-ignore-unverified", + Severity: SeverityWarning, + Ref: excludeFileRef, + Message: "The .git/info/exclude file could not be read, so the `# dckt:` ignore block cannot be verified (unverified, not proven absent).", + Remedy: "Restore read access to .git/info/exclude, then re-run `docket repository check`.", + } + } + return &Finding{ + Code: "committed-ignore-invalid", + Severity: SeverityError, + Ref: excludeFileRef, + Message: "The `# dckt:` ignore block is missing from .git/info/exclude.", + Remedy: "Run `docket repository init` to restore it.", + } +} + // committedIgnoreFinding renders the preserved ignore detail into the one // committed-ignore defect finding. The defect is explicitly located in the // committed integration tree: an uncommitted local fix does not establish diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index c5235bb6d..88b97f3dc 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -1119,3 +1119,35 @@ func TestFindingsUseResolvedWorktreeRef(t *testing.T) { } } } + +// TestPrivateIgnoreFindingNamesExcludeFile proves a private repository's +// ignore-guarantee findings name .git/info/exclude and the init remedy, never +// the committed .gitignore, and that a private probe is reported even with no +// resolved integration tip (the exclude file is read from the filesystem). +func TestPrivateIgnoreFindingNamesExcludeFile(t *testing.T) { + for _, tc := range []struct { + presence Presence + code string + }{ + {PresenceAbsent, "committed-ignore-invalid"}, + {PresenceUnknown, "committed-ignore-unverified"}, + } { + f := healthyFacts() + f.Private = true + f.CommittedIgnoreBlock = tc.presence + f.RemoteIntegration = BranchFact{Presence: PresenceUnknown} + fnd := conditionFinding(CondCommittedIgnoreValid, f) + if fnd == nil { + t.Fatalf("%v: no finding", tc.presence) + } + if fnd.Code != tc.code || fnd.Ref != ".git/info/exclude" || strings.Contains(fnd.Message+fnd.Remedy, ".gitignore") { + t.Errorf("%v: finding = %+v, want code %s naming .git/info/exclude only", tc.presence, fnd, tc.code) + } + } + f := healthyFacts() + f.Private = true + f.CommittedIgnoreBlock = PresenceAbsent + if fnd := conditionFinding(CondCommittedIgnoreValid, f); fnd.Remedy != "Run `docket repository init` to restore it." { + t.Errorf("remedy = %q, want the init remedy", fnd.Remedy) + } +} From b9b454effcf564d817d2faf1d2e973a7baa04d28 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 07:37:45 -0400 Subject: [PATCH 11/20] test(app): extract the claim-to-implemented driver and name the metadata remote explicitly --- internal/app/status_git_test.go | 8 +++++++- internal/app/workflow_e2e_test.go | 33 +++++++++++++++++++++---------- 2 files changed, 30 insertions(+), 11 deletions(-) diff --git a/internal/app/status_git_test.go b/internal/app/status_git_test.go index e29f1360c..edd5e4469 100644 --- a/internal/app/status_git_test.go +++ b/internal/app/status_git_test.go @@ -168,10 +168,14 @@ func changeRecord(id int, slug, title string) string { "---\n\nBody of " + slug + ".\n" } -// gitRepo is the bare origin plus writer and invocation clones. +// gitRepo is the bare origin plus writer and invocation clones. meta is the +// bare repository that holds the metadata branch: origin in a shared +// repository, the private store's remote.git in a private one. Every builder +// sets it explicitly, so a metadata read never silently defaults to origin. type gitRepo struct { root string origin string + meta string writer string invocation string } @@ -191,6 +195,7 @@ func newLegacyRepo(t *testing.T, files map[string]string) *gitRepo { r := &gitRepo{ root: root, origin: filepath.Join(root, "origin.git"), + meta: filepath.Join(root, "origin.git"), writer: filepath.Join(root, "writer"), invocation: filepath.Join(root, "invocation"), } @@ -255,6 +260,7 @@ func newDocketModeRepo(t *testing.T, mainFiles, docketRecords map[string]string) r := &gitRepo{ root: root, origin: filepath.Join(root, "origin.git"), + meta: filepath.Join(root, "origin.git"), writer: filepath.Join(root, "writer"), invocation: filepath.Join(root, "invocation"), } diff --git a/internal/app/workflow_e2e_test.go b/internal/app/workflow_e2e_test.go index 561b62bc0..76ec5b7c5 100644 --- a/internal/app/workflow_e2e_test.go +++ b/internal/app/workflow_e2e_test.go @@ -54,8 +54,6 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . slug = "widget" ) recPath := groomPath(id, slug) - planPath := "docs/superpowers/plans/2026-08-17-widget-plan.md" - resultsPath := "docs/results/2026-08-17-" + slug + "-results.md" specPath := "docs/superpowers/specs/2026-08-17-" + slug + "-design.md" // A resolved build.test_command is required so EvidenceRecord (build-owned @@ -71,6 +69,21 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . recPath: specLinkedBuildReadyChange(id, slug, specPath), specPath: workflowMetadataSpec(id, slug), }) + driveClaimToImplemented(t, repo, m.branch, ghBin, id, slug, recPath, specPath, entries...) +} + +// driveClaimToImplemented is the post-build half of runClaimToImplemented: from +// the implementation context through run.verify, for an already build-ready, +// spec-linked change. Every metadata read goes to repo.meta on branch — the +// bare repository that holds the metadata branch, which is origin only in a +// shared repository — while feature-branch reads stay in the workspace. +func driveClaimToImplemented(t *testing.T, repo *gitRepo, branch, ghBin string, id int, slug, recPath, specPath string, entries ...workflowEntry) { + t.Helper() + if repo.meta == "" { + t.Fatal("driveClaimToImplemented: repo.meta is unset; every builder names the metadata remote explicitly") + } + planPath := "docs/superpowers/plans/2026-08-17-" + slug + "-plan.md" + resultsPath := "docs/results/2026-08-17-" + slug + "-results.md" ctx := context.Background() node := planningDepsFor(t, repo.invocation) @@ -82,11 +95,11 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // The metadata remote tip BEFORE the run: every commit past it must be an // engine transaction. - baseTip := originTip(t, repo.origin, m.branch) + baseTip := originTip(t, repo.meta, branch) // ver reads the change record's current record revision from the bare origin — // the independent oracle each exact-revision request submits. - ver := func() string { return blobRevisionAt(t, repo.origin, m.branch, recPath) } + ver := func() string { return blobRevisionAt(t, repo.meta, branch, recPath) } complete := func(runContext string) GitHubDeps { // (1) Authoritative implementation context. @@ -141,7 +154,7 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . if attach.Result != ResultApplied { t.Fatalf("attach plan = %q (reason %q msg %q findings %v)", attach.Result, attach.Reason, attach.Message, attach.Findings) } - storedPlan, ok := originFile(t, repo.origin, m.branch, planPath) + storedPlan, ok := originFile(t, repo.meta, branch, planPath) if !ok || !strings.Contains(storedPlan, "docket:backlink:start") || !strings.Contains(storedPlan, "Concrete steps here.") { t.Fatalf("plan file at the metadata remote tip is missing or lacks its backlink:\n%s", storedPlan) } @@ -169,7 +182,7 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . if attachR.Result != ResultApplied { t.Fatalf("attach results = %q (reason %q msg %q findings %v)", attachR.Result, attachR.Reason, attachR.Message, attachR.Findings) } - storedResults, ok := originFile(t, repo.origin, m.branch, resultsPath) + storedResults, ok := originFile(t, repo.meta, branch, resultsPath) if !ok || !strings.Contains(storedResults, "docket:backlink:start") { t.Fatalf("results file at the metadata remote tip is missing or lacks its backlink:\n%s", storedResults) } @@ -212,7 +225,7 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . if v := EvidenceVerify(EvidenceVerifyRequest{RecordFile: evidenceBytes, Head: head}); v.Result != ResultApplied { t.Fatalf("evidence verify after a results checkpoint = %q (verdict %q reason %q)", v.Result, v.Verdict, v.Reason) } - if got, _ := originFile(t, repo.origin, m.branch, resultsPath); !strings.Contains(got, "the gate certifies this head") { + if got, _ := originFile(t, repo.meta, branch, resultsPath); !strings.Contains(got, "the gate certifies this head") { t.Fatalf("the later checkpoint did not replace the results on the metadata branch:\n%s", got) } @@ -290,11 +303,11 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // at the metadata remote tip, and the record evidence — written after the // post-gate results checkpoint — still verifies against the head. for _, p := range []string{planPath, resultsPath} { - if _, ok := originFile(t, repo.origin, m.branch, p); !ok { + if _, ok := originFile(t, repo.meta, branch, p); !ok { t.Errorf("%s is missing at the metadata remote tip", p) } } - record, ok := originFile(t, repo.origin, m.branch, recPath) + record, ok := originFile(t, repo.meta, branch, recPath) if !ok { t.Fatalf("change record %s is missing at the metadata remote tip", recPath) } @@ -319,7 +332,7 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // transitions are exactly the operations that ran — two results // checkpoints included — and no direct skill-owned metadata write slipped // in. - assertEngineOnlyMetadataCommits(t, repo.origin, m.branch, baseTip, + assertEngineOnlyMetadataCommits(t, repo.meta, branch, baseTip, []string{"change.attach-plan", "change.attach-results", "change.attach-results", "change.claim", "change.mark-implemented", "change.reconcile"}) return gdeps } From 2bb631ab84ddb74cd24ce8e09b991cd9ec782676 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:04:55 -0400 Subject: [PATCH 12/20] =?UTF-8?q?test(app):=20private-visibility=20accepta?= =?UTF-8?q?nce=20=E2=80=94=20lifecycle,=20concurrent=20claims,=20resolver?= =?UTF-8?q?=20mutations?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TestIntegrationWorkflowLifecyclePrivateInitToImplemented drives a private repository from init --private through create, groom, claim, plan, build, PR and mark-implemented against the dckt store, then proves origin gained only the feature branch, the only ignored entry is .worktrees/, git branch and git remote show dckt, and a walk of the whole clone (.git included) finds no docket-named path. TestRaceIntegrationAppConcurrencyPrivateClaims OneAppliesOneContends races two clones' keyed claims of one change: one applies, the other contends, and nothing reaches origin. The race exposed a product bug, shared mode too: claimRequestID was claim-- for every claim while the digest also carried the run-context hash. Two dispatches with different run contexts shared the request id, so the loser's retry after its lost lease found the winner's receipt under a different digest and failed request-id-reused (invalid-input) instead of reporting `contended`. A keyed claim's request id now carries a 16-hex prefix of the hashed run context. The loser misses the winner's receipt and the exact-revision expectation reports it contended. An identical keyed retry still replays as already-claimed, and an ungated claim keeps the bare claim-- id, so ungated replays and existing receipts are unchanged. The digest still binds the full hash, so a prefix collision falls back to the request-id-reused refusal and can never replay another context's receipt. TestIntegrationWorkflowRepoClaimForeignRunContextSameRevisionContends is the deterministic regression; TestClaimRequestIDIdentity pins the id's shape. Resolver mutations (each reddens the lifecycle test): MetadataRemote -> SharedRemote fails init on the remote-URL conflict; MetadataBranch -> SharedName loses the dckt branch on the store; StateName -> SharedName leaves .git/docket in the walk; MetadataWorktree -> /.docket shows ?? .docket/ and .docket in the walk. Budgets, worst serial reading on a quiet machine: workflowlifecycle 46.16s (row 35 -> 55; 34.82s without the new 7.8s private test), concurrency 35.56s (35 -> 45), reposetup 27.73s (15 -> 35; Tasks 7-9's private tests), workflow 26.36s (row 35 kept, 8.64s margin). Raised per the plan's rule; the prefixes the plan fixes leave no non-overlapping sibling shard to split into. --- ...ncurrency_private_race_integration_test.go | 136 ++++++++++++ internal/app/change_claim.go | 30 ++- internal/app/change_claim_test.go | 35 ++- internal/app/workflow_integration_test.go | 68 ++++++ .../app/workflow_private_integration_test.go | 200 ++++++++++++++++++ tests/runtime-budgets.tsv | 6 +- 6 files changed, 461 insertions(+), 14 deletions(-) create mode 100644 internal/app/app_concurrency_private_race_integration_test.go create mode 100644 internal/app/workflow_private_integration_test.go diff --git a/internal/app/app_concurrency_private_race_integration_test.go b/internal/app/app_concurrency_private_race_integration_test.go new file mode 100644 index 000000000..4d13df903 --- /dev/null +++ b/internal/app/app_concurrency_private_race_integration_test.go @@ -0,0 +1,136 @@ +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/danielhanold/docket/internal/layout" +) + +// seedBuildReadyPrivate creates one change through node and grooms it to +// build-ready with a spec, both through the real engine against the private +// store. It returns the change id and its record revision on the store. +func seedBuildReadyPrivate(t *testing.T, node realNode) (id int, rev string) { + t.Helper() + ctx := context.Background() + store := resolvedPrivateLayout(t, node.dir).DefaultBareRemote + created := ChangeCreate(ctx, node.deps, node.dir, validChangeCreateRequest()) + if created.Result != ResultApplied { + t.Fatalf("change create = %q (%s, findings %v)", created.Result, created.HumanText(), created.Findings) + } + groom := ChangeGroom(ctx, node.deps, node.dir, ChangeGroomRequest{ + ChangeID: created.ID, + Path: created.Path, + Revision: blobRevisionAt(t, store, privateFixtureBranch, created.Path), + Outcome: GroomSpec, + SpecMarkdown: "# Widget: design\n\nBuild the widget.\n", + }) + if groom.Result != ResultApplied { + t.Fatalf("change groom = %q (%s, findings %v)", groom.Result, groom.HumanText(), groom.Findings) + } + return created.ID, blobRevisionAt(t, store, privateFixtureBranch, created.Path) +} + +// clonePrivate makes a second clone of r's origin and runs init --private in +// it, so it adopts the store clone A already published. +func clonePrivate(t *testing.T, r *initRepo) string { + t.Helper() + dir := r.freshClone(t) + second := &initRepo{root: r.root, origin: r.origin, writer: r.writer, invocation: dir} + if res := second.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("second clone init --private = %q (%s), want applied", res.Result, res.HumanText()) + } + return dir +} + +// race shard (change 0531, acceptance 2): two clones of one private repository +// claim the same build-ready change at the same revision at the same moment. +// The private store is the shared authority and the lease push to it is the +// compare-and-swap: exactly one claim applies and the other contends, writing +// nothing. Each contender carries its own run context: identical ungated claims +// share claimRequestID and digest, so they would be an idempotent replay rather +// than a contention. The run context is part of the claim's request id, so the +// loser's retry after its lost lease misses the winner's receipt and the +// exact-revision expectation reports it contended. -race guards the shared +// adapter/transaction paths. +func TestRaceIntegrationAppConcurrencyPrivateClaimsOneAppliesOneContends(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("clone A init --private = %q (%s), want applied", res.Result, res.HumanText()) + } + nodeA := planningDepsFor(t, r.invocation) + id, rev := seedBuildReadyPrivate(t, nodeA) + + b := clonePrivate(t, r) + nodeB := planningDepsFor(t, b) + store := resolvedPrivateLayout(t, r.invocation).DefaultBareRemote + if got := runGit(t, b, "config", "--get", "remote.dckt.url"); got != store { + t.Fatalf("clone B's dckt remote = %q, want the shared store %q", got, store) + } + + // Each run context is minted in its own clone and lands under that clone's + // private state folder, never a docket-named one. + tokens := [2]string{"ctx-a", "ctx-b"} + for i, node := range []realNode{nodeA, nodeB} { + mintRunTrackerWithHash(t, node.dir, runTrackerHashToken(tokens[i]), false) + common := runGit(t, node.dir, "rev-parse", "--path-format=absolute", "--git-common-dir") + if _, err := os.Stat(filepath.Join(common, layout.PrivateName, runTrackerDirName)); err != nil { + t.Fatalf("clone %d's run tracker is not under .git/%s: %v", i, layout.PrivateName, err) + } + if _, err := os.Lstat(filepath.Join(common, "docket")); !os.IsNotExist(err) { + t.Fatalf("clone %d has a .git/docket state folder (err=%v)", i, err) + } + } + + before := originTip(t, store, privateFixtureBranch) + var ( + wg sync.WaitGroup + start = make(chan struct{}) + results [2]ChangeClaimResult + ) + for i, node := range []realNode{nodeA, nodeB} { + i, node := i, node + wg.Add(1) + go func() { + defer wg.Done() + <-start + results[i] = ChangeClaim(context.Background(), node.deps, node.dir, + ChangeClaimRequest{ID: id, Revision: rev, RunContext: tokens[i]}) + }() + } + close(start) + wg.Wait() + + applied, contended := 0, 0 + for i, res := range results { + switch { + case res.Result == ResultApplied && res.Disposition == ClaimDispositionApplied: + applied++ + case res.Result == ResultContended && res.Disposition == ClaimDispositionContended: + contended++ + default: + t.Errorf("contender %d = (%q, %q), want applied or contended (findings %v, failure %+v)", i, res.Result, res.Disposition, res.Findings, res.Failure) + } + } + if applied != 1 || contended != 1 { + t.Fatalf("concurrent private claims: applied=%d contended=%d, want exactly one of each", applied, contended) + } + + // Exactly one claim commit landed on the store, and origin never saw it. + if n := runGit(t, store, "rev-list", "--count", before+".."+privateFixtureBranch); n != "1" { + t.Errorf("the store carries %s claim commits past the seed, want exactly 1", n) + } + for _, name := range []string{privateFixtureBranch, "docket"} { + if _, err := tryGit(r.origin, "rev-parse", "--verify", "--quiet", "refs/heads/"+name); err == nil { + t.Errorf("origin has refs/heads/%s; private metadata never reaches origin", name) + } + } + for _, dir := range []string{r.invocation, b} { + assertNoDocketPathUnder(t, dir) + } +} diff --git a/internal/app/change_claim.go b/internal/app/change_claim.go index 138f15ed1..6baa18e62 100644 --- a/internal/app/change_claim.go +++ b/internal/app/change_claim.go @@ -80,8 +80,9 @@ type ChangeClaimRequest struct { // RunContext is the run-tracker run context token from run.start. It // is optional — an ungated claim omits it. When present it is hashed at this // boundary (runTrackerHashToken), so the raw token never enters the transaction, the - // receipt, or any finding; only its hash is folded into the idempotency digest - // and recorded as the durable claim proof (change 0407). + // receipt, or any finding; only its hash is folded into the idempotency key + // (the digest, and a prefix in the request id — see claimRequestID) and + // recorded as the durable claim proof (change 0407). RunContext string `json:"run_context,omitempty"` } @@ -439,12 +440,29 @@ func resolveClaimTarget(ctx context.Context, deps PlanningDeps, pin StatusPin, e return c.Path(), c.Slug(), facts, nil } +// claimRequestIDContextLen is how many hex characters of the hashed run context +// a keyed claim's request id carries. With a 64-hex (SHA-256) revision the +// composed id still fits the engine's 128-byte request-id grammar. +const claimRequestIDContextLen = 16 + // claimRequestID derives the idempotency request id for a claim from its own -// (id, revision) content, so a lost-response retry of the same request reuses the -// key and replays the original receipt. Revision is a full-hex blob id, so the -// composed id satisfies the engine's request-id grammar. +// identity — (id, revision) and, for a keyed dispatch, its run context — so a +// lost-response retry of the same request reuses the key and replays the +// original receipt. A claim of the same (id, revision) under ANOTHER run context +// carries a different key: it misses the winner's receipt, and the exact-revision +// expectation then reports the moved record as `contended`, the disposition a +// foreign claim owes its caller. Sharing the key would instead hit the winner's +// receipt with a different digest and fail as request-id-reused. An ungated claim +// keeps the bare (id, revision) key, its identity before run contexts existed. +// The context term is a prefix of the hashed context; the digest binds the full +// hash, so a prefix collision degrades to the engine's request-id-reused refusal, +// never a cross-context replay. Revision is a full-hex blob id, so the composed +// id satisfies the engine's request-id grammar. func claimRequestID(req ChangeClaimRequest) string { - return fmt.Sprintf("claim-%d-%s", req.ID, req.Revision) + if req.RunContext == "" { + return fmt.Sprintf("claim-%d-%s", req.ID, req.Revision) + } + return fmt.Sprintf("claim-%d-%s-%s", req.ID, req.Revision, runTrackerHashToken(req.RunContext)[:claimRequestIDContextLen]) } // claimResultFromOutcome folds a transaction outcome into the claim result. On diff --git a/internal/app/change_claim_test.go b/internal/app/change_claim_test.go index 5889db412..a48e16038 100644 --- a/internal/app/change_claim_test.go +++ b/internal/app/change_claim_test.go @@ -230,9 +230,12 @@ func gateClaimDeps(t *testing.T, engine *claimGateEngine, corpus []StatusBlob) P // TestClaimSameIDDifferentContextDigestDiffers: two dispatches submitting the // SAME (id, revision) under different contexts must not share the idempotency -// path — their digests differ while their request ids match, so the engine's -// replay scan refuses the second as id-reuse rather than replaying the first's -// receipt (criterion 3). +// path (change 0407 criterion 3) — neither their digests nor their request ids +// match, so the second never replays the first's receipt. Distinct request ids +// also keep the second off the engine's request-id-reused refusal: it reaches the +// exact-revision expectation and loses as `contended` (change 0531), which +// TestIntegrationWorkflowRepoClaimForeignRunContextSameRevisionContends proves +// against a real engine. func TestClaimSameIDDifferentContextDigestDiffers(t *testing.T) { h1 := runTrackerHashToken("tokA") h2 := runTrackerHashToken("tokB") @@ -249,8 +252,30 @@ func TestClaimSameIDDifferentContextDigestDiffers(t *testing.T) { } reqA := claimRequestID(ChangeClaimRequest{ID: 3, Revision: gateClaimRevision, RunContext: "tokA"}) reqB := claimRequestID(ChangeClaimRequest{ID: 3, Revision: gateClaimRevision, RunContext: "tokB"}) - if reqA != reqB { - t.Errorf("request ids differ (%q vs %q); they must match so the engine's replay scan sees id-reuse", reqA, reqB) + if reqA == reqB { + t.Errorf("request ids match across differing contexts (%q); the loser would hit the winner's receipt and fail request-id-reused instead of contending", reqA) + } +} + +// TestClaimRequestIDIdentity pins the claim request id's identity: the same +// keyed request always derives the same id (a lost-response retry replays), an +// ungated claim keeps the bare (id, revision) id it had before run contexts +// existed (so its committed receipts still replay), and the keyed id stays +// inside the engine's request-id grammar for a 64-hex revision. +func TestClaimRequestIDIdentity(t *testing.T) { + keyed := ChangeClaimRequest{ID: 3, Revision: gateClaimRevision, RunContext: "tokA"} + if a, b := claimRequestID(keyed), claimRequestID(keyed); a != b { + t.Errorf("the same keyed request derived %q then %q; a lost-response retry would not replay", a, b) + } + if got, want := claimRequestID(ChangeClaimRequest{ID: 3, Revision: gateClaimRevision}), "claim-3-"+gateClaimRevision; got != want { + t.Errorf("ungated request id = %q, want %q", got, want) + } + if got := claimRequestID(keyed); strings.Contains(got, "tokA") { + t.Errorf("request id %q carries the raw run context; only its hash may enter the key", got) + } + wide := ChangeClaimRequest{ID: 99999, Revision: strings.Repeat("ab", 32), RunContext: "tokA"} + if id := claimRequestID(wide); !validRequestID(id) { + t.Errorf("keyed request id %q (%d bytes) for a 64-hex revision breaks the request-id grammar", id, len(id)) } } diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index 512417fa5..60107572f 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -472,6 +472,74 @@ func TestIntegrationWorkflowRepoClaimRaceLosesCleanly(t *testing.T) { } } +// TestIntegrationWorkflowRepoClaimForeignRunContextSameRevisionContends is the +// keyed-dispatch twin of TestIntegrationWorkflowRepoClaimRaceLosesCleanly, run +// deterministically: two dispatches, +// each with its own run context, claim the SAME change at the SAME revision. The +// second arrives after the first landed — exactly what a concurrent loser sees on +// its retry after losing the lease push. A foreign claim is `contended`, never a +// request-id-reused failure: the run context is part of the claim's request +// identity, so the loser misses the winner's receipt and the exact-revision +// expectation reports the moved record. The winner's own identical retry still +// replays as `already-claimed`. +func TestIntegrationWorkflowRepoClaimForeignRunContextSameRevisionContends(t *testing.T) { + requireRealGit(t) + const ( + id = 3 + slug = "widget" + ) + recPath := groomPath(id, slug) + for _, m := range planRepoModes() { + m := m + t.Run(m.name, func(t *testing.T) { + repo := m.build(t, map[string]string{ + recPath: buildReadyChange(id, slug), + }) + ctx := context.Background() + nodeA := planningDepsFor(t, cloneOrigin(t, repo.origin)) + nodeB := planningDepsFor(t, cloneOrigin(t, repo.origin)) + keyA := mintRunTrackerWithHash(t, nodeA.dir, runTrackerHashToken("ctx-a"), false) + keyB := mintRunTrackerWithHash(t, nodeB.dir, runTrackerHashToken("ctx-b"), false) + revision := blobRevisionAt(t, repo.origin, m.branch, recPath) + beforeTip := originTip(t, repo.origin, m.branch) + + won := ChangeClaim(ctx, nodeA.deps, nodeA.dir, ChangeClaimRequest{ID: id, Revision: revision, RunContext: "ctx-a"}) + if won.Result != ResultApplied || won.Disposition != ClaimDispositionApplied { + t.Fatalf("first claim = (%q, %q), want applied/applied (findings %v, failure %+v)", won.Result, won.Disposition, won.Findings, won.Failure) + } + wonTip := originTip(t, repo.origin, m.branch) + + lost := ChangeClaim(ctx, nodeB.deps, nodeB.dir, ChangeClaimRequest{ID: id, Revision: revision, RunContext: "ctx-b"}) + if lost.Result != ResultContended || lost.Disposition != ClaimDispositionContended || lost.Failure != nil { + t.Fatalf("foreign-context claim = (%q, %q), want contended/contended with no failure (findings %v, failure %+v)", lost.Result, lost.Disposition, lost.Findings, lost.Failure) + } + if got := originTip(t, repo.origin, m.branch); got != wonTip { + t.Errorf("the contended claim moved the metadata remote: %q -> %q", wonTip, got) + } + // The loser's reservation never became ownership. + if b, ok, err := LoadRunTrackerClaimBinding(nodeB.dir, keyB); err != nil || (ok && b.Confirmed) { + t.Errorf("loser's claim binding = (%+v, present %v, err %v); a contended claim must confirm nothing", b, ok, err) + } + + // The winner's lost-response retry — same context, same revision — is + // an idempotent replay of its own receipt, committing nothing. + replay := ChangeClaim(ctx, nodeA.deps, nodeA.dir, ChangeClaimRequest{ID: id, Revision: revision, RunContext: "ctx-a"}) + if replay.Result != ResultApplied || replay.Disposition != ClaimDispositionAlreadyClaimed { + t.Fatalf("winner's retry = (%q, %q), want applied/already-claimed (findings %v, failure %+v)", replay.Result, replay.Disposition, replay.Findings, replay.Failure) + } + if got := originTip(t, repo.origin, m.branch); got != wonTip { + t.Errorf("the replay committed: %q -> %q", wonTip, got) + } + if n := runGit(t, repo.origin, "rev-list", "--count", beforeTip+".."+wonTip); n != "1" { + t.Errorf("metadata remote carries %s claim commits, want exactly 1", n) + } + if b, ok, err := LoadRunTrackerClaimBinding(nodeA.dir, keyA); err != nil || !ok || !b.Confirmed || b.Revision != wonTip { + t.Errorf("winner's claim binding = (%+v, present %v, err %v); want confirmed at %s", b, ok, err, wonTip) + } + }) + } +} + // TestClaimRetryAfterLostResponse proves the idempotency key makes a lost-response // retry safe: the identical claim request, re-run after its receipt was discarded, // replays the original applied claim as `already-claimed` and commits nothing new, diff --git a/internal/app/workflow_private_integration_test.go b/internal/app/workflow_private_integration_test.go new file mode 100644 index 000000000..f64d3daf3 --- /dev/null +++ b/internal/app/workflow_private_integration_test.go @@ -0,0 +1,200 @@ +//go:build integration + +package app + +import ( + "context" + "io/fs" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" +) + +// This file is the private-visibility acceptance slice of the workflow +// lifecycle shard (prefix TestIntegrationWorkflowLifecycle). It drives a +// private repository from `init --private` through change creation, grooming, +// and the whole claim-to-implemented run, then proves the repository is left +// with nothing docket-named in it: the metadata branch lives on the bare +// `dckt` store, origin gains only the feature branch, and a walk of the whole +// clone — .git included — finds no path component spelled `docket`. + +// privateFixtureBranch is the private metadata branch as the spec spells it. +// It is a literal, not layout.PrivateName, so a resolver that drifts from the +// spec's spelling cannot carry the oracle with it. +const privateFixtureBranch = "dckt" + +// assertNoDocketPathUnder walks root — its .git directory included — and fails +// listing every path whose component contains "docket" in any case. A +// docket-named directory is listed once and not descended into. The walk proves +// it is not vacuous: it must have seen the private state folder under .git. +func assertNoDocketPathUnder(t *testing.T, root string) { + t.Helper() + var hits []string + sawState := false + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, rerr := filepath.Rel(root, p) + if rerr != nil { + return rerr + } + if rel == "." { + return nil + } + if rel == filepath.Join(".git", layout.PrivateName) { + sawState = true + } + if strings.Contains(strings.ToLower(d.Name()), "docket") { + hits = append(hits, rel) + if d.IsDir() { + return filepath.SkipDir + } + } + return nil + }) + if err != nil { + t.Fatalf("walking %s: %v", root, err) + } + if !sawState { + t.Errorf("the walk of %s never reached .git/%s; it must cover .git", root, layout.PrivateName) + } + if len(hits) != 0 { + sort.Strings(hits) + t.Errorf("found %d docket-named path(s) under %s; a private repository holds none:\n %s", + len(hits), root, strings.Join(hits, "\n ")) + } +} + +// localHeads returns the sorted refs/heads/* names of the repository at dir. +func localHeads(t *testing.T, dir string) []string { + t.Helper() + out := runGit(t, dir, "for-each-ref", "--format=%(refname)", "refs/heads") + heads := strings.Fields(out) + sort.Strings(heads) + return heads +} + +// resolvedPrivateLayout resolves the clone's layout through the production +// resolver with a real client, and requires it to be private. +func resolvedPrivateLayout(t *testing.T, dir string) layout.Layout { + t.Helper() + client := newGitClient(t) + repo, err := client.Discover(context.Background(), gitcli.DiscoverOptions{InvocationPath: dir}) + if err != nil { + t.Fatalf("Discover %s: %v", dir, err) + } + lay, err := resolveLayout(context.Background(), client, repo) + if err != nil { + t.Fatalf("resolveLayout %s: %v", dir, err) + } + if lay.Mode != layout.Private { + t.Fatalf("layout mode = %q after init --private, want private", lay.Mode) + } + return lay +} + +// TestIntegrationWorkflowLifecyclePrivateInitToImplemented is acceptance 1: a +// private repository goes from init through implemented, and afterwards the +// clone holds nothing docket-named and origin holds nothing but the feature +// branch. +func TestIntegrationWorkflowLifecyclePrivateInitToImplemented(t *testing.T) { + t.Setenv("DOCKET_SCRIPTS_DIR", "") + ghBin := buildFakeGH(t) + r, data := newPrivateInitRepo(t, nil) + ctx := context.Background() + + // The walk below is relative to the clone, so only names the fixture puts + // INSIDE the clone could make it fire on the fixture's account rather than + // the product's. (testsupport.TempDir's "docketfix-" prefix sits above the + // walk root, and the store under the data home is outside the clone.) + req := validChangeCreateRequest() + for _, name := range []string{filepath.Base(r.invocation), layout.CloneID(r.invocation), req.Title} { + if strings.Contains(strings.ToLower(name), "docket") { + t.Fatalf("fixture name %q contains docket; the walk would test the fixture, not the product", name) + } + } + + // Init and config. + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("init --private = %q (%s), want applied", res.Result, res.HumanText()) + } + gate := passingGateScript(t) + if ct := r.runConfigureTestsWith(t, ConfigureTestsOptions{Command: &gate}); ct.Result != ResultApplied { + t.Fatalf("configure-tests = %q (%s), want applied", ct.Result, ct.HumanText()) + } + originBefore := localHeads(t, r.origin) + + lay := resolvedPrivateLayout(t, r.invocation) + if want := privateLayoutOf(t, r.invocation, data).DefaultBareRemote; lay.DefaultBareRemote != want { + t.Fatalf("resolved store %q, want %q", lay.DefaultBareRemote, want) + } + if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != lay.DefaultBareRemote { + t.Fatalf("dckt remote URL = %q, want the store %q", url, lay.DefaultBareRemote) + } + repo := &gitRepo{root: r.root, origin: r.origin, meta: lay.DefaultBareRemote, writer: r.writer, invocation: r.invocation} + + // Create and groom to build-ready with a spec. + node := planningDepsFor(t, r.invocation) + created := ChangeCreate(ctx, node.deps, node.dir, req) + if created.Result != ResultApplied { + t.Fatalf("change create = %q (%s, findings %v)", created.Result, created.HumanText(), created.Findings) + } + for _, name := range []string{created.Slug, created.Path} { + if strings.Contains(strings.ToLower(name), "docket") { + t.Fatalf("fixture name %q contains docket", name) + } + } + groom := ChangeGroom(ctx, node.deps, node.dir, ChangeGroomRequest{ + ChangeID: created.ID, + Path: created.Path, + Revision: blobRevisionAt(t, repo.meta, privateFixtureBranch, created.Path), + Outcome: GroomSpec, + SpecMarkdown: "# Widget: design\n\nBuild the widget.\n", + }) + if groom.Result != ResultApplied || groom.SpecPath == "" { + t.Fatalf("change groom = %q (%s, findings %v), want applied with a spec", groom.Result, groom.HumanText(), groom.Findings) + } + + // Drive the whole run against the private store. + driveClaimToImplemented(t, repo, privateFixtureBranch, ghBin, created.ID, created.Slug, created.Path, groom.SpecPath) + + // Origin gained exactly one branch, and it is the feature branch. + originAfter := localHeads(t, r.origin) + var gained []string + for _, h := range originAfter { + if !contains(originBefore, h) { + gained = append(gained, h) + } + } + if len(gained) != 1 || !strings.HasPrefix(gained[0], "refs/heads/feat/") || len(originAfter) != len(originBefore)+1 { + t.Errorf("origin heads %v -> %v; want exactly one new refs/heads/feat/... and nothing else", originBefore, originAfter) + } + + // The working tree is clean; the only ignored entry is the feature worktree + // folder. + for _, line := range strings.Split(runGit(t, r.invocation, "status", "--porcelain", "--ignored"), "\n") { + if line != "!! .worktrees/" { + t.Errorf("git status --porcelain --ignored line %q; want only %q", line, "!! .worktrees/") + } + } + + branches := strings.Fields(runGit(t, r.invocation, "branch", "--format=%(refname:short)")) + if !contains(branches, privateFixtureBranch) || contains(branches, "docket") { + t.Errorf("local branches = %v; want %s and no docket", branches, privateFixtureBranch) + } + if remotes := strings.Fields(runGit(t, r.invocation, "remote")); !contains(remotes, privateFixtureBranch) { + t.Errorf("remotes = %v; want a %s remote", remotes, privateFixtureBranch) + } + + assertNoDocketPathUnder(t, r.invocation) + + if got := os.Getenv("DOCKET_SCRIPTS_DIR"); got != "" { + t.Errorf("the workflow relied on a legacy Bash facade: DOCKET_SCRIPTS_DIR = %q", got) + } +} diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 9f8ab32a4..3f8b63183 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -23,7 +23,7 @@ tests/test_go_integration_app_changeruntime.sh 40 parallel tests/test_go_integration_app_cleanup.sh 40 parallel tests/test_go_integration_app_closeout.sh 40 parallel tests/test_go_integration_app_archive.sh 40 parallel -tests/test_go_integration_app_concurrency.sh 35 parallel +tests/test_go_integration_app_concurrency.sh 45 parallel tests/test_go_integration_app_merge.sh 15 parallel tests/test_go_integration_app_rebase.sh 45 parallel tests/test_go_integration_app_rebasepublished.sh 45 parallel @@ -41,12 +41,12 @@ tests/test_go_integration_app_repoownership.sh 30 parallel tests/test_go_integration_app_repoprepare.sh 20 parallel tests/test_go_integration_app_reporecovery.sh 20 parallel tests/test_go_integration_app_reporepair.sh 30 parallel -tests/test_go_integration_app_reposetup.sh 15 parallel +tests/test_go_integration_app_reposetup.sh 35 parallel tests/test_go_integration_app_reposetup_race.sh 25 parallel tests/test_go_integration_app_reposynccheck.sh 30 parallel tests/test_go_integration_app_state.sh 50 parallel tests/test_go_integration_app_workflow.sh 35 parallel -tests/test_go_integration_app_workflowlifecycle.sh 35 parallel +tests/test_go_integration_app_workflowlifecycle.sh 55 parallel tests/test_go_integration_app_named.sh 20 parallel tests/test_go_integration_app_sweep.sh 55 parallel tests/test_go_integration_app_sync.sh 55 parallel From 78c923a412ee5f8697f04854cb4e04ea7266734b Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:09:31 -0400 Subject: [PATCH 13/20] docs(skills): key metadata worktree and remote on the prepare context instead of .docket/ and origin/docket Adds TestSkillsSpellNoMetadataLiterals, ratchets the touched skill budgets, repoints the auto-groom draft pin, and notes the private config layer in the .docket.example.yml layer header. --- .docket.example.yml | 3 + internal/assets/embedded/manifest.json | 54 ++++---- .../assets/embedded/tree/.docket.example.yml | 3 + .../embedded/tree/skills/docket-adr/SKILL.md | 4 +- .../tree/skills/docket-auto-groom/SKILL.md | 10 +- .../tree/skills/docket-build-task/SKILL.md | 4 +- .../tree/skills/docket-build/SKILL.md | 3 +- .../tree/skills/docket-convention/SKILL.md | 16 +-- .../docket-convention/references/close-out.md | 9 +- .../tree/skills/docket-groom-next/SKILL.md | 14 +-- .../skills/docket-implement-next/SKILL.md | 20 +-- .../references/edge-paths.md | 7 +- .../tree/skills/docket-new-change/SKILL.md | 10 +- .../tree/skills/docket-review/SKILL.md | 2 +- .../tree/skills/docket-status/SKILL.md | 2 +- internal/repoguard/budgets_test.go | 24 ++-- internal/repoguard/layout_guard_test.go | 116 ++++++++++++++++++ internal/repoguard/prose_contracts_test.go | 4 +- skills/docket-adr/SKILL.md | 4 +- skills/docket-auto-groom/SKILL.md | 10 +- skills/docket-build-task/SKILL.md | 4 +- skills/docket-build/SKILL.md | 3 +- skills/docket-convention/SKILL.md | 16 +-- .../docket-convention/references/close-out.md | 9 +- skills/docket-groom-next/SKILL.md | 14 +-- skills/docket-implement-next/SKILL.md | 20 +-- .../references/edge-paths.md | 7 +- skills/docket-new-change/SKILL.md | 10 +- skills/docket-review/SKILL.md | 2 +- skills/docket-status/SKILL.md | 2 +- 30 files changed, 269 insertions(+), 137 deletions(-) diff --git a/.docket.example.yml b/.docket.example.yml index 2f599014d..92782caa0 100644 --- a/.docket.example.yml +++ b/.docket.example.yml @@ -9,6 +9,9 @@ # 2. committed /.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. diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 37e4a4f1f..1cdc9b5b5 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,14 +1,14 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:fb1378bb638922eb8ed790b8b8c4fb0d83bc785045a6d4fbdb687a477ed6af52", + "asset_set_id": "sha256:3bb07c3e1dbbe67904238c314adc4e848fb5feff21fd942a7fe167faa3a1056c", "entries": [ { "path": ".docket.example.yml", "role": "config-schema", "mode": 420, - "size": 19297, - "sha256": "a394e2b24fb102dff61052da362067efa553e0bc9bce039f9b7ecdf2552ea951" + "size": 19549, + "sha256": "e80e598968cd632f39095c9e580442c203c919319936cae434a4bda2503c2d23" }, { "path": "agents/docket-adr.md", @@ -273,15 +273,15 @@ "path": "skills/docket-adr/SKILL.md", "role": "skill", "mode": 420, - "size": 7612, - "sha256": "32b59558f2705c12102884df0bfab4e70caed18bd24c6df3590524baa4c598d7" + "size": 7758, + "sha256": "c6da9306b5cf1ce3299886ebe8454d2b3492cbbe149252803317775abd8639b7" }, { "path": "skills/docket-auto-groom/SKILL.md", "role": "skill", "mode": 420, - "size": 10994, - "sha256": "108a5e85f6b67762ebb67782c1e1b00f7dc04d7158aeb8c3e8111007085156c7" + "size": 11146, + "sha256": "b643f3686d6563bc26c5fad11c24a901c9febb39f436cce606056acb1e3650e5" }, { "path": "skills/docket-brainstorm/SKILL.md", @@ -294,15 +294,15 @@ "path": "skills/docket-build-task/SKILL.md", "role": "skill", "mode": 420, - "size": 10200, - "sha256": "7ed99c390d77f404aa4814238b9e85dd9ee4dc19bbacc13dff5a281b6199dd73" + "size": 10238, + "sha256": "d6e8c6ae9c6e95feace285ea02b9d60d4b29dac8ce08c40367e7561f9d859f43" }, { "path": "skills/docket-build/SKILL.md", "role": "skill", "mode": 420, - "size": 25642, - "sha256": "34e10719f0e1cdef7d3dc1905401d3e49ae3ad7b8acb8483c2681c5d219a1c57" + "size": 25724, + "sha256": "c21a98708e798f79eafc4d130a874004abf5c7395e68e4bb5fff28614caa8170" }, { "path": "skills/docket-build/references/gate-driver.md", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 51338, - "sha256": "47ffa6f66686b4bb0df5ca96455e6656d05cf6e24b0ec9679137970cf431e163" + "size": 52114, + "sha256": "c2f68748785f18d39c17a124ba1ad86b2f1eceb9483448c5d78b665c53e0e777" }, { "path": "skills/docket-convention/references/agent-layer.md", @@ -336,8 +336,8 @@ "path": "skills/docket-convention/references/close-out.md", "role": "skill", "mode": 420, - "size": 9515, - "sha256": "0273fcd39f934d974fa20f23a07893745f1fe1e27178ad33c3c9c71e4eb239e4" + "size": 9591, + "sha256": "cea60cb641edc0df8e2b650adabfe8c986603be876201456884f6e8268bbaf45" }, { "path": "skills/docket-convention/references/learnings.md", @@ -371,22 +371,22 @@ "path": "skills/docket-groom-next/SKILL.md", "role": "skill", "mode": 420, - "size": 14322, - "sha256": "0719cf2ca17e3cf41f9367a14858c9d886c9300671cac7957540e7042c147d39" + "size": 14508, + "sha256": "957197bd9fbf96918f3776d4465bb34524335ea9f27bfdda811c5be677f994f5" }, { "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56669, - "sha256": "a378c981ecc237c2c81ab55c4a2602ed28a6735dbac98caaac9067ceac1d5199" + "size": 56932, + "sha256": "839e04ee35d24e43fef82b34ad24de6a2857c4abd080c7002b19fe02f78f96dc" }, { "path": "skills/docket-implement-next/references/edge-paths.md", "role": "skill", "mode": 420, - "size": 8269, - "sha256": "8788cab626aae2baa10e938abcb7e07ed88965a4caedb298622cd47bd3ec6af7" + "size": 8374, + "sha256": "af43f15755edc0ceac519149b56270f9f1d4134d71e5ae4dadd21df86a657141" }, { "path": "skills/docket-implement-next/references/fix-pass.md", @@ -406,22 +406,22 @@ "path": "skills/docket-new-change/SKILL.md", "role": "skill", "mode": 420, - "size": 11077, - "sha256": "a1569fa478d4e73eca814f313fa7234731168d7a95ee7addf351ccd373a8b507" + "size": 11229, + "sha256": "6cc4221bf184ef0e2d8c57305c92b17eb8ab08fff0ae7e48b6eb6549e8b6eda6" }, { "path": "skills/docket-review/SKILL.md", "role": "skill", "mode": 420, - "size": 5897, - "sha256": "2248fd54e04c9b23d8931f8b7860f64d63e52f52bc29d9caba9921f0e15a1326" + "size": 5904, + "sha256": "0c94410faa10c499565fcf4cd5365f05757d758bc9e9b097aaa449a7f3f3bbec" }, { "path": "skills/docket-status/SKILL.md", "role": "skill", "mode": 420, - "size": 19818, - "sha256": "ba45dd2538b3805c20fa75181fb2e03441f1babb646d5875be7b9aed071729a5" + "size": 19822, + "sha256": "e08d782fbd265aee6c55c3a6300f28e63e4cdd3c7b02c05cfb46e4d13dcae824" } ] } diff --git a/internal/assets/embedded/tree/.docket.example.yml b/internal/assets/embedded/tree/.docket.example.yml index 2f599014d..92782caa0 100644 --- a/internal/assets/embedded/tree/.docket.example.yml +++ b/internal/assets/embedded/tree/.docket.example.yml @@ -9,6 +9,9 @@ # 2. committed /.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. diff --git a/internal/assets/embedded/tree/skills/docket-adr/SKILL.md b/internal/assets/embedded/tree/skills/docket-adr/SKILL.md index 488ff63ad..895bff8f9 100644 --- a/internal/assets/embedded/tree/skills/docket-adr/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-adr/SKILL.md @@ -19,7 +19,7 @@ agent: docket-adr ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context forward as literals; act on the verdict). Everything below uses its vocabulary without redefinition. All ADR reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context forward as literals; act on the verdict). Everything below uses its vocabulary without redefinition. All ADR reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. ## Actions @@ -63,7 +63,7 @@ One transaction lands atomically: the new ADR (carrying its `supersedes:`/`rever ### Update note -For a non-reversing material change in context — where the decision still stands but important surrounding information has changed — append a dated `## Update` section to the ADR body. The `## Decision` section itself is never edited. Commit the updated ADR file in `.docket/` and push `origin/docket`; regenerate the index only if the update changes how the entry reads in the index. +For a non-reversing material change in context — where the decision still stands but important surrounding information has changed — append a dated `## Update` section to the ADR body. The `## Decision` section itself is never edited. Commit the updated ADR file in the metadata worktree and run `git -C push HEAD`; regenerate the index only if the update changes how the entry reads in the index. ### Index / validate diff --git a/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md b/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md index 41fd94a90..a334ef84a 100644 --- a/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md @@ -20,7 +20,7 @@ agent: docket-auto-groom ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary (needs-grooming, auto-groomable, the abstain rule, …) without redefinition. All reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary (needs-grooming, auto-groomable, the abstain rule, …) without redefinition. All reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. ## Procedure — the drain @@ -32,7 +32,7 @@ Sync the metadata working tree (the startup-check `repository.prepare` operation ### Step 2 — Designer pass -Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `/learnings/README.md` and pull any findings whose hook bears on the stub, so the self-brainstorm is informed by past lessons (skipped entirely when `learnings.enabled` is `false`). Enumerate the decision points an interactive brainstorm would raise. For each, weigh 2–3 approaches and COMMIT to the conservative / recommended default — do NOT invoke `superpowers:brainstorming` with a simulated human answerer (a subagent picking "the recommended option" is the model agreeing with itself while faking an approval gate; rejected at design time). Draft the spec in session scratch space — never inside `.docket/` — as the Markdown body that Step 4 sends as `spec_markdown` in the `change.groom` request file, with an `## Assumptions` block: every decision, the chosen default, the rejected alternatives, and why — the human's deferred audit trail. If the stub is genuinely mechanical (no real design questions), the draft verdict is *trivial* instead of a spec, with the reasoning written for the critic. +Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `/learnings/README.md` and pull any findings whose hook bears on the stub, so the self-brainstorm is informed by past lessons (skipped entirely when `learnings.enabled` is `false`). Enumerate the decision points an interactive brainstorm would raise. For each, weigh 2–3 approaches and COMMIT to the conservative / recommended default — do NOT invoke `superpowers:brainstorming` with a simulated human answerer (a subagent picking "the recommended option" is the model agreeing with itself while faking an approval gate; rejected at design time). Draft the spec in session scratch space — never inside the metadata worktree — as the Markdown body that Step 4 sends as `spec_markdown` in the `change.groom` request file, with an `## Assumptions` block: every decision, the chosen default, the rejected alternatives, and why — the human's deferred audit trail. If the stub is genuinely mechanical (no real design questions), the draft verdict is *trivial* instead of a spec, with the reasoning written for the critic. ### Step 3 — Critic pass @@ -45,9 +45,9 @@ Dispatch the dedicated **`docket-auto-groom-critic`** subagent (foreground, at t ### Step 4 — Exit (one of three) -1. **Spec** — every assumption survived: apply one atomic transaction — the `change.groom` operation with `--repo-dir .docket --request ` — carrying the change id, the pinned `path` + `revision` from Step 1, `outcome: spec`, the drafted `spec_markdown` (the settled design plus its `## Assumptions` block), the owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed), and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on`. In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board; a typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing. Build-ready. -2. **Trivial** — the critic confirmed no hidden design decisions: apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body and its reasoning as the trivial rationale. The transaction sets `trivial: true` + `updated:` and re-renders the `## Artifacts` block and inline board atomically. Build-ready, no spec. -3. **Abstain** — any needs-human-context verdict, or Step 3's exhausted no-verdict posture: emit NO spec; apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: abstain`, the pinned `path` + `revision`, and a `blocked_note` — the undecidable decision(s), what context is missing, what a human should supply, and any recommendation (including "this should probably be killed/deferred because …"), with subsections at `###` or deeper. The transaction sets `auto_groomable: false` + `updated:`, appends a dated entry to the `## Auto-groom blocked` section, and re-renders the inline board — the row flips to **auto-groom blocked — needs you** in that same commit. It accepts no section, spec, or relationship edits. The stub stays needs-grooming, first in `docket-groom-next`'s queue. +1. **Spec** — every assumption survived: apply one atomic transaction — the `change.groom` operation with `--repo-dir --request ` — carrying the change id, the pinned `path` + `revision` from Step 1, `outcome: spec`, the drafted `spec_markdown` (the settled design plus its `## Assumptions` block), the owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed), and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on`. In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board; a typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing. Build-ready. +2. **Trivial** — the critic confirmed no hidden design decisions: apply the `change.groom` operation with `--repo-dir --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body and its reasoning as the trivial rationale. The transaction sets `trivial: true` + `updated:` and re-renders the `## Artifacts` block and inline board atomically. Build-ready, no spec. +3. **Abstain** — any needs-human-context verdict, or Step 3's exhausted no-verdict posture: emit NO spec; apply the `change.groom` operation with `--repo-dir --request ` with `outcome: abstain`, the pinned `path` + `revision`, and a `blocked_note` — the undecidable decision(s), what context is missing, what a human should supply, and any recommendation (including "this should probably be killed/deferred because …"), with subsections at `###` or deeper. The transaction sets `auto_groomable: false` + `updated:`, appends a dated entry to the `## Auto-groom blocked` section, and re-renders the inline board — the row flips to **auto-groom blocked — needs you** in that same commit. It accepts no section, spec, or relationship edits. The stub stays needs-grooming, first in `docket-groom-next`'s queue. **Kill and defer are NEVER autonomous.** Verdict authority over the backlog's composition stays human; the strongest the drain may say is an abstain-with-recommendation. diff --git a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md index 43baf0222..19b86040d 100644 --- a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md @@ -21,8 +21,8 @@ subagent, and never load a review skill. commit, never by amending: another agent's work may already be inside it, and you cannot observe that. - Stay **inside the feature worktree, on its branch**, performing **no docket metadata operations**: - never write to `.docket/`, the metadata branch, change files, ADRs, the board, or the - learnings ledger; never push, force-push, `reset --hard`, or rebase — `docket-implement-next` + never write to the metadata worktree (`metadata_worktree_path`), the metadata branch, change + files, ADRs, the board, or the learnings ledger; never push, force-push, `reset --hard`, or rebase — `docket-implement-next` owns that. - A plan's `- [ ]` checkboxes are **not** progress state — do not tick them. Your commit is the record of what you finished; nothing reads the marks. diff --git a/internal/assets/embedded/tree/skills/docket-build/SKILL.md b/internal/assets/embedded/tree/skills/docket-build/SKILL.md index db0af4c02..298772357 100644 --- a/internal/assets/embedded/tree/skills/docket-build/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build/SKILL.md @@ -20,7 +20,8 @@ gets exactly one fresh worker dispatch unless that worker requests its single al ## Inputs - The **plan** `docket-implement-next` Step 4 wrote, at the path recorded in the change's `plan:` - field, read from the metadata worktree at `.docket/`. + field, read from the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) at + `/`. - The **feature branch and worktree** already cut for this change, plus that repo's own instruction files (`AGENTS.md`, `CLAUDE.md`, nested equivalents). - The plan's `### Task N` headings, the **unit of dispatch** — one heading, one worker, one diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index 8b0192e59..f75d91d6a 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -74,12 +74,12 @@ Every operating skill starts identically; skill bodies compress to a pointer her - the `capabilities` verb is unknown to the binary → the installed binary predates the capability contract: **stop** and instruct the human to update or reinstall Docket. Never fall back to `--help`, guessed verbs, or probe invocations. - **refuse** (stop and surface the diagnostic) on any of: `protocol_version` ≠ 1 or an unsupported `capability_version`; a malformed envelope; duplicate `id`s; an effect outside the allowed values (`read` | `local-write` | `metadata-write` | `external-write` | `process-control`); an entry with a missing/empty `argv`; an absent or empty `commands` array. - a validated catalog **may be reused** by later skills in the **same** agent context; a **separately dispatched** agent fetches its own. No cache file, no repository metadata, no environment transport. -3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir --json` **as its own Bash call** — never compounded with other commands — then validate the protocol-v1 envelope and carry its typed context values forward as literals in later commands (no `eval`, no `source`). `repository.prepare` resolves config, enforces the bootstrap guard **fail-closed**, and ensures + syncs the metadata working tree (the persistent `.docket/` worktree, parked on `docket`, shared hooks disabled), returning a **closed typed context** (repo root, origin, the default/integration/metadata branches with pinned revisions, the `.docket` worktree path, the changes/ADR/results dirs, the `build` configuration (`gate`, `test_command`, `max_attempts`), and the finalize configuration — no `DOCKET_*` names, no shell quoting). Its **disposition** takes the allowed values `applied` | `no-op` | `refused` | `error`: an `applied`/`no-op` carries the context; a `refused`/`error` carries a structured finding whose remedy names the exact next command instead. +3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir --json` **as its own Bash call** — never compounded with other commands — then validate the protocol-v1 envelope and carry its typed context values forward as literals in later commands (no `eval`, no `source`). `repository.prepare` resolves config, enforces the bootstrap guard **fail-closed**, and ensures + syncs the metadata working tree (the persistent metadata worktree, parked on the metadata branch, shared hooks disabled), returning a **closed typed context** (repo root, origin, the default/integration/metadata branches with pinned revisions, the metadata worktree path `metadata_worktree_path`, the metadata remote `metadata_remote` and its remote-tracking ref `metadata_tracking_ref`, the changes/ADR/results dirs, the `build` configuration (`gate`, `test_command`, `max_attempts`), and the finalize configuration — no `DOCKET_*` names, no shell quoting). Its **disposition** takes the allowed values `applied` | `no-op` | `refused` | `error`: an `applied`/`no-op` carries the context; a `refused`/`error` carries a structured finding whose remedy names the exact next command instead. 4. Act on the disposition: `applied`/`no-op` → continue. A `refused` on a **legacy single-branch** layout → refuse and point the human at `docket repository migrate` (human-typed remedy, human-initiated, never an agent runtime invocation). A `refused` on a **fresh repository** (once, human-attended) → the human runs `docket repository init` (human-typed remedy), then re-run `repository.prepare`. Any other `refused`/`error` (dirty, ahead, diverged, foreign, ambiguous, or an unresolved probe) → surface the finding's remedy and stop; it is a human's to resolve. **Mid-run posture.** Construct every subsequent docket invocation from the fetched catalog entry for the semantic operation the skill names. An operation the workflow needs that is **absent** from the catalog → the workflow's existing hard-error posture; never guess a spelling. An invocation resolved from a validated catalog that returns **unknown-command mid-run** → the binary was replaced or is inconsistent: **stop**; do not silently refetch and switch interfaces mid-workflow. An operation whose cataloged `effects` **exceed the workflow's authorized boundary** → stop with a capability-mismatch diagnostic. A `--request`/`--input` body is built from that operation's `schema` descriptor (per *Reaching docket's operations*), never guessed, probed, or read from source. -All metadata reads and writes happen in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. Every mid-run metadata re-sync — pre-read syncs and **conflict-checked push retries alike** — is a fresh `repository.prepare` run (for a push retry: re-run `repository.prepare`, then retry the push); plain git plumbing (`git add`/`commit`/`push`, `git -C` forms) stays direct — and stages by explicit path: that tree is SHARED, so `git add -A`, `git add .`, or `commit -a` there sweeps up whatever another agent had staged at that instant, committing it under your message and pushing it. Stage by explicit path. +All metadata reads and writes happen in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. Every mid-run metadata re-sync — pre-read syncs and **conflict-checked push retries alike** — is a fresh `repository.prepare` run (for a push retry: re-run `repository.prepare`, then retry the push); plain git plumbing (`git add`/`commit`/`push`, `git -C` forms) stays direct — and stages by explicit path: that tree is SHARED, so `git add -A`, `git add .`, or `commit -a` there sweeps up whatever another agent had staged at that instant, committing it under your message and pushing it. Stage by explicit path. ### Harness-native recovery after sandbox or permission denial @@ -109,7 +109,7 @@ Each **autonomous** docket skill can run as a model/effort-pinned **subagent** i A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -160,7 +160,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: The `archive/` filename date prefix is **UTC**: the **merge commit's** date for `done`, the **kill commit's** date for `killed`. -All of the above lives on the `docket` branch, written through the persistent, gitignored **`.docket/` metadata worktree** (deliberately not under `.worktrees/` — slug collisions, prune blast radius; see *Branch model*). +All of the above lives on the metadata branch, written through the persistent **metadata worktree**: `/.docket/` in a shared repository (gitignored, deliberately not under `.worktrees/` — slug collisions, prune blast radius), or a checkout outside the clone in a private one; skills always use `metadata_worktree_path` (see *Branch model*). **Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `/--results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. @@ -336,7 +336,7 @@ The derived views over the change files — the `inline` board, each change's `# At startup, after resolving config, fetch origin and evaluate a 2×2 over two probes: -- **`DOCKET`** = the `docket` branch exists (origin OR local). +- **`DOCKET`** = the metadata branch exists (on the metadata remote OR locally). - **`LIVE`** = the **live planning surface** still sits on the integration branch — probe ONLY the pruned surface (`/active`, `README.md`, `BOARD.md`) via `git ls-tree origin/`; `archive/`, `/`, and pre-migration specs deliberately *stay* on integration, so probing them would read `LIVE` forever. An unresolvable `origin/` is a **hard config error**, not `¬LIVE`. | | `LIVE` | `¬LIVE` | @@ -348,10 +348,12 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr ### Branch model -Metadata (change files, `BOARD.md`, ADRs, specs) commits to the `docket` branch via the persistent **`.docket/` worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to its remote immediately** (the planning surface stays browsable on the remote). +Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. + +A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. A change's feature branch is minted at claim as `/`, or `/` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. -The `.docket` metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. +The metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md index 27ef0b513..81cb2c9d7 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md @@ -13,8 +13,9 @@ Contents: [The sequence](#the-sequence) · [Failure posture](#failure-posture--p ## The sequence -All metadata writes happen in the metadata working tree (`.docket/`), synced to `origin/docket` -before the first read; every commit pushes immediately. +All metadata writes happen in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context), +synced to the metadata remote (`metadata_remote`) before the first read; every commit pushes +immediately. 1. **Archive on `docket` first.** The two final outcomes split here: `done` runs the Go `finalize.closeout` transaction; `killed` runs the Go `change.kill` transaction (`finalize.closeout` @@ -52,7 +53,7 @@ before the first read; every commit pushes immediately. ``` # request-file: { "change_id": , "path": "/active/--.md", # "revision": "", "why_killed": "" } - change.kill --repo-dir .docket --input --json # resolve argv from the capability catalog + change.kill --repo-dir --input --json # resolve argv from the capability catalog ``` Trust the typed outcome: `applied` ⇒ archived — an idempotent no-op if already archived, @@ -92,7 +93,7 @@ before the first read; every commit pushes immediately. workspaces and feature refs proven owned by *this* closed change are removed — the local ref only when its recorded tip is detached from every worktree AND contained in the verified merge chain, the remote ref only under an exact old-value lease with no open child PR still targeting - it — never the `.docket/` metadata worktree, the primary tree, or any out-of-tree path. Any + it — never the metadata worktree, the primary tree, or any out-of-tree path. Any resource whose ownership it cannot prove is **retained**, not force-removed. A failure aborts per the caller's posture. diff --git a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md index d1fd36413..e0693b513 100644 --- a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md @@ -21,7 +21,7 @@ This skill grooms interactively with a human, so it cannot be a fire-and-forget ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately so the backlog stays reviewable on GitHub and visible to the autonomous implementer; grooming never touches `origin/` — markdown only, no branches or code. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately so the backlog stays reviewable and visible to the autonomous implementer; grooming never touches `origin/` — markdown only, no branches or code. ## Procedure @@ -58,18 +58,18 @@ Then invoke `superpowers:brainstorming` WITH THE HUMAN, seeded with the stub's b All six exits reuse existing transitions — this skill introduces no new lifecycle status: -1. **Spec** (the normal exit): **author** the settled design as the spec's Markdown body, and any owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed). Apply it with one atomic transaction — the `change.groom` operation with `--repo-dir .docket --request ` — carrying the change id, the pinned record `path` + `revision` from Step 1, `outcome: spec`, the `spec_markdown`, the owned-section edits, and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on` (authored Markdown travels in the request file, never a shell-escaped flag). In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board — so there is no separate render, back-link, or Board pass. A typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing — surface it. The change is now build-ready. -2. **Trivial verdict**: the brainstorm concludes there is no real design question — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body as the trivial rationale. The transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically — no spec file, no separate Board pass. Also build-ready. +1. **Spec** (the normal exit): **author** the settled design as the spec's Markdown body, and any owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed). Apply it with one atomic transaction — the `change.groom` operation with `--repo-dir --request ` — carrying the change id, the pinned record `path` + `revision` from Step 1, `outcome: spec`, the `spec_markdown`, the owned-section edits, and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on` (authored Markdown travels in the request file, never a shell-escaped flag). In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board — so there is no separate render, back-link, or Board pass. A typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing — surface it. The change is now build-ready. +2. **Trivial verdict**: the brainstorm concludes there is no real design question — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body as the trivial rationale. The transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically — no spec file, no separate Board pass. Also build-ready. 3. **Kill**: the stub is obsolete, a duplicate, or decided against — follow the proposed-kill sub-path in `docket-new-change` (it owns the kill mechanics; do not restate them here). -4. **Defer**: right idea, wrong time — apply the `change.defer` operation with `--repo-dir .docket --request ` with the pinned `path` + `revision` and the authored `why_deferred` section body. The transaction sets `status: deferred`, splices the `## Why deferred` section, sets `updated:`, and re-renders the inline board atomically. -5. **Revise** (explicit-id route only): the human wants a `proposed` change adjusted without changing its groom state — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `revision`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_revision`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:`, `trivial:`, or `auto_groomable:`. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked` — also the answer to `spec_markdown` on a needs-grooming stub, which has no spec — `spec-file-missing`, `empty-revise`, `empty-spec_revision`, `invalid-section-heading` for an edit naming `## Auto-groom blocked`, revision mismatch) writes nothing — surface it. The change keeps its groom state. -6. **Re-enable** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: re-enable`, the pinned `path` + `revision`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-re-enable` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-enable. +4. **Defer**: right idea, wrong time — apply the `change.defer` operation with `--repo-dir --request ` with the pinned `path` + `revision` and the authored `why_deferred` section body. The transaction sets `status: deferred`, splices the `## Why deferred` section, sets `updated:`, and re-renders the inline board atomically. +5. **Revise** (explicit-id route only): the human wants a `proposed` change adjusted without changing its groom state — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: revise`, the pinned `path` + `revision`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_revision`, the linked spec's blob object id read by `git -C rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:`, `trivial:`, or `auto_groomable:`. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked` — also the answer to `spec_markdown` on a needs-grooming stub, which has no spec — `spec-file-missing`, `empty-revise`, `empty-spec_revision`, `invalid-section-heading` for an edit naming `## Auto-groom blocked`, revision mismatch) writes nothing — surface it. The change keeps its groom state. +6. **Re-enable** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: re-enable`, the pinned `path` + `revision`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-re-enable` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-enable. **Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-enable) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `not-retitleable`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. ### Step 5 — The transaction lands (no separate board pass) -The Step-4 typed op is the whole write: it re-checks the pinned exact `revision`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to `origin/docket` under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-grooming, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row's readiness; a re-enable returns an abstained row to needs-grooming). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `revision` from the `status` operation, and — on a groom exit, if it is no longer needs-grooming (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_revision`) and stop if the change is no longer `proposed` or a stub edit finds it groomed (`spec:` or `trivial: true`). STOP — grooming never implements. +The Step-4 typed op is the whole write: it re-checks the pinned exact `revision`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to the metadata remote under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-grooming, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row's readiness; a re-enable returns an abstained row to needs-grooming). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `revision` from the `status` operation, and — on a groom exit, if it is no longer needs-grooming (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_revision`) and stop if the change is no longer `proposed` or a stub edit finds it groomed (`spec:` or `trivial: true`). STOP — grooming never implements. ## Concurrency — no claim diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md index 697f5bd83..f20360103 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -24,7 +24,7 @@ Before anything else in this skill, invoke the `docket-convention` skill via the ### Step 0 — Sync & sweep -Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the spec copy and the code land on the feature branch. +Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately; only the spec copy and the code land on the feature branch. **Named invocation — no maintenance preflight.** When this run was invoked with exactly **one explicit change id** — an initial named call, an attributed retry carrying the id, or a named resume of an in-progress change — skip the implementation preflight and Step 1's status-digest certification entirely: after the `repository.prepare` operation succeeds, go **directly** to the authoritative `context.implementation` operation with `--id ` and the existing claim/resume path. The named path never runs `maintenance.preflight` or any maintenance sweep first — another change's failing closeout, cleanup, or reclaim can never halt this change before its own claim/gate; anyone who wants those maintenance outcomes runs the explicit `maintenance.preflight` or `maintenance.sweep` operation deliberately, where every problem stays visible. Every local check for the named change is kept — readiness, record revision, dependencies, effective base, claim eligibility, run context, and resume/cancellation — so a malformed, absent, not-ready, or improperly resumed id returns its existing typed refusal, reported with its reason exactly as a scoped-id skip is (ending `drained` when nothing else is in scope); a named request **never falls back to selecting another change**, and shared repository/configuration/transport failures keep the `halted` posture. @@ -46,7 +46,7 @@ The digest is an **accelerator, not the sole channel**: the authoritative read i ### Step 2 — Claim (conflict-checked write) -Claim by transaction: the `change.claim` operation with `--id --revision ` (the exact revision from the step-1 bundle). When the dispatch prompt carried a **run context** token, pass it to the claim as --run-context : the claim validates it against the started run, records durable proof binding this dispatch to its successful claim, and the parent's keyed verdict resolves ownership from that proof; an invalid or conflicting run context is a typed refusal that writes nothing — never retried as an ungated claim. A claim without the token stays supported for ungated invocations. In the **metadata working tree** the operation reloads fresh origin, re-proves the change is still `proposed`, uniquely identified, build-ready, and has a resolved effective base, then applies the landed claim (`status: in-progress` + `branch: /` + `updated:` + `claimed_at:` — the lease the `change.reclaim` operation and the maintenance sweep's reclaim key on) and updates the change record, `## Artifacts` block, and inline board **together in one metadata commit** on the `docket` branch (`origin/docket`). It returns the new record revision, feature branch, lease classification, and a closed disposition; a wrong revision or a not-`proposed`/not-build-ready/duplicate/unresolved-base change returns `contended`/a typed refusal that **writes nothing and creates no workspace**. A lost-response retry converges by re-reading authority — an already-held matching claim returns the applied receipt or a typed `already-claimed`, a foreign claim returns `contended`; never adopt a branch or workspace as proof the claim landed. A `contended` result is the **`contended`** disposition (see *Terminal disposition*): a lost conflict-checked claim race is a normal, continue-able outcome a driver re-selects past, **never `halted`** — re-sync (re-run the `repository.prepare` operation) and re-select. No worktree yet. +Claim by transaction: the `change.claim` operation with `--id --revision ` (the exact revision from the step-1 bundle). When the dispatch prompt carried a **run context** token, pass it to the claim as --run-context : the claim validates it against the started run, records durable proof binding this dispatch to its successful claim, and the parent's keyed verdict resolves ownership from that proof; an invalid or conflicting run context is a typed refusal that writes nothing — never retried as an ungated claim. A claim without the token stays supported for ungated invocations. In the **metadata working tree** the operation reloads fresh origin, re-proves the change is still `proposed`, uniquely identified, build-ready, and has a resolved effective base, then applies the landed claim (`status: in-progress` + `branch: /` + `updated:` + `claimed_at:` — the lease the `change.reclaim` operation and the maintenance sweep's reclaim key on) and updates the change record, `## Artifacts` block, and inline board **together in one metadata commit** on the metadata branch, pushed to the metadata remote. It returns the new record revision, feature branch, lease classification, and a closed disposition; a wrong revision or a not-`proposed`/not-build-ready/duplicate/unresolved-base change returns `contended`/a typed refusal that **writes nothing and creates no workspace**. A lost-response retry converges by re-reading authority — an already-held matching claim returns the applied receipt or a typed `already-claimed`, a foreign claim returns `contended`; never adopt a branch or workspace as proof the claim landed. A `contended` result is the **`contended`** disposition (see *Terminal disposition*): a lost conflict-checked claim race is a normal, continue-able outcome a driver re-selects past, **never `halted`** — re-sync (re-run the `repository.prepare` operation) and re-select. No worktree yet. A claim of a `proposed` record that still carries a `## Run halted` section removes that section in the same transaction. **Resuming a halted in-progress change** — one the caller re-dispatches by id after a `run.verify` verdict of **`run-halted`** — goes through the `change.resume-halted` operation with `--id --revision --acknowledge-quiescent`, never a fresh claim: it requires the exact marked record and the explicit `--acknowledge-quiescent` acknowledgement that the prior worker is quiescent, reprobes the branch/workspace/live gate, refreshes the claim, and removes exactly the `## Run halted` marker section while preserving every other byte and checkpoint — it never resets or adopts a workspace whose writer may be live. It refuses (writing nothing) without the acknowledgement, on revision drift (`contended`), or on a live gate lock. A change that instead reached `proposed` through the `change.reclaim` operation carries no live claim, so it re-enters through the ordinary claim above. The claim and resume both render the inline board in their own transaction, so no separate Board pass runs here. Starting the resume also passes through the run tracker, which refuses a second run over one that has not verifiably stopped — **read `references/edge-paths.md` now (blocking)** for those run-tracker resume refusals and their remedies. @@ -54,7 +54,7 @@ A claim of a `proposed` record that still carries a `## Run halted` section remo ### Step 3 — Reconcile ⭐ -Re-read the step-1 bundle's change + spec bytes against `related` + recently-archived changes, cited + recent ADRs, and CURRENT code, and decide NON-INTERACTIVELY what is true NOW (drop work done elsewhere, adjust scope, fold in new constraints). That judgment is yours; the write is a transaction. **Author** a reconcile request — structured replacement text for the owned proposal sections and still-mutable linked-spec content, the complete desired relations (`depends_on`/`stacked_on`/`related`/`adrs`/`discovered_from`) when reality requires them, and one dated `## Reconcile log` entry — and apply it with the `change.reconcile` operation with `--input ` (the request file carries `id`, the pinned `revision`, and the authored Markdown — never a shell-escaped flag). The transaction checks the exact revision and legal `in-progress` status, sets `reconciled: true`, refreshes the claim, rerenders backlinks/`## Artifacts` block/inline board, validates the whole repository, and commits atomically on the `docket` branch (`origin/docket`); unknown frontmatter, unlisted authored sections, line endings, and unrelated files stay byte-identical, and a request naming a non-owned field/section is a typed refusal that writes nothing. A trivial change has no spec — patch the body only. If fresh origin state diverges incompatibly, the operation returns `contended` and writes nothing — never a text-merge; re-read fresh context and re-author. +Re-read the step-1 bundle's change + spec bytes against `related` + recently-archived changes, cited + recent ADRs, and CURRENT code, and decide NON-INTERACTIVELY what is true NOW (drop work done elsewhere, adjust scope, fold in new constraints). That judgment is yours; the write is a transaction. **Author** a reconcile request — structured replacement text for the owned proposal sections and still-mutable linked-spec content, the complete desired relations (`depends_on`/`stacked_on`/`related`/`adrs`/`discovered_from`) when reality requires them, and one dated `## Reconcile log` entry — and apply it with the `change.reconcile` operation with `--input ` (the request file carries `id`, the pinned `revision`, and the authored Markdown — never a shell-escaped flag). The transaction checks the exact revision and legal `in-progress` status, sets `reconciled: true`, refreshes the claim, rerenders backlinks/`## Artifacts` block/inline board, validates the whole repository, and commits atomically on the metadata branch, pushed to the metadata remote; unknown frontmatter, unlisted authored sections, line endings, and unrelated files stay byte-identical, and a request naming a non-owned field/section is a typed refusal that writes nothing. A trivial change has no spec — patch the body only. If fresh origin state diverges incompatibly, the operation returns `contended` and writes nothing — never a text-merge; re-read fresh context and re-author. Adjacent follow-up work this pass surfaces is **noted for the final report** — nothing is minted automatically; a human captures reported work deliberately with `docket change create`, guided by the backlog verdict final consolidation attaches (Step 6.5 *Backlog match*). @@ -65,7 +65,7 @@ Two escape hatches: ### Step 4 — Worktree + plan -CONFIRM the step-3 reconcile push has landed on the **metadata branch** before continuing — by **SHA-compare**, not "the push exited 0": after a re-sync, the local metadata tip must equal the remote tip (re-run the `repository.prepare` operation, assert `git -C .docket rev-parse @ == git rev-parse origin/docket`). If they differ (a concurrent writer rejected the push): re-sync, re-push — repeat until the SHAs match, so the build never reads bytes older than origin. Then prepare the owned feature workspace: +CONFIRM the step-3 reconcile push has landed on the **metadata branch** before continuing — by **SHA-compare**, not "the push exited 0": after a re-sync, the local metadata tip must equal the remote tip (re-run the `repository.prepare` operation, assert `git -C rev-parse HEAD` equals `git -C rev-parse `). If they differ (a concurrent writer rejected the push): re-sync, re-push — repeat until the SHAs match, so the build never reads bytes older than origin. Then prepare the owned feature workspace: ``` workspace.prepare --id --revision # resolve argv from the capability catalog @@ -75,7 +75,7 @@ The `workspace.prepare` operation reloads the authoritative snapshot, resolves t the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/` in the ordinary case). -**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id `: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id --revision ` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. +**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync the metadata worktree immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id `: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id --revision ` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. **Plan authoring (dispatched).** The parent stays the orchestrator. @@ -84,9 +84,9 @@ Feature worktree: It also supplies the change id, title, exact record revision, synchronized change-file and spec paths, pre-dispatch HEAD, and learnings enablement plus (when enabled) `/learnings/README.md`. The child invokes `superpowers:writing-plans` **DIRECTED to:** write the plan in a scratch file outside both worktrees and stop there (when the skill cannot be invoked it authors the plan itself, warning prominently — carry that warning into the run report and PR body), runs the `change.attach-plan` operation with `--id --revision --path docs/superpowers/plans/-.md --markdown ` — it writes the plan with its backlink on `docket` and sets `plan:` in one commit — and returns the single success line `PLAN_PATH=`. -**Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. +**Verification (parent).** The returned path is a **claim, not proof**. Re-sync the metadata worktree (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id `) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. +**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id `) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`/`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build @@ -103,7 +103,7 @@ For the `docket-review` role, **select the review tier** deterministically from The review role — `docket-review` — is invoked **DIRECTED to:** review the whole branch against its base and return its findings, then stop, answering any choice it poses from resolved config and never surfacing one — log one line naming the role and skill if you suppressed a hand-off. `docket-review` is a skill, never a same-name agent to dispatch, and the skill itself dispatches nothing: you — the controller — dispatch the selected tier wrapper — `docket-review-lean`, `docket-review-standard`, or `docket-review-deep` — foreground, and that wrapper preloads the `docket-review` skill. Its dispatch payload contains: Feature worktree: -It also contains the branch and base ref, change title and scope, relevant learnings hooks, the evidence record, and the resolved `build.gate` value (from the startup-check prepared context's `build` block). When the `docket-review` skill is missing, apply the convention's missing-skill rule: review the whole branch inline before the PR opens, warning prominently, and dispatch no reviewer. Re-read the learnings index `/learnings/README.md` first and pull the findings relevant to what this change touched (skipped entirely when `learnings.enabled` is `false`). For any non-obvious decision made during implementation, **dispatch the `docket-adr` subagent** (foreground, at the model/effort its wrapper resolves) — once per decision; it assigns the number, updates the index, commits the ADR on `origin/docket`, and **returns the number**. After re-syncing `.docket/`, record that number in the change's `adrs:` relation through a `change.reconcile` operation relations update — the transaction re-checks the exact revision and re-renders the `## Artifacts` block and inline board atomically, so the skill never hand-renders a derived view. Review findings that are distinct follow-up work — not this change's own fixes — are **reported as follow-up work in the final report**; nothing is minted automatically, and a human captures reported work deliberately with `docket change create`. On unavailable dispatch — established only per the convention's *Dispatch-capability resolution*, never from a tool name, and never from a rejected same-name role-agent attempt — an undispatchable review tier makes the review role **`halt`** on the same terms as step 5; the `docket-adr` dispatch falls back **`inline`**, with its git-state contract unchanged. +It also contains the branch and base ref, change title and scope, relevant learnings hooks, the evidence record, and the resolved `build.gate` value (from the startup-check prepared context's `build` block). When the `docket-review` skill is missing, apply the convention's missing-skill rule: review the whole branch inline before the PR opens, warning prominently, and dispatch no reviewer. Re-read the learnings index `/learnings/README.md` first and pull the findings relevant to what this change touched (skipped entirely when `learnings.enabled` is `false`). For any non-obvious decision made during implementation, **dispatch the `docket-adr` subagent** (foreground, at the model/effort its wrapper resolves) — once per decision; it assigns the number, updates the index, commits the ADR on the metadata remote, and **returns the number**. After re-syncing the metadata worktree, record that number in the change's `adrs:` relation through a `change.reconcile` operation relations update — the transaction re-checks the exact revision and re-renders the `## Artifacts` block and inline board atomically, so the skill never hand-renders a derived view. Review findings that are distinct follow-up work — not this change's own fixes — are **reported as follow-up work in the final report**; nothing is minted automatically, and a human captures reported work deliberately with `docket change create`. On unavailable dispatch — established only per the convention's *Dispatch-capability resolution*, never from a tool name, and never from a rejected same-name role-agent attempt — an undispatchable review tier makes the review role **`halt`** on the same terms as step 5; the `docket-adr` dispatch falls back **`inline`**, with its git-state contract unchanged. **Triage the returned findings, then FIX them in-branch.** Findings are repaired on the open branch before the PR opens — a bounded **fix pass**, not a stub for every one. `review.min_fix_severity` (read through the `diagnostic.config` operation; `minor` by default) is the lowest severity that enters it, and blockers are fixed regardless of it. Routing is by the fix's CHARACTER via the shared rubric, never by its severity, and never reaches the `max` tier; every fix runs the `docket-build-task` contract. Before dispatching the first fix task, **read `references/fix-pass.md` now (blocking)** — it owns the routing table, the non-blocker task cap, the per-finding and batched task shapes, the revert-and-record suite gate (bounded at two runs; still-red halts), and the PR-body disposition table. The reviewer's `unverified-build-state` blocker is the one exception you resolve yourself, by re-running the suite — or, under `build.gate: off`, by re-minting the skipped record. A finding that is genuinely distinct beyond-the-branch work is reported as follow-up work in the final report, carrying the backlog verdict final consolidation attaches; a finding about this branch's own diff never is. There is **no re-review** round after fixes — remediation is carried by the worker's own self-review, the suite re-run, and the human reading every fix in the PR diff. @@ -119,7 +119,7 @@ The results artifact is **required for every change, trivial included** — ther **Per-checkpoint mechanics** (in order): 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. -2. Read and **preserve** the prior results content — after a re-sync, from `.docket/` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). +2. Read and **preserve** the prior results content — after a re-sync, from `/` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). 3. Write the update into the scratch file. 4. Attach it: the `change.attach-results` operation with `--id --revision --path --markdown `, at a revision re-read after a re-sync (the `context.implementation` operation with `--id `) because the claim refresh, plan attach, and every earlier checkpoint move the record, writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. @@ -201,7 +201,7 @@ A change is drafted against a *snapshot* of the codebase, ADRs, and other in-fli ### The field-write rule -Every change-file field write in this run (claim's `status:`/`branch:`, reconcile, `plan:`, `adrs:`, `results:`, `pr:` + `status: implemented`) lands through a **`change.*` / attach / mark-implemented transaction** in the `.docket/` worktree on the `docket` branch — never a hand-edit, never in the feature worktree — which reloads fresh origin, checks the exact record revision, and commits and pushes atomically. The skill **never invokes a facade renderer and never hand-writes a derived view**: the transaction re-stamps `claimed_at: ` on every mutation and, for any **link-bearing field** (`spec:`/`plan:`/`adrs:`/`pr:`/`results:`), regenerates the `## Artifacts` block and inline board in the same commit; the skill also refreshes the claim explicitly (the `change.refresh-claim` operation) around the long plan and build dispatches, stamping the plan→build seam where a stopped run is otherwise invisible. The halt-path authored sections also land through transactions, never a hand-edit: the `change.halt` operation writes the `## Run halted` section (Step 3) and the `change.resume-halted` operation removes it on resume (Step 2), each an exact-revision transaction that renders the inline board in its own commit. The skill authors the halt report as a request file and hands it across; it never edits the section bytes itself. **Stage by explicit path** — that tree is shared, so a bare `add -A` commits another agent's staged work under your message. +Every change-file field write in this run (claim's `status:`/`branch:`, reconcile, `plan:`, `adrs:`, `results:`, `pr:` + `status: implemented`) lands through a **`change.*` / attach / mark-implemented transaction** in the metadata worktree on the metadata branch — never a hand-edit, never in the feature worktree — which reloads fresh origin, checks the exact record revision, and commits and pushes atomically. The skill **never invokes a facade renderer and never hand-writes a derived view**: the transaction re-stamps `claimed_at: ` on every mutation and, for any **link-bearing field** (`spec:`/`plan:`/`adrs:`/`pr:`/`results:`), regenerates the `## Artifacts` block and inline board in the same commit; the skill also refreshes the claim explicitly (the `change.refresh-claim` operation) around the long plan and build dispatches, stamping the plan→build seam where a stopped run is otherwise invisible. The halt-path authored sections also land through transactions, never a hand-edit: the `change.halt` operation writes the `## Run halted` section (Step 3) and the `change.resume-halted` operation removes it on resume (Step 2), each an exact-revision transaction that renders the inline board in its own commit. The skill authors the halt report as a request file and hands it across; it never edits the section bytes itself. **Stage by explicit path** — that tree is shared, so a bare `add -A` commits another agent's staged work under your message. ### Feature branch invariants diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md b/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md index 0f6719548..6f932067e 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md @@ -83,9 +83,10 @@ Before building continues, re-run `workspace.commit-spec`: after a mid-flight sp refreshes the copy; otherwise it is a no-op. **The results seam.** The results artifact is required for every change, so a resume -must not lose it. On resume, **load the attached results from `.docket/` (after a -re-sync) before starting new work**, and reuse the `results:` path whenever it is set — a changed -authoring date **never mints a second file**; the canonical path is chosen once and reused. +must not lose it. On resume, **load the attached results from the metadata worktree +(`metadata_worktree_path` in the `repository.prepare` context) at +`/`, after a re-sync, before starting new work**, and +reuse the `results:` path whenever it is set — a changed authoring date **never mints a second file**; the canonical path is chosen once and reused. **Never overwrite newer remote work** — a resume that finds the remote ahead re-reads authority rather than force-writing its local view. diff --git a/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md index 5675b36e1..085297768 100644 --- a/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md @@ -22,7 +22,7 @@ This skill brainstorms with a human, so it cannot be a fire-and-forget subagent ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All writes — the change, spec, and refreshed `BOARD.md` — land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately so the backlog is reviewable on GitHub and visible to the autonomous implementer. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All writes — the change, spec, and refreshed `BOARD.md` — land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately so the backlog is reviewable and visible to the autonomous implementer. ## Brainstorm mode (default) @@ -34,19 +34,19 @@ The default path for any non-trivial new change. Five steps: 3. **Scan related context** — scan neighbouring changes (`active/` + recent `archive/`) and the ADR index to pre-fill `related`, `depends_on`, `adrs`, and — when the human names the change(s) whose work surfaced this one (or scan mode infers an origin) — `discovered_from` (informational, like `related:`; empty for deliberately planned work). In practice, do this quick read just *before* step 2 so the brainstorm is informed by neighbouring work; record the resulting `related`/`depends_on`/`adrs`/`discovered_from` after the design settles. -4. **Create the proposed change** — submit the record through one atomic transaction: the `change.create` operation (resolve argv from the capability catalog) with `--repo-dir .docket --request `. Its closed JSON request carries `title`, `type` (one configured `change_type` — `create` refuses an unknown or empty type, so no created change is ever left `untyped`, and there is no template comment to replace), `priority` (default `medium`), the PM-altitude `why`/`what_changes`/`out_of_scope` body distilled from the brainstorm (design detail lives in the linked spec, NOT here), the resolved `depends_on`/`related`/`adrs`/`discovered_from` from step 3, `stacked_on` when the work builds on another change's **unmerged** branch (set it and **read [stacked-changes.md](../docket-convention/references/stacked-changes.md) now (blocking)** first — stacking changes how it is built, merged, and closed out), and the stable `request_id` from step 1. The transaction allocates the id, derives the slug, serializes the canonical `status: proposed` record (`created`/`updated` = the commit's UTC date), renders its `## Artifacts` block, and re-renders the inline board — one metadata commit under an exact-lease push, returning the new `id`/`slug`/`path`. +4. **Create the proposed change** — submit the record through one atomic transaction: the `change.create` operation (resolve argv from the capability catalog) with `--repo-dir --request `. Its closed JSON request carries `title`, `type` (one configured `change_type` — `create` refuses an unknown or empty type, so no created change is ever left `untyped`, and there is no template comment to replace), `priority` (default `medium`), the PM-altitude `why`/`what_changes`/`out_of_scope` body distilled from the brainstorm (design detail lives in the linked spec, NOT here), the resolved `depends_on`/`related`/`adrs`/`discovered_from` from step 3, `stacked_on` when the work builds on another change's **unmerged** branch (set it and **read [stacked-changes.md](../docket-convention/references/stacked-changes.md) now (blocking)** first — stacking changes how it is built, merged, and closed out), and the stable `request_id` from step 1. The transaction allocates the id, derives the slug, serializes the canonical `status: proposed` record (`created`/`updated` = the commit's UTC date), renders its `## Artifacts` block, and re-renders the inline board — one metadata commit under an exact-lease push, returning the new `id`/`slug`/`path`. **Two draft-time scalars ride in the same request** — `auto_groomable` and `branch_prefix`. When the human says the change may be designed without them, send `auto_groomable: true` (so `docket-auto-groom` carries it to build-ready; `false` opts it out). When they name a branch prefix ("use the `hotfix/` prefix"), send it as typed in `branch_prefix` — the operation normalizes it and refuses an unusable value with `invalid-branch_prefix` before anything is written: show the human that finding and ask for a new value. Omit `auto_groomable` to leave the change not auto-groomable; omit `branch_prefix` to use ``. -5. **Groom to build-ready & land** — read the created record's `path` + exact **record revision** (blob object id) from the `status` operation (with `--json`), then apply the spec atomically: the `change.groom` operation with `--repo-dir .docket --request ` carrying the change id, the pinned `path` + `revision`, `outcome: spec`, the `spec_markdown` from step 2, and any owned proposal-section rewrites. In one metadata commit the transaction writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, re-renders the `## Artifacts` block, and re-renders the inline board (pushed under an exact-lease push) — **no separate Board pass**. A revision-mismatch / `contended` refusal writes nothing: re-sync (re-run the `repository.prepare` operation), re-read `path` + `revision`, retry. STOP. Never implements. To adjust a just-landed spec or its owned sections afterwards, run `docket-groom-next ` — its explicit-id path routes an already-groomed change to `change.groom` with `outcome: revise`; never re-run this skill or hand-edit the spec file. +5. **Groom to build-ready & land** — read the created record's `path` + exact **record revision** (blob object id) from the `status` operation (with `--json`), then apply the spec atomically: the `change.groom` operation with `--repo-dir --request ` carrying the change id, the pinned `path` + `revision`, `outcome: spec`, the `spec_markdown` from step 2, and any owned proposal-section rewrites. In one metadata commit the transaction writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, re-renders the `## Artifacts` block, and re-renders the inline board (pushed under an exact-lease push) — **no separate Board pass**. A revision-mismatch / `contended` refusal writes nothing: re-sync (re-run the `repository.prepare` operation), re-read `path` + `revision`, retry. STOP. Never implements. To adjust a just-landed spec or its owned sections afterwards, run `docket-groom-next ` — its explicit-id path routes an already-groomed change to `change.groom` with `outcome: revise`; never re-run this skill or hand-edit the spec file. ## Trivial path -For a small mechanical change with no real design questions: skip the brainstorm — no spec, still build-ready. It follows Brainstorm mode's steps 1 (Allocate), 3 (Scan related context), and 4 (Create the proposed change) unchanged, then step 5 grooms with `outcome: trivial` instead of `spec`: the `change.groom` operation with `--repo-dir .docket --request ` with the pinned `path` + `revision` and an owned-section edit carrying the tightened body as the trivial rationale — the transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically (no spec file, no separate Board pass). Only step 2 (Brainstorm) is skipped. +For a small mechanical change with no real design questions: skip the brainstorm — no spec, still build-ready. It follows Brainstorm mode's steps 1 (Allocate), 3 (Scan related context), and 4 (Create the proposed change) unchanged, then step 5 grooms with `outcome: trivial` instead of `spec`: the `change.groom` operation with `--repo-dir --request ` with the pinned `path` + `revision` and an owned-section edit carrying the tightened body as the trivial rationale — the transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically (no spec file, no separate Board pass). Only step 2 (Brainstorm) is skipped. ## Scan mode (opt-in) -Survey TODOs, deferred changes, known gaps, and the ADR backlog; emit several lightweight `proposed` STUBS in one pass — WITHOUT specs. Scan-stubs are NOT build-ready (no spec, not trivial) — the board calls this state `needs-grooming`; they form the "ideas to brainstorm" backlog `docket-groom-next` turns build-ready. Scan-stubs still carry a `type:` — every creation path writes one, so the untyped set only ever shrinks. Scan-stubs leave `auto_groomable` unset, so none is auto-groomable until a human sets `auto_groomable: true` on it. Kept opt-in so routine runs don't generate speculative noise. Emit each stub through its own `change.create` operation transaction (resolve argv from the capability catalog) with `--repo-dir .docket --request ` (title/type/priority + why/what/scope + a stable `request_id`; no spec). Each transaction allocates the id, renders the stub's `## Artifacts` block, and re-renders the inline board atomically, pushing to `origin/docket` under an exact-lease push — so there is **no separate Board pass**. +Survey TODOs, deferred changes, known gaps, and the ADR backlog; emit several lightweight `proposed` STUBS in one pass — WITHOUT specs. Scan-stubs are NOT build-ready (no spec, not trivial) — the board calls this state `needs-grooming`; they form the "ideas to brainstorm" backlog `docket-groom-next` turns build-ready. Scan-stubs still carry a `type:` — every creation path writes one, so the untyped set only ever shrinks. Scan-stubs leave `auto_groomable` unset, so none is auto-groomable until a human sets `auto_groomable: true` on it. Kept opt-in so routine runs don't generate speculative noise. Emit each stub through its own `change.create` operation transaction (resolve argv from the capability catalog) with `--repo-dir --request ` (title/type/priority + why/what/scope + a stable `request_id`; no spec). Each transaction allocates the id, renders the stub's `## Artifacts` block, and re-renders the inline board atomically, pushing to the metadata remote under an exact-lease push — so there is **no separate Board pass**. ## Proposed-kill sub-path diff --git a/internal/assets/embedded/tree/skills/docket-review/SKILL.md b/internal/assets/embedded/tree/skills/docket-review/SKILL.md index 00b2028b7..853bf470d 100644 --- a/internal/assets/embedded/tree/skills/docket-review/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-review/SKILL.md @@ -11,7 +11,7 @@ says. ## Scope -You touch **no docket metadata**: no `.docket/`, no metadata branch, no change file, no board, no +You touch **no docket metadata**: no metadata worktree, no metadata branch, no change file, no board, no ADR, no learnings ledger. You do not open, update, or comment on a PR. You read the feature branch and return findings — the controller that dispatched you owns every write that follows from them. diff --git a/internal/assets/embedded/tree/skills/docket-status/SKILL.md b/internal/assets/embedded/tree/skills/docket-status/SKILL.md index 5dbe2142c..2a3866aae 100644 --- a/internal/assets/embedded/tree/skills/docket-status/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-status/SKILL.md @@ -64,7 +64,7 @@ There is **no** separate board pass to key on: every board-authoritative typed m ## Completion barrier — observe the sweep to its terminal result -Starting the sweep command is not finishing it. If the shell tool's foreground window expires and the harness moves the still-running sweep to the background, that is a move to background, not completion — and not a failed sweep. You stay responsible for that exact task: keep the task identity the harness returned and collect that task's terminal result through the harness's native observation/wait mechanism, and never start a second shell watcher, never poll the output file's size, never sleep-and-tail, never re-run the sweep, and never return a success report while the process remains unobserved. An output file turning nonempty, metadata commits appearing on `origin/docket`, elapsed time, or some separate command succeeding are not completion signals. +Starting the sweep command is not finishing it. If the shell tool's foreground window expires and the harness moves the still-running sweep to the background, that is a move to background, not completion — and not a failed sweep. You stay responsible for that exact task: keep the task identity the harness returned and collect that task's terminal result through the harness's native observation/wait mechanism, and never start a second shell watcher, never poll the output file's size, never sleep-and-tail, never re-run the sweep, and never return a success report while the process remains unobserved. An output file turning nonempty, metadata commits appearing on the metadata remote, elapsed time, or some separate command succeeding are not completion signals. Only the sweep's actual terminal protocol-v1 envelope completes the command. Validate it and every entry: `protocol_version` `1`, `operation` `maintenance.sweep`, a `scope` equal to the one you requested, and each entry's closed disposition. Retain the original structured output — a harness result handle or a task-local output artifact — and extract the compact summary plus any problem entries in one read/parse rather than reopening the full output repeatedly; stdout is the JSON document, and any progress diagnostics belong on a separate channel. Only after that terminal validation run the post-sweep read: the `status` operation (resolve argv from the capability catalog) with `--json`. A read taken after a failed or unvalidated sweep is diagnostic only — label it as diagnostic; it can never authorize selection. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index aed5c1ae2..35c2b3739 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -186,33 +186,33 @@ type skillBudget struct { // lock, and launch-unconfirmed is no longer an admission refusal. Pinned at the // exact new counts. var skillBudgets = []skillBudget{ - {"docket-adr/SKILL.md", 86, 1129}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (86/1123 -> 86/1129); 0502: the adr-unpublished health-check text is deleted (86/1148 -> 86/1123); 0502: the deferred-publication section and narration are deleted (110/1600 -> 86/1148) - {"docket-auto-groom/SKILL.md", 64, 1597}, // 0502: "loop" means /loop only (64/1596 -> 64/1597); 0502: one metadata layout — the docket branch and the .docket/ worktree (64/1595 -> 64/1596); 0502: the spec draft stays out of .docket/ (64/1572 -> 64/1595); 0502: auto-groom is per-change only (64/1582 -> 64/1572); 0502: the dummy-mode pointer is deleted (66/1625 -> 64/1582); 0382: typed change.groom abstain replaces the plain-git abstain commit prose (word ceiling 1750 -> 1627) + {"docket-adr/SKILL.md", 86, 1143}, // 0531: the metadata worktree and remote come from the repository.prepare context (86/1129 -> 86/1143); 0502: one metadata layout — the docket branch and the .docket/ worktree (86/1123 -> 86/1129); 0502: the adr-unpublished health-check text is deleted (86/1148 -> 86/1123); 0502: the deferred-publication section and narration are deleted (110/1600 -> 86/1148) + {"docket-auto-groom/SKILL.md", 64, 1606}, // 0531: the metadata worktree and remote come from the repository.prepare context (64/1597 -> 64/1606); 0502: "loop" means /loop only (64/1596 -> 64/1597); 0502: one metadata layout — the docket branch and the .docket/ worktree (64/1595 -> 64/1596); 0502: the spec draft stays out of .docket/ (64/1572 -> 64/1595); 0502: auto-groom is per-change only (64/1582 -> 64/1572); 0502: the dummy-mode pointer is deleted (66/1625 -> 64/1582); 0382: typed change.groom abstain replaces the plain-git abstain commit prose (word ceiling 1750 -> 1627) {"docket-brainstorm/SKILL.md", 81, 690}, // 0502 review: the scope stop names new-change Steps 3–5 accurately (81/685 -> 81/690); 0502: the role runs on a human request, not a binding (80/671 -> 81/680); 0502: drop change citations and Go v1 narration (81/680 -> 81/679); 0502: description names the human request, no binding or change citation (81/679 -> 81/685) - {"docket-build/SKILL.md", 384, 3851}, // 0502: the install operation replaces install.sh; the wrapper skills list (385/3850 -> 385/3856); 0502: the observation-budget and build-checkpoint export reads are deleted (395/3942 -> 385/3850); 0502: fixed build role; undispatchable tier halts (398/3974 -> 395/3942); 0502: the gate-execution pointer paragraph is deleted with the reference (403/4007 -> 398/3974); 0493: the gate relaunch is retired (404/4023 -> 403/4007); 0491: the run id is retired (406/4037 -> 404/4023); 0488 review fix: +the dispatch payload names the branch (406/4034 -> 406/4037); 0488 review: build-owned starts carry --change-id/--run-context (404/4018 -> 406/4034); 0488: workers run tests directly; the dispatch payload carries no worker scope identity, the task-level WAITING/takeover section is gone, the controller runs every post-repair attempt, +time-limit audit (439/4479 -> 404/4018); 0467 review fix: +the repair dispatch payload carries the run id for the build-owned post-fix re-run (436/4443 -> 439/4479); 0467: +run-id threading on prepare-scope and the build-owned start; the bundle carries no run id (432/4391 -> 436/4443); 0459: +continuation carries the claimed verdict, closed-scope statement, and fresh prepare-scope bundle (424/4284 -> 432/4391); 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above); 0502: drop change citations and Go v1 narration (385/3856 -> 384/3851) + {"docket-build/SKILL.md", 384, 3852}, // 0531: the metadata worktree and remote come from the repository.prepare context (384/3851 -> 384/3852); 0502: the install operation replaces install.sh; the wrapper skills list (385/3850 -> 385/3856); 0502: the observation-budget and build-checkpoint export reads are deleted (395/3942 -> 385/3850); 0502: fixed build role; undispatchable tier halts (398/3974 -> 395/3942); 0502: the gate-execution pointer paragraph is deleted with the reference (403/4007 -> 398/3974); 0493: the gate relaunch is retired (404/4023 -> 403/4007); 0491: the run id is retired (406/4037 -> 404/4023); 0488 review fix: +the dispatch payload names the branch (406/4034 -> 406/4037); 0488 review: build-owned starts carry --change-id/--run-context (404/4018 -> 406/4034); 0488: workers run tests directly; the dispatch payload carries no worker scope identity, the task-level WAITING/takeover section is gone, the controller runs every post-repair attempt, +time-limit audit (439/4479 -> 404/4018); 0467 review fix: +the repair dispatch payload carries the run id for the build-owned post-fix re-run (436/4443 -> 439/4479); 0467: +run-id threading on prepare-scope and the build-owned start; the bundle carries no run id (432/4391 -> 436/4443); 0459: +continuation carries the claimed verdict, closed-scope statement, and fresh prepare-scope bundle (424/4284 -> 432/4391); 0405: sequential-drive contract; 0420: shell-safe capture; 0421: budgeted repair cycle (see note above); 0502: drop change citations and Go v1 narration (385/3856 -> 384/3851) // 0154: docket-build/references/delegation-execution.md removed — it was the // evidence record for the Bash delegation facade that change 0370 deleted; its // budget row is deleted with it. {"docket-build/references/gate-driver.md", 138, 1552}, // 0502 review: the raw-verb section names finalize's repaired-head re-gate (137/1546 -> 138/1552); 0502: "loop" means /loop only (137/1550 -> 137/1546); 0502: finalize's repaired-head re-gate is a direct caller (136/1523 -> 137/1550); 0502: the gate-execution intro reference is deleted (138/1555 -> 136/1523); 0502: renamed from gate-caller-loop.md; 0497: +halted-gate tree-survives finding relay (134/1510 -> 138/1555); 0491: the run id is retired (134/1513 -> 134/1510); 0490: the admission section describes the supervisor-held worktree lock; launch-unconfirmed is no longer a refusal (136/1511 -> 134/1513); 0488 review fix: finalize reaches the driver only through finalize.rebase; task-era handoff clauses dropped (word ceiling 1504 -> 1511); 0488 review: start row carries --change-id (word ceiling 1491 -> 1504); 0488: worker-scope/takeover/acknowledge rows and the parent-takeover section removed; callers are the full-suite gates (175/1872 -> 136/1491); 0467: +prepare-scope --run-id and scope-inherited start run id (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/task-routing.md", 50, 500}, - {"docket-build-task/SKILL.md", 168, 1637}, // 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) - {"docket-convention/SKILL.md", 357, 7062}, // 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) + {"docket-build-task/SKILL.md", 168, 1640}, // 0531: the metadata worktree and remote come from the repository.prepare context (168/1637 -> 168/1640); 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) + {"docket-convention/SKILL.md", 359, 7155}, // 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. {"docket-convention/references/agent-layer.md", 148, 1419}, // 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) - {"docket-convention/references/close-out.md", 141, 1349}, // 0502: "loop" means /loop only (144/1375 -> 144/1374); 0502: one metadata layout — the docket branch and the .docket/ worktree (144/1376 -> 144/1375); 0502: the scripts/.md contract pointer becomes the schema operation (144/1374 -> 144/1376); 0502: the terminal-publication step is deleted (240/2150 -> 144/1374); 0474: reference renamed (ADR-0129 row 59a); ceilings unchanged; 0502: drop change citations and Go v1 narration (144/1374 -> 144/1356); 0502: the docket-source test citations are deleted (144/1356 -> 141/1349) + {"docket-convention/references/close-out.md", 141, 1347}, // 0531: the metadata worktree and remote come from the repository.prepare context (141/1349 -> 141/1347); 0502: "loop" means /loop only (144/1375 -> 144/1374); 0502: one metadata layout — the docket branch and the .docket/ worktree (144/1376 -> 144/1375); 0502: the scripts/.md contract pointer becomes the schema operation (144/1374 -> 144/1376); 0502: the terminal-publication step is deleted (240/2150 -> 144/1374); 0474: reference renamed (ADR-0129 row 59a); ceilings unchanged; 0502: drop change citations and Go v1 narration (144/1374 -> 144/1356); 0502: the docket-source test citations are deleted (144/1356 -> 141/1349) {"docket-convention/references/learnings.md", 66, 457}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (66/455 -> 66/457); 0502: learning.record/learning.update are the writers; the capacity section is deleted (84/580 -> 66/455) {"docket-convention/references/stacked-changes.md", 214, 2149}, // 0502: typed operations and binary-emitted findings replace the Bash-era names (213/2125 -> 214/2149); 0502: the deferred descendant-publication sentence is deleted (215/2140 -> 213/2125); 0327: +carry-preservation contract prose (see note above) {"docket-finalize-change/SKILL.md", 239, 6000}, // 0529 review: the PR-backlink repoint is a best-effort follow-up after the archive push, for root-archived descendants too (239/5984 -> 239/6000); 0529: close-out repoints the merged PR description's backlink; pr-backlink-pending is a retryable warning (239/5936 -> 239/5984); 0525 review: workspace-remnant rides any finished-workspace disposition and is named in the final report; branch-rules-unavailable comes only from the probing run (239/5910 -> 239/5936); 0525: plan-gated merges carry branch-rules-unavailable into closeout notes; cleanup reports workspace-remnant and names what blocked a workspace (239/5820 -> 239/5910); 0515 review: the refused-notes fallback names every dropped note in the final report (238/5809 -> 239/5820); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (238/5708 -> 238/5809); 0515: the repair sign-off is retired; a green repair merges (238/5808 -> 238/5708); 0517: the repaired-head evidence.record carries --owner finalize and records finalize.test_command (238/5790 -> 238/5808); 0502 review: clear-block re-reads the revision after the block lands (238/5771 -> 238/5790); 0502: repair sign-off records finalize.block before publishing the repaired head (238/5733 -> 238/5771); 0502: "loop" means /loop only (238/5729 -> 238/5733); 0502: repair sign-off is the human running finalize.clear-block (238/5666 -> 238/5729); 0502: the repaired head is re-gated through the gate driver (236/5568 -> 238/5666); 0502: the dummy-mode paragraph is deleted (238/5641 -> 236/5568); 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 161, 2109}, // 0515 review: the refused-notes fallback names every dropped note in the final report (160/2098 -> 161/2109); 0515 review: a repair whose run halts before closeout is named in finalize.block and carried into a later closeout (153/2005 -> 160/2098); 0515: the repair sign-off is retired; a green repair merges (160/2086 -> 153/2005); 0517: the repaired-head evidence.record carries --owner finalize (159/2081 -> 160/2086); 0502 review: clear-block re-reads the revision after the block lands (157/2062 -> 159/2081); 0502: repair sign-off records finalize.block before publishing the repaired head (155/2028 -> 157/2062); 0502: the embedded-bundle rule is conditioned on docket's own repository (155/2024 -> 155/2028); 0502: "loop" means /loop only (155/2027 -> 155/2024); 0502: repair sign-off is the human running finalize.clear-block; the marker lifecycle names what removes it (154/2013 -> 155/2027); 0502: the repaired head is re-gated through the gate driver (148/1937 -> 154/2013); 0497: +wait-for-the-leftover-group sentence (146/1901 -> 148/1937); 0493: the gate relaunch is retired (145/1892 -> 146/1901); 0491: the run id is retired (145/1894 -> 145/1892); 0490: the shared-slot note became the shared worktree-lock note (147/1901 -> 145/1894); 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) - {"docket-groom-next/SKILL.md", 76, 2102}, // 0502 rebase onto 0509: carried the edit-a-stub revise route and groom-exit-scoped STOPs (76/2012 -> 76/2102); 0502: "loop" means /loop only (76/2011 -> 76/2012); 0502: one metadata layout — the docket branch and the .docket/ worktree (76/2010 -> 76/2011); 0502: selection bands key on auto_groomable itself (76/2009 -> 76/2010); 0502: invoke superpowers:brainstorming by name (76/2037 -> 76/2009); 0502: the dummy-mode pointer is deleted (78/2080 -> 76/2037); 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) - {"docket-implement-next/SKILL.md", 216, 8443}, // 0517: evidence.record reads the gate command from build configuration (216/8438 -> 216/8443); 0502 rebase onto 0510: carried the Step 6.5 Backlog match paragraph and verdict pointers (214/8039 -> 216/8438); 0502 review: the controller dispatches the review tier wrapper; the skill dispatches nothing (214/8023 -> 214/8039); 0502: "loop" means /loop only (214/8058 -> 214/8057); 0502: one metadata layout — the docket branch and the .docket/ worktree (214/8042 -> 214/8058); 0502: the review payload names the resolved build.gate value; build_gate spellings become build.gate (214/8020 -> 214/8042); 0502: change.reclaim replaces reclaim-claims; the claim removes a leftover ## Run halted (214/8009 -> 214/8020); 0502: terminal-publication narration is deleted (214/8037 -> 214/8009); 0502: the severity floor is read as review.min_fix_severity through diagnostic.config (214/8034 -> 214/8037); 0502: the fixed role skills replace the $SKILL_* sites (214/8257 -> 214/8034); 0502: the dummy-mode paragraph and PR-body clause are deleted (216/8356 -> 214/8257); 0497: +Step 5 halted-build evidence kept verbatim in ## Run halted (216/8325 -> 216/8356); 0498 review: checkpoint (ii) persist wording (8320 -> 8325); 0498: +Step 6.5 review-findings homes, condensation exception, Human-actions class rule (214/8161 -> 216/8320); 0491: the run id is retired (214/8183 -> 214/8161); 0488 review: build-owned starts carry --change-id/--run-context (word ceiling 8162 -> 8183); 0488: run context and run id name only build-owned starts (word ceiling 8175 -> 8162); 0467 review fix: +the repair worker's post-fix re-run is a build-owned start (word ceiling 8165 -> 8175); 0467: +run id threaded to prepare-scope and build-owned starts (word ceiling 8080 -> 8165); 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +run-tracker resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose; 0502: drop change citations and Go v1 narration (214/8057 -> 214/8023) - {"docket-implement-next/references/edge-paths.md", 118, 1499}, // 0502: one metadata layout — the docket branch and the .docket/ worktree (118/1507 -> 118/1509); 0502: run.verify replaces verify-run (118/1505 -> 118/1507); 0502: terminal-publication narration is deleted (118/1513 -> 118/1505); 0502: the deferred results-only permit clause is deleted (118/1546 -> 118/1513); 0502: no custom plan location (118/1547 -> 118/1546); 0491: the run id is retired (118/1554 -> 118/1547); 0410: +resume/recovery + required-results reconciliation; 0375: +run-tracker resume refusals (78/1091 -> 93/1261); 0448: +named own-dependency closeout moved from SKILL.md (93/1261 -> 118/1554); 0502: drop change citations and Go v1 narration (118/1509 -> 118/1499) + {"docket-groom-next/SKILL.md", 76, 2109}, // 0531: the metadata worktree and remote come from the repository.prepare context (76/2102 -> 76/2109); 0502 rebase onto 0509: carried the edit-a-stub revise route and groom-exit-scoped STOPs (76/2012 -> 76/2102); 0502: "loop" means /loop only (76/2011 -> 76/2012); 0502: one metadata layout — the docket branch and the .docket/ worktree (76/2010 -> 76/2011); 0502: selection bands key on auto_groomable itself (76/2009 -> 76/2010); 0502: invoke superpowers:brainstorming by name (76/2037 -> 76/2009); 0502: the dummy-mode pointer is deleted (78/2080 -> 76/2037); 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) + {"docket-implement-next/SKILL.md", 212, 8282}, // 0531: the metadata worktree and remote come from the repository.prepare context (216/8443 -> 212/8282); 0517: evidence.record reads the gate command from build configuration (216/8438 -> 216/8443); 0502 rebase onto 0510: carried the Step 6.5 Backlog match paragraph and verdict pointers (214/8039 -> 216/8438); 0502 review: the controller dispatches the review tier wrapper; the skill dispatches nothing (214/8023 -> 214/8039); 0502: "loop" means /loop only (214/8058 -> 214/8057); 0502: one metadata layout — the docket branch and the .docket/ worktree (214/8042 -> 214/8058); 0502: the review payload names the resolved build.gate value; build_gate spellings become build.gate (214/8020 -> 214/8042); 0502: change.reclaim replaces reclaim-claims; the claim removes a leftover ## Run halted (214/8009 -> 214/8020); 0502: terminal-publication narration is deleted (214/8037 -> 214/8009); 0502: the severity floor is read as review.min_fix_severity through diagnostic.config (214/8034 -> 214/8037); 0502: the fixed role skills replace the $SKILL_* sites (214/8257 -> 214/8034); 0502: the dummy-mode paragraph and PR-body clause are deleted (216/8356 -> 214/8257); 0497: +Step 5 halted-build evidence kept verbatim in ## Run halted (216/8325 -> 216/8356); 0498 review: checkpoint (ii) persist wording (8320 -> 8325); 0498: +Step 6.5 review-findings homes, condensation exception, Human-actions class rule (214/8161 -> 216/8320); 0491: the run id is retired (214/8183 -> 214/8161); 0488 review: build-owned starts carry --change-id/--run-context (word ceiling 8162 -> 8183); 0488: run context and run id name only build-owned starts (word ceiling 8175 -> 8162); 0467 review fix: +the repair worker's post-fix re-run is a build-owned start (word ceiling 8165 -> 8175); 0467: +run id threaded to prepare-scope and build-owned starts (word ceiling 8080 -> 8165); 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +run-tracker resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose; 0502: drop change citations and Go v1 narration (214/8057 -> 214/8023) + {"docket-implement-next/references/edge-paths.md", 109, 1226}, // 0531: the metadata worktree and remote come from the repository.prepare context (118/1499 -> 109/1226); 0502: one metadata layout — the docket branch and the .docket/ worktree (118/1507 -> 118/1509); 0502: run.verify replaces verify-run (118/1505 -> 118/1507); 0502: terminal-publication narration is deleted (118/1513 -> 118/1505); 0502: the deferred results-only permit clause is deleted (118/1546 -> 118/1513); 0502: no custom plan location (118/1547 -> 118/1546); 0491: the run id is retired (118/1554 -> 118/1547); 0410: +resume/recovery + required-results reconciliation; 0375: +run-tracker resume refusals (78/1091 -> 93/1261); 0448: +named own-dependency closeout moved from SKILL.md (93/1261 -> 118/1554); 0502: drop change citations and Go v1 narration (118/1509 -> 118/1499) {"docket-implement-next/references/fix-pass.md", 187, 1943}, // 0502 rebase onto 0510: carried the backlog-verdict pointer for reported findings (186/1929 -> 187/1943); 0502: "loop" means /loop only (190/1962 -> 190/1967); 0502: undispatchable fix worker halts (192/1991 -> 190/1962); 0502: renamed from fix-loop.md; 0498 review: results-file antecedent (1989 -> 1991); 0498: +review-findings condensation at final consolidation (190/1958 -> 192/1989); 0410: +findings-to-results checkpoint linkage (see note above); 0502: drop change citations and Go v1 narration (190/1967 -> 186/1929) {"docket-implement-next/results-template.md", 70, 545}, // 0502 rebase onto 0510: carried the backlog verdict and next action in the Known issues placeholder (68/506 -> 70/545); 0502: inline review replaces the custom review skill (68/511 -> 68/510); 0498: +whole-branch review summary line in Verification performed (64/446 -> 68/511); 0440: reader-first template — action statement + merged Known issues (see note above); 0502: drop change citations and Go v1 narration (68/510 -> 68/506) - {"docket-review/SKILL.md", 114, 965}, // 0502: the evidence rule keys on the payload's build.gate value; gate-off skipped records are expected (110/913 -> 114/965); 0410: +findings-return capture contract (see note above) - {"docket-new-change/SKILL.md", 57, 1596}, // 0502 review: omitted draft-time scalars are described exactly (57/1591 -> 57/1596); 0502: "loop" means /loop only (57/1593 -> 57/1591); 0502: one metadata layout — the docket branch and the .docket/ worktree (57/1590 -> 57/1593); 0502: terminal-publication narration and the repository auto_groom default are deleted (57/1609 -> 57/1590); 0502: invoke superpowers:brainstorming by name (57/1636 -> 57/1609); 0502: the dummy-mode pointer is deleted (59/1674 -> 57/1636); 0445: +pointer to the docket-groom-next revise path after landing (word ceiling 1700 -> 1706); 0382: draft-time scalars moved into change.create (ceiling 1706 -> 1675) - {"docket-status/SKILL.md", 126, 2921}, // 0530: artifact-missing names where a plan or results file may live — the docket branch, or the integration branch for a record closed before the move (126/2904 -> 126/2921); 0529 review: full-scope cleanup retries also repoint stale merged-PR description backlinks; repair --pr-backlinks previews them (126/2880 -> 126/2904); 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) + {"docket-review/SKILL.md", 114, 966}, // 0531: the metadata worktree and remote come from the repository.prepare context (114/965 -> 114/966); 0502: the evidence rule keys on the payload's build.gate value; gate-off skipped records are expected (110/913 -> 114/965); 0410: +findings-return capture contract (see note above) + {"docket-new-change/SKILL.md", 57, 1603}, // 0531: the metadata worktree and remote come from the repository.prepare context (57/1596 -> 57/1603); 0502 review: omitted draft-time scalars are described exactly (57/1591 -> 57/1596); 0502: "loop" means /loop only (57/1593 -> 57/1591); 0502: one metadata layout — the docket branch and the .docket/ worktree (57/1590 -> 57/1593); 0502: terminal-publication narration and the repository auto_groom default are deleted (57/1609 -> 57/1590); 0502: invoke superpowers:brainstorming by name (57/1636 -> 57/1609); 0502: the dummy-mode pointer is deleted (59/1674 -> 57/1636); 0445: +pointer to the docket-groom-next revise path after landing (word ceiling 1700 -> 1706); 0382: draft-time scalars moved into change.create (ceiling 1706 -> 1675) + {"docket-status/SKILL.md", 126, 2923}, // 0531: the metadata worktree and remote come from the repository.prepare context (126/2921 -> 126/2923); 0530: artifact-missing names where a plan or results file may live — the docket branch, or the integration branch for a record closed before the move (126/2904 -> 126/2921); 0529 review: full-scope cleanup retries also repoint stale merged-PR description backlinks; repair --pr-backlinks previews them (126/2880 -> 126/2904); 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) } // wcLines counts lines the way `wc -l` does: the number of newline bytes. diff --git a/internal/repoguard/layout_guard_test.go b/internal/repoguard/layout_guard_test.go index 27e585cbe..d38b66748 100644 --- a/internal/repoguard/layout_guard_test.go +++ b/internal/repoguard/layout_guard_test.go @@ -22,7 +22,9 @@ import ( "go/printer" "go/token" "io/fs" + "os" "path/filepath" + "regexp" "sort" "strconv" "strings" @@ -229,3 +231,117 @@ func TestNoInlineStateFolderSpelling(t *testing.T) { strings.Join(offenders, "\n ")) } } + +// skillMetadataLiterals are the shared-mode spellings a skill or agent must not +// hard-code: the metadata worktree path and remote vary with the repository's +// visibility, so prose names them through the repository.prepare context keys +// metadata_worktree_path and metadata_remote instead. +var skillMetadataLiterals = []struct { + re *regexp.Regexp + name string +}{ + {regexp.MustCompile(`--repo-dir \.docket([^.\w]|$)`), "--repo-dir .docket"}, + {regexp.MustCompile(`git -C \.docket([^.\w]|$)`), "git -C .docket"}, + {regexp.MustCompile(`origin/docket`), "origin/docket"}, + {regexp.MustCompile(`\.docket/`), ".docket/"}, +} + +// skillMetadataLiteralViolations returns one "label: literal" entry per +// forbidden literal on each line of body. In skills/docket-convention/SKILL.md a +// single ".docket/" on a line that also says "shared" is exempt: it is the +// directory-layout sentence describing where a shared repository keeps the +// metadata worktree. +func skillMetadataLiteralViolations(rel, body string) []string { + var out []string + exemptUsed := false + for i, line := range strings.Split(body, "\n") { + for _, lit := range skillMetadataLiterals { + n := len(lit.re.FindAllStringIndex(line, -1)) + if n == 0 { + continue + } + if lit.name == ".docket/" && rel == "skills/docket-convention/SKILL.md" && + !exemptUsed && n == 1 && strings.Contains(line, "shared") { + exemptUsed = true + continue + } + out = append(out, rel+":"+strconv.Itoa(i+1)+": "+lit.name) + } + } + return out +} + +// TestSkillsSpellNoMetadataLiterals pins that no skill or agent body spells the +// shared-mode metadata worktree (.docket/, --repo-dir .docket, git -C .docket) +// or remote (origin/docket) inline. A private repository keeps its metadata +// checkout outside the clone and pushes to a local dckt remote, so the +// literals would send a worker to the wrong place. +// +// Mutation-tested: re-adding `--repo-dir .docket` to docket-new-change reddens it. +func TestSkillsSpellNoMetadataLiterals(t *testing.T) { + root := guardRoot(t) + var files []string + err := filepath.WalkDir(filepath.Join(root, "skills"), func(p string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() && strings.HasSuffix(p, ".md") { + files = append(files, p) + } + return nil + }) + if err != nil { + t.Fatalf("walk skills: %v", err) + } + agents, err := filepath.Glob(filepath.Join(root, "agents", "*.md")) + if err != nil { + t.Fatalf("glob agents: %v", err) + } + files = append(files, agents...) + if len(files) < 13 { + t.Fatalf("population floor: scanned only %d skill/agent files", len(files)) + } + var offenders []string + for _, p := range files { + b, err := os.ReadFile(p) + if err != nil { + t.Fatalf("read %s: %v", p, err) + } + rel, err := filepath.Rel(root, p) + if err != nil { + t.Fatalf("rel %s: %v", p, err) + } + offenders = append(offenders, skillMetadataLiteralViolations(filepath.ToSlash(rel), string(b))...) + } + if len(offenders) > 0 { + t.Errorf("skill prose spells a shared-mode metadata literal (use / the metadata remote from the repository.prepare context):\n %s", + strings.Join(offenders, "\n ")) + } +} + +// TestSkillMetadataLiteralViolationsDetects proves the matcher's shape, +// including the single convention exemption and its limits. +func TestSkillMetadataLiteralViolationsDetects(t *testing.T) { + const conv = "skills/docket-convention/SKILL.md" + cases := []struct { + rel, body string + want int + }{ + {"skills/x/SKILL.md", "run with `--repo-dir .docket --json`", 1}, + {"skills/x/SKILL.md", "run `git -C .docket rev-parse HEAD`", 1}, + {"skills/x/SKILL.md", "pushed to `origin/docket`", 1}, + {"skills/x/SKILL.md", "read `.docket/`", 1}, + {"skills/x/SKILL.md", "edit `.docket.yml` or `.docket.local.yml`", 0}, + {"skills/x/SKILL.md", "`--repo-dir `", 0}, + {"skills/x/SKILL.md", "`/.docket/` in a shared repository", 1}, + {conv, "`/.docket/` in a shared repository", 0}, + {conv, "`/.docket/` in a private repository", 1}, + {conv, "`.docket/` and `.docket/` in a shared repository", 1}, + {conv, "`.docket/` shared\n`.docket/` shared", 1}, + } + for _, c := range cases { + if got := len(skillMetadataLiteralViolations(c.rel, c.body)); got != c.want { + t.Errorf("%s %q: got %d violations, want %d", c.rel, c.body, got, c.want) + } + } +} diff --git a/internal/repoguard/prose_contracts_test.go b/internal/repoguard/prose_contracts_test.go index 917a77c96..727becf43 100644 --- a/internal/repoguard/prose_contracts_test.go +++ b/internal/repoguard/prose_contracts_test.go @@ -1113,10 +1113,10 @@ var alignmentContracts = []proseContract{ absent: []string{"per-repo agent pass", "generates wrapper files for", "(change 0016)", "skills: [, docket-convention]"}}, {sentinel: "align_0502_agent_layer", file: "skills/docket-build/SKILL.md", absent: []string{"install.sh", "`skills:` frontmatter"}}, - // 0502 bug 1: the draft never touches .docket/ (a dirty metadata worktree + // 0502 bug 1: the draft never touches the metadata worktree (a dirty one // makes the next repository.prepare refuse metadata-worktree-dirty). {sentinel: "align_0502_autogroom_draft", file: "skills/docket-auto-groom/SKILL.md", - present: []string{"never inside `.docket/`", "`spec_markdown`", "discard the draft"}, + present: []string{"never inside the metadata worktree", "`spec_markdown`", "discard the draft"}, absent: []string{".docket/docs/superpowers/specs/", "delete any just-drafted spec markdown"}}, // 0502 bug 2: under build.gate off a skipped record is the expected build // state; under local (or an unstated gate) green is still required. diff --git a/skills/docket-adr/SKILL.md b/skills/docket-adr/SKILL.md index 488ff63ad..895bff8f9 100644 --- a/skills/docket-adr/SKILL.md +++ b/skills/docket-adr/SKILL.md @@ -19,7 +19,7 @@ agent: docket-adr ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context forward as literals; act on the verdict). Everything below uses its vocabulary without redefinition. All ADR reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context forward as literals; act on the verdict). Everything below uses its vocabulary without redefinition. All ADR reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. ## Actions @@ -63,7 +63,7 @@ One transaction lands atomically: the new ADR (carrying its `supersedes:`/`rever ### Update note -For a non-reversing material change in context — where the decision still stands but important surrounding information has changed — append a dated `## Update` section to the ADR body. The `## Decision` section itself is never edited. Commit the updated ADR file in `.docket/` and push `origin/docket`; regenerate the index only if the update changes how the entry reads in the index. +For a non-reversing material change in context — where the decision still stands but important surrounding information has changed — append a dated `## Update` section to the ADR body. The `## Decision` section itself is never edited. Commit the updated ADR file in the metadata worktree and run `git -C push HEAD`; regenerate the index only if the update changes how the entry reads in the index. ### Index / validate diff --git a/skills/docket-auto-groom/SKILL.md b/skills/docket-auto-groom/SKILL.md index 41fd94a90..a334ef84a 100644 --- a/skills/docket-auto-groom/SKILL.md +++ b/skills/docket-auto-groom/SKILL.md @@ -20,7 +20,7 @@ agent: docket-auto-groom ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary (needs-grooming, auto-groomable, the abstain rule, …) without redefinition. All reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary (needs-grooming, auto-groomable, the abstain rule, …) without redefinition. All reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. ## Procedure — the drain @@ -32,7 +32,7 @@ Sync the metadata working tree (the startup-check `repository.prepare` operation ### Step 2 — Designer pass -Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `/learnings/README.md` and pull any findings whose hook bears on the stub, so the self-brainstorm is informed by past lessons (skipped entirely when `learnings.enabled` is `false`). Enumerate the decision points an interactive brainstorm would raise. For each, weigh 2–3 approaches and COMMIT to the conservative / recommended default — do NOT invoke `superpowers:brainstorming` with a simulated human answerer (a subagent picking "the recommended option" is the model agreeing with itself while faking an approval gate; rejected at design time). Draft the spec in session scratch space — never inside `.docket/` — as the Markdown body that Step 4 sends as `spec_markdown` in the `change.groom` request file, with an `## Assumptions` block: every decision, the chosen default, the rejected alternatives, and why — the human's deferred audit trail. If the stub is genuinely mechanical (no real design questions), the draft verdict is *trivial* instead of a spec, with the reasoning written for the critic. +Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `/learnings/README.md` and pull any findings whose hook bears on the stub, so the self-brainstorm is informed by past lessons (skipped entirely when `learnings.enabled` is `false`). Enumerate the decision points an interactive brainstorm would raise. For each, weigh 2–3 approaches and COMMIT to the conservative / recommended default — do NOT invoke `superpowers:brainstorming` with a simulated human answerer (a subagent picking "the recommended option" is the model agreeing with itself while faking an approval gate; rejected at design time). Draft the spec in session scratch space — never inside the metadata worktree — as the Markdown body that Step 4 sends as `spec_markdown` in the `change.groom` request file, with an `## Assumptions` block: every decision, the chosen default, the rejected alternatives, and why — the human's deferred audit trail. If the stub is genuinely mechanical (no real design questions), the draft verdict is *trivial* instead of a spec, with the reasoning written for the critic. ### Step 3 — Critic pass @@ -45,9 +45,9 @@ Dispatch the dedicated **`docket-auto-groom-critic`** subagent (foreground, at t ### Step 4 — Exit (one of three) -1. **Spec** — every assumption survived: apply one atomic transaction — the `change.groom` operation with `--repo-dir .docket --request ` — carrying the change id, the pinned `path` + `revision` from Step 1, `outcome: spec`, the drafted `spec_markdown` (the settled design plus its `## Assumptions` block), the owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed), and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on`. In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board; a typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing. Build-ready. -2. **Trivial** — the critic confirmed no hidden design decisions: apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body and its reasoning as the trivial rationale. The transaction sets `trivial: true` + `updated:` and re-renders the `## Artifacts` block and inline board atomically. Build-ready, no spec. -3. **Abstain** — any needs-human-context verdict, or Step 3's exhausted no-verdict posture: emit NO spec; apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: abstain`, the pinned `path` + `revision`, and a `blocked_note` — the undecidable decision(s), what context is missing, what a human should supply, and any recommendation (including "this should probably be killed/deferred because …"), with subsections at `###` or deeper. The transaction sets `auto_groomable: false` + `updated:`, appends a dated entry to the `## Auto-groom blocked` section, and re-renders the inline board — the row flips to **auto-groom blocked — needs you** in that same commit. It accepts no section, spec, or relationship edits. The stub stays needs-grooming, first in `docket-groom-next`'s queue. +1. **Spec** — every assumption survived: apply one atomic transaction — the `change.groom` operation with `--repo-dir --request ` — carrying the change id, the pinned `path` + `revision` from Step 1, `outcome: spec`, the drafted `spec_markdown` (the settled design plus its `## Assumptions` block), the owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed), and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on`. In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board; a typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing. Build-ready. +2. **Trivial** — the critic confirmed no hidden design decisions: apply the `change.groom` operation with `--repo-dir --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body and its reasoning as the trivial rationale. The transaction sets `trivial: true` + `updated:` and re-renders the `## Artifacts` block and inline board atomically. Build-ready, no spec. +3. **Abstain** — any needs-human-context verdict, or Step 3's exhausted no-verdict posture: emit NO spec; apply the `change.groom` operation with `--repo-dir --request ` with `outcome: abstain`, the pinned `path` + `revision`, and a `blocked_note` — the undecidable decision(s), what context is missing, what a human should supply, and any recommendation (including "this should probably be killed/deferred because …"), with subsections at `###` or deeper. The transaction sets `auto_groomable: false` + `updated:`, appends a dated entry to the `## Auto-groom blocked` section, and re-renders the inline board — the row flips to **auto-groom blocked — needs you** in that same commit. It accepts no section, spec, or relationship edits. The stub stays needs-grooming, first in `docket-groom-next`'s queue. **Kill and defer are NEVER autonomous.** Verdict authority over the backlog's composition stays human; the strongest the drain may say is an abstain-with-recommendation. diff --git a/skills/docket-build-task/SKILL.md b/skills/docket-build-task/SKILL.md index 43baf0222..19b86040d 100644 --- a/skills/docket-build-task/SKILL.md +++ b/skills/docket-build-task/SKILL.md @@ -21,8 +21,8 @@ subagent, and never load a review skill. commit, never by amending: another agent's work may already be inside it, and you cannot observe that. - Stay **inside the feature worktree, on its branch**, performing **no docket metadata operations**: - never write to `.docket/`, the metadata branch, change files, ADRs, the board, or the - learnings ledger; never push, force-push, `reset --hard`, or rebase — `docket-implement-next` + never write to the metadata worktree (`metadata_worktree_path`), the metadata branch, change + files, ADRs, the board, or the learnings ledger; never push, force-push, `reset --hard`, or rebase — `docket-implement-next` owns that. - A plan's `- [ ]` checkboxes are **not** progress state — do not tick them. Your commit is the record of what you finished; nothing reads the marks. diff --git a/skills/docket-build/SKILL.md b/skills/docket-build/SKILL.md index db0af4c02..298772357 100644 --- a/skills/docket-build/SKILL.md +++ b/skills/docket-build/SKILL.md @@ -20,7 +20,8 @@ gets exactly one fresh worker dispatch unless that worker requests its single al ## Inputs - The **plan** `docket-implement-next` Step 4 wrote, at the path recorded in the change's `plan:` - field, read from the metadata worktree at `.docket/`. + field, read from the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) at + `/`. - The **feature branch and worktree** already cut for this change, plus that repo's own instruction files (`AGENTS.md`, `CLAUDE.md`, nested equivalents). - The plan's `### Task N` headings, the **unit of dispatch** — one heading, one worker, one diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index 8b0192e59..f75d91d6a 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -74,12 +74,12 @@ Every operating skill starts identically; skill bodies compress to a pointer her - the `capabilities` verb is unknown to the binary → the installed binary predates the capability contract: **stop** and instruct the human to update or reinstall Docket. Never fall back to `--help`, guessed verbs, or probe invocations. - **refuse** (stop and surface the diagnostic) on any of: `protocol_version` ≠ 1 or an unsupported `capability_version`; a malformed envelope; duplicate `id`s; an effect outside the allowed values (`read` | `local-write` | `metadata-write` | `external-write` | `process-control`); an entry with a missing/empty `argv`; an absent or empty `commands` array. - a validated catalog **may be reused** by later skills in the **same** agent context; a **separately dispatched** agent fetches its own. No cache file, no repository metadata, no environment transport. -3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir --json` **as its own Bash call** — never compounded with other commands — then validate the protocol-v1 envelope and carry its typed context values forward as literals in later commands (no `eval`, no `source`). `repository.prepare` resolves config, enforces the bootstrap guard **fail-closed**, and ensures + syncs the metadata working tree (the persistent `.docket/` worktree, parked on `docket`, shared hooks disabled), returning a **closed typed context** (repo root, origin, the default/integration/metadata branches with pinned revisions, the `.docket` worktree path, the changes/ADR/results dirs, the `build` configuration (`gate`, `test_command`, `max_attempts`), and the finalize configuration — no `DOCKET_*` names, no shell quoting). Its **disposition** takes the allowed values `applied` | `no-op` | `refused` | `error`: an `applied`/`no-op` carries the context; a `refused`/`error` carries a structured finding whose remedy names the exact next command instead. +3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir --json` **as its own Bash call** — never compounded with other commands — then validate the protocol-v1 envelope and carry its typed context values forward as literals in later commands (no `eval`, no `source`). `repository.prepare` resolves config, enforces the bootstrap guard **fail-closed**, and ensures + syncs the metadata working tree (the persistent metadata worktree, parked on the metadata branch, shared hooks disabled), returning a **closed typed context** (repo root, origin, the default/integration/metadata branches with pinned revisions, the metadata worktree path `metadata_worktree_path`, the metadata remote `metadata_remote` and its remote-tracking ref `metadata_tracking_ref`, the changes/ADR/results dirs, the `build` configuration (`gate`, `test_command`, `max_attempts`), and the finalize configuration — no `DOCKET_*` names, no shell quoting). Its **disposition** takes the allowed values `applied` | `no-op` | `refused` | `error`: an `applied`/`no-op` carries the context; a `refused`/`error` carries a structured finding whose remedy names the exact next command instead. 4. Act on the disposition: `applied`/`no-op` → continue. A `refused` on a **legacy single-branch** layout → refuse and point the human at `docket repository migrate` (human-typed remedy, human-initiated, never an agent runtime invocation). A `refused` on a **fresh repository** (once, human-attended) → the human runs `docket repository init` (human-typed remedy), then re-run `repository.prepare`. Any other `refused`/`error` (dirty, ahead, diverged, foreign, ambiguous, or an unresolved probe) → surface the finding's remedy and stop; it is a human's to resolve. **Mid-run posture.** Construct every subsequent docket invocation from the fetched catalog entry for the semantic operation the skill names. An operation the workflow needs that is **absent** from the catalog → the workflow's existing hard-error posture; never guess a spelling. An invocation resolved from a validated catalog that returns **unknown-command mid-run** → the binary was replaced or is inconsistent: **stop**; do not silently refetch and switch interfaces mid-workflow. An operation whose cataloged `effects` **exceed the workflow's authorized boundary** → stop with a capability-mismatch diagnostic. A `--request`/`--input` body is built from that operation's `schema` descriptor (per *Reaching docket's operations*), never guessed, probed, or read from source. -All metadata reads and writes happen in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately. Every mid-run metadata re-sync — pre-read syncs and **conflict-checked push retries alike** — is a fresh `repository.prepare` run (for a push retry: re-run `repository.prepare`, then retry the push); plain git plumbing (`git add`/`commit`/`push`, `git -C` forms) stays direct — and stages by explicit path: that tree is SHARED, so `git add -A`, `git add .`, or `commit -a` there sweeps up whatever another agent had staged at that instant, committing it under your message and pushing it. Stage by explicit path. +All metadata reads and writes happen in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately. Every mid-run metadata re-sync — pre-read syncs and **conflict-checked push retries alike** — is a fresh `repository.prepare` run (for a push retry: re-run `repository.prepare`, then retry the push); plain git plumbing (`git add`/`commit`/`push`, `git -C` forms) stays direct — and stages by explicit path: that tree is SHARED, so `git add -A`, `git add .`, or `commit -a` there sweeps up whatever another agent had staged at that instant, committing it under your message and pushing it. Stage by explicit path. ### Harness-native recovery after sandbox or permission denial @@ -109,7 +109,7 @@ Each **autonomous** docket skill can run as a model/effort-pinned **subagent** i A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -160,7 +160,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: The `archive/` filename date prefix is **UTC**: the **merge commit's** date for `done`, the **kill commit's** date for `killed`. -All of the above lives on the `docket` branch, written through the persistent, gitignored **`.docket/` metadata worktree** (deliberately not under `.worktrees/` — slug collisions, prune blast radius; see *Branch model*). +All of the above lives on the metadata branch, written through the persistent **metadata worktree**: `/.docket/` in a shared repository (gitignored, deliberately not under `.worktrees/` — slug collisions, prune blast radius), or a checkout outside the clone in a private one; skills always use `metadata_worktree_path` (see *Branch model*). **Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `/--results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. @@ -336,7 +336,7 @@ The derived views over the change files — the `inline` board, each change's `# At startup, after resolving config, fetch origin and evaluate a 2×2 over two probes: -- **`DOCKET`** = the `docket` branch exists (origin OR local). +- **`DOCKET`** = the metadata branch exists (on the metadata remote OR locally). - **`LIVE`** = the **live planning surface** still sits on the integration branch — probe ONLY the pruned surface (`/active`, `README.md`, `BOARD.md`) via `git ls-tree origin/`; `archive/`, `/`, and pre-migration specs deliberately *stay* on integration, so probing them would read `LIVE` forever. An unresolvable `origin/` is a **hard config error**, not `¬LIVE`. | | `LIVE` | `¬LIVE` | @@ -348,10 +348,12 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr ### Branch model -Metadata (change files, `BOARD.md`, ADRs, specs) commits to the `docket` branch via the persistent **`.docket/` worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to its remote immediately** (the planning surface stays browsable on the remote). +Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. + +A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. A change's feature branch is minted at claim as `/`, or `/` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. -The `.docket` metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. +The metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/skills/docket-convention/references/close-out.md b/skills/docket-convention/references/close-out.md index 27ef0b513..81cb2c9d7 100644 --- a/skills/docket-convention/references/close-out.md +++ b/skills/docket-convention/references/close-out.md @@ -13,8 +13,9 @@ Contents: [The sequence](#the-sequence) · [Failure posture](#failure-posture--p ## The sequence -All metadata writes happen in the metadata working tree (`.docket/`), synced to `origin/docket` -before the first read; every commit pushes immediately. +All metadata writes happen in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context), +synced to the metadata remote (`metadata_remote`) before the first read; every commit pushes +immediately. 1. **Archive on `docket` first.** The two final outcomes split here: `done` runs the Go `finalize.closeout` transaction; `killed` runs the Go `change.kill` transaction (`finalize.closeout` @@ -52,7 +53,7 @@ before the first read; every commit pushes immediately. ``` # request-file: { "change_id": , "path": "/active/--.md", # "revision": "", "why_killed": "" } - change.kill --repo-dir .docket --input --json # resolve argv from the capability catalog + change.kill --repo-dir --input --json # resolve argv from the capability catalog ``` Trust the typed outcome: `applied` ⇒ archived — an idempotent no-op if already archived, @@ -92,7 +93,7 @@ before the first read; every commit pushes immediately. workspaces and feature refs proven owned by *this* closed change are removed — the local ref only when its recorded tip is detached from every worktree AND contained in the verified merge chain, the remote ref only under an exact old-value lease with no open child PR still targeting - it — never the `.docket/` metadata worktree, the primary tree, or any out-of-tree path. Any + it — never the metadata worktree, the primary tree, or any out-of-tree path. Any resource whose ownership it cannot prove is **retained**, not force-removed. A failure aborts per the caller's posture. diff --git a/skills/docket-groom-next/SKILL.md b/skills/docket-groom-next/SKILL.md index d1fd36413..e0693b513 100644 --- a/skills/docket-groom-next/SKILL.md +++ b/skills/docket-groom-next/SKILL.md @@ -21,7 +21,7 @@ This skill grooms interactively with a human, so it cannot be a fire-and-forget ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All reads and writes land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately so the backlog stays reviewable on GitHub and visible to the autonomous implementer; grooming never touches `origin/` — markdown only, no branches or code. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All reads and writes land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately so the backlog stays reviewable and visible to the autonomous implementer; grooming never touches `origin/` — markdown only, no branches or code. ## Procedure @@ -58,18 +58,18 @@ Then invoke `superpowers:brainstorming` WITH THE HUMAN, seeded with the stub's b All six exits reuse existing transitions — this skill introduces no new lifecycle status: -1. **Spec** (the normal exit): **author** the settled design as the spec's Markdown body, and any owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed). Apply it with one atomic transaction — the `change.groom` operation with `--repo-dir .docket --request ` — carrying the change id, the pinned record `path` + `revision` from Step 1, `outcome: spec`, the `spec_markdown`, the owned-section edits, and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on` (authored Markdown travels in the request file, never a shell-escaped flag). In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board — so there is no separate render, back-link, or Board pass. A typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing — surface it. The change is now build-ready. -2. **Trivial verdict**: the brainstorm concludes there is no real design question — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body as the trivial rationale. The transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically — no spec file, no separate Board pass. Also build-ready. +1. **Spec** (the normal exit): **author** the settled design as the spec's Markdown body, and any owned proposal-section rewrites (proposal altitude, resolved `## Open questions` removed). Apply it with one atomic transaction — the `change.groom` operation with `--repo-dir --request ` — carrying the change id, the pinned record `path` + `revision` from Step 1, `outcome: spec`, the `spec_markdown`, the owned-section edits, and the desired `depends_on`/`related`/`adrs`/`discovered_from`/`stacked_on` (authored Markdown travels in the request file, never a shell-escaped flag). In one metadata commit it writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, and re-renders the `## Artifacts` block and inline board — so there is no separate render, back-link, or Board pass. A typed refusal (not-groomable, spec-path-taken, malformed markers, revision mismatch) writes nothing — surface it. The change is now build-ready. +2. **Trivial verdict**: the brainstorm concludes there is no real design question — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: trivial`, the pinned `path` + `revision`, and an owned-section edit carrying the tightened body as the trivial rationale. The transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically — no spec file, no separate Board pass. Also build-ready. 3. **Kill**: the stub is obsolete, a duplicate, or decided against — follow the proposed-kill sub-path in `docket-new-change` (it owns the kill mechanics; do not restate them here). -4. **Defer**: right idea, wrong time — apply the `change.defer` operation with `--repo-dir .docket --request ` with the pinned `path` + `revision` and the authored `why_deferred` section body. The transaction sets `status: deferred`, splices the `## Why deferred` section, sets `updated:`, and re-renders the inline board atomically. -5. **Revise** (explicit-id route only): the human wants a `proposed` change adjusted without changing its groom state — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `revision`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_revision`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:`, `trivial:`, or `auto_groomable:`. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked` — also the answer to `spec_markdown` on a needs-grooming stub, which has no spec — `spec-file-missing`, `empty-revise`, `empty-spec_revision`, `invalid-section-heading` for an edit naming `## Auto-groom blocked`, revision mismatch) writes nothing — surface it. The change keeps its groom state. -6. **Re-enable** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: re-enable`, the pinned `path` + `revision`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-re-enable` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-enable. +4. **Defer**: right idea, wrong time — apply the `change.defer` operation with `--repo-dir --request ` with the pinned `path` + `revision` and the authored `why_deferred` section body. The transaction sets `status: deferred`, splices the `## Why deferred` section, sets `updated:`, and re-renders the inline board atomically. +5. **Revise** (explicit-id route only): the human wants a `proposed` change adjusted without changing its groom state — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: revise`, the pinned `path` + `revision`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_revision`, the linked spec's blob object id read by `git -C rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:`, `trivial:`, or `auto_groomable:`. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked` — also the answer to `spec_markdown` on a needs-grooming stub, which has no spec — `spec-file-missing`, `empty-revise`, `empty-spec_revision`, `invalid-section-heading` for an edit naming `## Auto-groom blocked`, revision mismatch) writes nothing — surface it. The change keeps its groom state. +6. **Re-enable** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir --request ` with `outcome: re-enable`, the pinned `path` + `revision`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-re-enable` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-enable. **Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-enable) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `not-retitleable`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. ### Step 5 — The transaction lands (no separate board pass) -The Step-4 typed op is the whole write: it re-checks the pinned exact `revision`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to `origin/docket` under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-grooming, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row's readiness; a re-enable returns an abstained row to needs-grooming). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `revision` from the `status` operation, and — on a groom exit, if it is no longer needs-grooming (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_revision`) and stop if the change is no longer `proposed` or a stub edit finds it groomed (`spec:` or `trivial: true`). STOP — grooming never implements. +The Step-4 typed op is the whole write: it re-checks the pinned exact `revision`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to the metadata remote under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-grooming, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row's readiness; a re-enable returns an abstained row to needs-grooming). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `revision` from the `status` operation, and — on a groom exit, if it is no longer needs-grooming (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_revision`) and stop if the change is no longer `proposed` or a stub edit finds it groomed (`spec:` or `trivial: true`). STOP — grooming never implements. ## Concurrency — no claim diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index 697f5bd83..f20360103 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -24,7 +24,7 @@ Before anything else in this skill, invoke the `docket-convention` skill via the ### Step 0 — Sync & sweep -Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the spec copy and the code land on the feature branch. +Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately; only the spec copy and the code land on the feature branch. **Named invocation — no maintenance preflight.** When this run was invoked with exactly **one explicit change id** — an initial named call, an attributed retry carrying the id, or a named resume of an in-progress change — skip the implementation preflight and Step 1's status-digest certification entirely: after the `repository.prepare` operation succeeds, go **directly** to the authoritative `context.implementation` operation with `--id ` and the existing claim/resume path. The named path never runs `maintenance.preflight` or any maintenance sweep first — another change's failing closeout, cleanup, or reclaim can never halt this change before its own claim/gate; anyone who wants those maintenance outcomes runs the explicit `maintenance.preflight` or `maintenance.sweep` operation deliberately, where every problem stays visible. Every local check for the named change is kept — readiness, record revision, dependencies, effective base, claim eligibility, run context, and resume/cancellation — so a malformed, absent, not-ready, or improperly resumed id returns its existing typed refusal, reported with its reason exactly as a scoped-id skip is (ending `drained` when nothing else is in scope); a named request **never falls back to selecting another change**, and shared repository/configuration/transport failures keep the `halted` posture. @@ -46,7 +46,7 @@ The digest is an **accelerator, not the sole channel**: the authoritative read i ### Step 2 — Claim (conflict-checked write) -Claim by transaction: the `change.claim` operation with `--id --revision ` (the exact revision from the step-1 bundle). When the dispatch prompt carried a **run context** token, pass it to the claim as --run-context : the claim validates it against the started run, records durable proof binding this dispatch to its successful claim, and the parent's keyed verdict resolves ownership from that proof; an invalid or conflicting run context is a typed refusal that writes nothing — never retried as an ungated claim. A claim without the token stays supported for ungated invocations. In the **metadata working tree** the operation reloads fresh origin, re-proves the change is still `proposed`, uniquely identified, build-ready, and has a resolved effective base, then applies the landed claim (`status: in-progress` + `branch: /` + `updated:` + `claimed_at:` — the lease the `change.reclaim` operation and the maintenance sweep's reclaim key on) and updates the change record, `## Artifacts` block, and inline board **together in one metadata commit** on the `docket` branch (`origin/docket`). It returns the new record revision, feature branch, lease classification, and a closed disposition; a wrong revision or a not-`proposed`/not-build-ready/duplicate/unresolved-base change returns `contended`/a typed refusal that **writes nothing and creates no workspace**. A lost-response retry converges by re-reading authority — an already-held matching claim returns the applied receipt or a typed `already-claimed`, a foreign claim returns `contended`; never adopt a branch or workspace as proof the claim landed. A `contended` result is the **`contended`** disposition (see *Terminal disposition*): a lost conflict-checked claim race is a normal, continue-able outcome a driver re-selects past, **never `halted`** — re-sync (re-run the `repository.prepare` operation) and re-select. No worktree yet. +Claim by transaction: the `change.claim` operation with `--id --revision ` (the exact revision from the step-1 bundle). When the dispatch prompt carried a **run context** token, pass it to the claim as --run-context : the claim validates it against the started run, records durable proof binding this dispatch to its successful claim, and the parent's keyed verdict resolves ownership from that proof; an invalid or conflicting run context is a typed refusal that writes nothing — never retried as an ungated claim. A claim without the token stays supported for ungated invocations. In the **metadata working tree** the operation reloads fresh origin, re-proves the change is still `proposed`, uniquely identified, build-ready, and has a resolved effective base, then applies the landed claim (`status: in-progress` + `branch: /` + `updated:` + `claimed_at:` — the lease the `change.reclaim` operation and the maintenance sweep's reclaim key on) and updates the change record, `## Artifacts` block, and inline board **together in one metadata commit** on the metadata branch, pushed to the metadata remote. It returns the new record revision, feature branch, lease classification, and a closed disposition; a wrong revision or a not-`proposed`/not-build-ready/duplicate/unresolved-base change returns `contended`/a typed refusal that **writes nothing and creates no workspace**. A lost-response retry converges by re-reading authority — an already-held matching claim returns the applied receipt or a typed `already-claimed`, a foreign claim returns `contended`; never adopt a branch or workspace as proof the claim landed. A `contended` result is the **`contended`** disposition (see *Terminal disposition*): a lost conflict-checked claim race is a normal, continue-able outcome a driver re-selects past, **never `halted`** — re-sync (re-run the `repository.prepare` operation) and re-select. No worktree yet. A claim of a `proposed` record that still carries a `## Run halted` section removes that section in the same transaction. **Resuming a halted in-progress change** — one the caller re-dispatches by id after a `run.verify` verdict of **`run-halted`** — goes through the `change.resume-halted` operation with `--id --revision --acknowledge-quiescent`, never a fresh claim: it requires the exact marked record and the explicit `--acknowledge-quiescent` acknowledgement that the prior worker is quiescent, reprobes the branch/workspace/live gate, refreshes the claim, and removes exactly the `## Run halted` marker section while preserving every other byte and checkpoint — it never resets or adopts a workspace whose writer may be live. It refuses (writing nothing) without the acknowledgement, on revision drift (`contended`), or on a live gate lock. A change that instead reached `proposed` through the `change.reclaim` operation carries no live claim, so it re-enters through the ordinary claim above. The claim and resume both render the inline board in their own transaction, so no separate Board pass runs here. Starting the resume also passes through the run tracker, which refuses a second run over one that has not verifiably stopped — **read `references/edge-paths.md` now (blocking)** for those run-tracker resume refusals and their remedies. @@ -54,7 +54,7 @@ A claim of a `proposed` record that still carries a `## Run halted` section remo ### Step 3 — Reconcile ⭐ -Re-read the step-1 bundle's change + spec bytes against `related` + recently-archived changes, cited + recent ADRs, and CURRENT code, and decide NON-INTERACTIVELY what is true NOW (drop work done elsewhere, adjust scope, fold in new constraints). That judgment is yours; the write is a transaction. **Author** a reconcile request — structured replacement text for the owned proposal sections and still-mutable linked-spec content, the complete desired relations (`depends_on`/`stacked_on`/`related`/`adrs`/`discovered_from`) when reality requires them, and one dated `## Reconcile log` entry — and apply it with the `change.reconcile` operation with `--input ` (the request file carries `id`, the pinned `revision`, and the authored Markdown — never a shell-escaped flag). The transaction checks the exact revision and legal `in-progress` status, sets `reconciled: true`, refreshes the claim, rerenders backlinks/`## Artifacts` block/inline board, validates the whole repository, and commits atomically on the `docket` branch (`origin/docket`); unknown frontmatter, unlisted authored sections, line endings, and unrelated files stay byte-identical, and a request naming a non-owned field/section is a typed refusal that writes nothing. A trivial change has no spec — patch the body only. If fresh origin state diverges incompatibly, the operation returns `contended` and writes nothing — never a text-merge; re-read fresh context and re-author. +Re-read the step-1 bundle's change + spec bytes against `related` + recently-archived changes, cited + recent ADRs, and CURRENT code, and decide NON-INTERACTIVELY what is true NOW (drop work done elsewhere, adjust scope, fold in new constraints). That judgment is yours; the write is a transaction. **Author** a reconcile request — structured replacement text for the owned proposal sections and still-mutable linked-spec content, the complete desired relations (`depends_on`/`stacked_on`/`related`/`adrs`/`discovered_from`) when reality requires them, and one dated `## Reconcile log` entry — and apply it with the `change.reconcile` operation with `--input ` (the request file carries `id`, the pinned `revision`, and the authored Markdown — never a shell-escaped flag). The transaction checks the exact revision and legal `in-progress` status, sets `reconciled: true`, refreshes the claim, rerenders backlinks/`## Artifacts` block/inline board, validates the whole repository, and commits atomically on the metadata branch, pushed to the metadata remote; unknown frontmatter, unlisted authored sections, line endings, and unrelated files stay byte-identical, and a request naming a non-owned field/section is a typed refusal that writes nothing. A trivial change has no spec — patch the body only. If fresh origin state diverges incompatibly, the operation returns `contended` and writes nothing — never a text-merge; re-read fresh context and re-author. Adjacent follow-up work this pass surfaces is **noted for the final report** — nothing is minted automatically; a human captures reported work deliberately with `docket change create`, guided by the backlog verdict final consolidation attaches (Step 6.5 *Backlog match*). @@ -65,7 +65,7 @@ Two escape hatches: ### Step 4 — Worktree + plan -CONFIRM the step-3 reconcile push has landed on the **metadata branch** before continuing — by **SHA-compare**, not "the push exited 0": after a re-sync, the local metadata tip must equal the remote tip (re-run the `repository.prepare` operation, assert `git -C .docket rev-parse @ == git rev-parse origin/docket`). If they differ (a concurrent writer rejected the push): re-sync, re-push — repeat until the SHAs match, so the build never reads bytes older than origin. Then prepare the owned feature workspace: +CONFIRM the step-3 reconcile push has landed on the **metadata branch** before continuing — by **SHA-compare**, not "the push exited 0": after a re-sync, the local metadata tip must equal the remote tip (re-run the `repository.prepare` operation, assert `git -C rev-parse HEAD` equals `git -C rev-parse `). If they differ (a concurrent writer rejected the push): re-sync, re-push — repeat until the SHAs match, so the build never reads bytes older than origin. Then prepare the owned feature workspace: ``` workspace.prepare --id --revision # resolve argv from the capability catalog @@ -75,7 +75,7 @@ The `workspace.prepare` operation reloads the authoritative snapshot, resolves t the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/` in the ordinary case). -**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id `: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id --revision ` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. +**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync the metadata worktree immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id `: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id --revision ` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. **Plan authoring (dispatched).** The parent stays the orchestrator. @@ -84,9 +84,9 @@ Feature worktree: It also supplies the change id, title, exact record revision, synchronized change-file and spec paths, pre-dispatch HEAD, and learnings enablement plus (when enabled) `/learnings/README.md`. The child invokes `superpowers:writing-plans` **DIRECTED to:** write the plan in a scratch file outside both worktrees and stop there (when the skill cannot be invoked it authors the plan itself, warning prominently — carry that warning into the run report and PR body), runs the `change.attach-plan` operation with `--id --revision --path docs/superpowers/plans/-.md --markdown ` — it writes the plan with its backlink on `docket` and sets `plan:` in one commit — and returns the single success line `PLAN_PATH=`. -**Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. +**Verification (parent).** The returned path is a **claim, not proof**. Re-sync the metadata worktree (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id `) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. +**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id `) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`/`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build @@ -103,7 +103,7 @@ For the `docket-review` role, **select the review tier** deterministically from The review role — `docket-review` — is invoked **DIRECTED to:** review the whole branch against its base and return its findings, then stop, answering any choice it poses from resolved config and never surfacing one — log one line naming the role and skill if you suppressed a hand-off. `docket-review` is a skill, never a same-name agent to dispatch, and the skill itself dispatches nothing: you — the controller — dispatch the selected tier wrapper — `docket-review-lean`, `docket-review-standard`, or `docket-review-deep` — foreground, and that wrapper preloads the `docket-review` skill. Its dispatch payload contains: Feature worktree: -It also contains the branch and base ref, change title and scope, relevant learnings hooks, the evidence record, and the resolved `build.gate` value (from the startup-check prepared context's `build` block). When the `docket-review` skill is missing, apply the convention's missing-skill rule: review the whole branch inline before the PR opens, warning prominently, and dispatch no reviewer. Re-read the learnings index `/learnings/README.md` first and pull the findings relevant to what this change touched (skipped entirely when `learnings.enabled` is `false`). For any non-obvious decision made during implementation, **dispatch the `docket-adr` subagent** (foreground, at the model/effort its wrapper resolves) — once per decision; it assigns the number, updates the index, commits the ADR on `origin/docket`, and **returns the number**. After re-syncing `.docket/`, record that number in the change's `adrs:` relation through a `change.reconcile` operation relations update — the transaction re-checks the exact revision and re-renders the `## Artifacts` block and inline board atomically, so the skill never hand-renders a derived view. Review findings that are distinct follow-up work — not this change's own fixes — are **reported as follow-up work in the final report**; nothing is minted automatically, and a human captures reported work deliberately with `docket change create`. On unavailable dispatch — established only per the convention's *Dispatch-capability resolution*, never from a tool name, and never from a rejected same-name role-agent attempt — an undispatchable review tier makes the review role **`halt`** on the same terms as step 5; the `docket-adr` dispatch falls back **`inline`**, with its git-state contract unchanged. +It also contains the branch and base ref, change title and scope, relevant learnings hooks, the evidence record, and the resolved `build.gate` value (from the startup-check prepared context's `build` block). When the `docket-review` skill is missing, apply the convention's missing-skill rule: review the whole branch inline before the PR opens, warning prominently, and dispatch no reviewer. Re-read the learnings index `/learnings/README.md` first and pull the findings relevant to what this change touched (skipped entirely when `learnings.enabled` is `false`). For any non-obvious decision made during implementation, **dispatch the `docket-adr` subagent** (foreground, at the model/effort its wrapper resolves) — once per decision; it assigns the number, updates the index, commits the ADR on the metadata remote, and **returns the number**. After re-syncing the metadata worktree, record that number in the change's `adrs:` relation through a `change.reconcile` operation relations update — the transaction re-checks the exact revision and re-renders the `## Artifacts` block and inline board atomically, so the skill never hand-renders a derived view. Review findings that are distinct follow-up work — not this change's own fixes — are **reported as follow-up work in the final report**; nothing is minted automatically, and a human captures reported work deliberately with `docket change create`. On unavailable dispatch — established only per the convention's *Dispatch-capability resolution*, never from a tool name, and never from a rejected same-name role-agent attempt — an undispatchable review tier makes the review role **`halt`** on the same terms as step 5; the `docket-adr` dispatch falls back **`inline`**, with its git-state contract unchanged. **Triage the returned findings, then FIX them in-branch.** Findings are repaired on the open branch before the PR opens — a bounded **fix pass**, not a stub for every one. `review.min_fix_severity` (read through the `diagnostic.config` operation; `minor` by default) is the lowest severity that enters it, and blockers are fixed regardless of it. Routing is by the fix's CHARACTER via the shared rubric, never by its severity, and never reaches the `max` tier; every fix runs the `docket-build-task` contract. Before dispatching the first fix task, **read `references/fix-pass.md` now (blocking)** — it owns the routing table, the non-blocker task cap, the per-finding and batched task shapes, the revert-and-record suite gate (bounded at two runs; still-red halts), and the PR-body disposition table. The reviewer's `unverified-build-state` blocker is the one exception you resolve yourself, by re-running the suite — or, under `build.gate: off`, by re-minting the skipped record. A finding that is genuinely distinct beyond-the-branch work is reported as follow-up work in the final report, carrying the backlog verdict final consolidation attaches; a finding about this branch's own diff never is. There is **no re-review** round after fixes — remediation is carried by the worker's own self-review, the suite re-run, and the human reading every fix in the PR diff. @@ -119,7 +119,7 @@ The results artifact is **required for every change, trivial included** — ther **Per-checkpoint mechanics** (in order): 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. -2. Read and **preserve** the prior results content — after a re-sync, from `.docket/` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). +2. Read and **preserve** the prior results content — after a re-sync, from `/` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). 3. Write the update into the scratch file. 4. Attach it: the `change.attach-results` operation with `--id --revision --path --markdown `, at a revision re-read after a re-sync (the `context.implementation` operation with `--id `) because the claim refresh, plan attach, and every earlier checkpoint move the record, writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. @@ -201,7 +201,7 @@ A change is drafted against a *snapshot* of the codebase, ADRs, and other in-fli ### The field-write rule -Every change-file field write in this run (claim's `status:`/`branch:`, reconcile, `plan:`, `adrs:`, `results:`, `pr:` + `status: implemented`) lands through a **`change.*` / attach / mark-implemented transaction** in the `.docket/` worktree on the `docket` branch — never a hand-edit, never in the feature worktree — which reloads fresh origin, checks the exact record revision, and commits and pushes atomically. The skill **never invokes a facade renderer and never hand-writes a derived view**: the transaction re-stamps `claimed_at: ` on every mutation and, for any **link-bearing field** (`spec:`/`plan:`/`adrs:`/`pr:`/`results:`), regenerates the `## Artifacts` block and inline board in the same commit; the skill also refreshes the claim explicitly (the `change.refresh-claim` operation) around the long plan and build dispatches, stamping the plan→build seam where a stopped run is otherwise invisible. The halt-path authored sections also land through transactions, never a hand-edit: the `change.halt` operation writes the `## Run halted` section (Step 3) and the `change.resume-halted` operation removes it on resume (Step 2), each an exact-revision transaction that renders the inline board in its own commit. The skill authors the halt report as a request file and hands it across; it never edits the section bytes itself. **Stage by explicit path** — that tree is shared, so a bare `add -A` commits another agent's staged work under your message. +Every change-file field write in this run (claim's `status:`/`branch:`, reconcile, `plan:`, `adrs:`, `results:`, `pr:` + `status: implemented`) lands through a **`change.*` / attach / mark-implemented transaction** in the metadata worktree on the metadata branch — never a hand-edit, never in the feature worktree — which reloads fresh origin, checks the exact record revision, and commits and pushes atomically. The skill **never invokes a facade renderer and never hand-writes a derived view**: the transaction re-stamps `claimed_at: ` on every mutation and, for any **link-bearing field** (`spec:`/`plan:`/`adrs:`/`pr:`/`results:`), regenerates the `## Artifacts` block and inline board in the same commit; the skill also refreshes the claim explicitly (the `change.refresh-claim` operation) around the long plan and build dispatches, stamping the plan→build seam where a stopped run is otherwise invisible. The halt-path authored sections also land through transactions, never a hand-edit: the `change.halt` operation writes the `## Run halted` section (Step 3) and the `change.resume-halted` operation removes it on resume (Step 2), each an exact-revision transaction that renders the inline board in its own commit. The skill authors the halt report as a request file and hands it across; it never edits the section bytes itself. **Stage by explicit path** — that tree is shared, so a bare `add -A` commits another agent's staged work under your message. ### Feature branch invariants diff --git a/skills/docket-implement-next/references/edge-paths.md b/skills/docket-implement-next/references/edge-paths.md index 0f6719548..6f932067e 100644 --- a/skills/docket-implement-next/references/edge-paths.md +++ b/skills/docket-implement-next/references/edge-paths.md @@ -83,9 +83,10 @@ Before building continues, re-run `workspace.commit-spec`: after a mid-flight sp refreshes the copy; otherwise it is a no-op. **The results seam.** The results artifact is required for every change, so a resume -must not lose it. On resume, **load the attached results from `.docket/` (after a -re-sync) before starting new work**, and reuse the `results:` path whenever it is set — a changed -authoring date **never mints a second file**; the canonical path is chosen once and reused. +must not lose it. On resume, **load the attached results from the metadata worktree +(`metadata_worktree_path` in the `repository.prepare` context) at +`/`, after a re-sync, before starting new work**, and +reuse the `results:` path whenever it is set — a changed authoring date **never mints a second file**; the canonical path is chosen once and reused. **Never overwrite newer remote work** — a resume that finds the remote ahead re-reads authority rather than force-writing its local view. diff --git a/skills/docket-new-change/SKILL.md b/skills/docket-new-change/SKILL.md index 5675b36e1..085297768 100644 --- a/skills/docket-new-change/SKILL.md +++ b/skills/docket-new-change/SKILL.md @@ -22,7 +22,7 @@ This skill brainstorms with a human, so it cannot be a fire-and-forget subagent ## Convention (load first — blocking) -Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All writes — the change, spec, and refreshed `BOARD.md` — land in the `.docket/` worktree on the `docket` branch, pushed to its remote immediately so the backlog is reviewable on GitHub and visible to the autonomous implementer. +Invoke the `docket-convention` skill via the Skill tool first — unless already invoked this session — and run its *startup check* (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). Everything below uses its vocabulary without redefinition. All writes — the change, spec, and refreshed `BOARD.md` — land in the metadata worktree (`metadata_worktree_path` in the `repository.prepare` context) on the metadata branch, pushed to the metadata remote (`metadata_remote`) immediately so the backlog is reviewable and visible to the autonomous implementer. ## Brainstorm mode (default) @@ -34,19 +34,19 @@ The default path for any non-trivial new change. Five steps: 3. **Scan related context** — scan neighbouring changes (`active/` + recent `archive/`) and the ADR index to pre-fill `related`, `depends_on`, `adrs`, and — when the human names the change(s) whose work surfaced this one (or scan mode infers an origin) — `discovered_from` (informational, like `related:`; empty for deliberately planned work). In practice, do this quick read just *before* step 2 so the brainstorm is informed by neighbouring work; record the resulting `related`/`depends_on`/`adrs`/`discovered_from` after the design settles. -4. **Create the proposed change** — submit the record through one atomic transaction: the `change.create` operation (resolve argv from the capability catalog) with `--repo-dir .docket --request `. Its closed JSON request carries `title`, `type` (one configured `change_type` — `create` refuses an unknown or empty type, so no created change is ever left `untyped`, and there is no template comment to replace), `priority` (default `medium`), the PM-altitude `why`/`what_changes`/`out_of_scope` body distilled from the brainstorm (design detail lives in the linked spec, NOT here), the resolved `depends_on`/`related`/`adrs`/`discovered_from` from step 3, `stacked_on` when the work builds on another change's **unmerged** branch (set it and **read [stacked-changes.md](../docket-convention/references/stacked-changes.md) now (blocking)** first — stacking changes how it is built, merged, and closed out), and the stable `request_id` from step 1. The transaction allocates the id, derives the slug, serializes the canonical `status: proposed` record (`created`/`updated` = the commit's UTC date), renders its `## Artifacts` block, and re-renders the inline board — one metadata commit under an exact-lease push, returning the new `id`/`slug`/`path`. +4. **Create the proposed change** — submit the record through one atomic transaction: the `change.create` operation (resolve argv from the capability catalog) with `--repo-dir --request `. Its closed JSON request carries `title`, `type` (one configured `change_type` — `create` refuses an unknown or empty type, so no created change is ever left `untyped`, and there is no template comment to replace), `priority` (default `medium`), the PM-altitude `why`/`what_changes`/`out_of_scope` body distilled from the brainstorm (design detail lives in the linked spec, NOT here), the resolved `depends_on`/`related`/`adrs`/`discovered_from` from step 3, `stacked_on` when the work builds on another change's **unmerged** branch (set it and **read [stacked-changes.md](../docket-convention/references/stacked-changes.md) now (blocking)** first — stacking changes how it is built, merged, and closed out), and the stable `request_id` from step 1. The transaction allocates the id, derives the slug, serializes the canonical `status: proposed` record (`created`/`updated` = the commit's UTC date), renders its `## Artifacts` block, and re-renders the inline board — one metadata commit under an exact-lease push, returning the new `id`/`slug`/`path`. **Two draft-time scalars ride in the same request** — `auto_groomable` and `branch_prefix`. When the human says the change may be designed without them, send `auto_groomable: true` (so `docket-auto-groom` carries it to build-ready; `false` opts it out). When they name a branch prefix ("use the `hotfix/` prefix"), send it as typed in `branch_prefix` — the operation normalizes it and refuses an unusable value with `invalid-branch_prefix` before anything is written: show the human that finding and ask for a new value. Omit `auto_groomable` to leave the change not auto-groomable; omit `branch_prefix` to use ``. -5. **Groom to build-ready & land** — read the created record's `path` + exact **record revision** (blob object id) from the `status` operation (with `--json`), then apply the spec atomically: the `change.groom` operation with `--repo-dir .docket --request ` carrying the change id, the pinned `path` + `revision`, `outcome: spec`, the `spec_markdown` from step 2, and any owned proposal-section rewrites. In one metadata commit the transaction writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, re-renders the `## Artifacts` block, and re-renders the inline board (pushed under an exact-lease push) — **no separate Board pass**. A revision-mismatch / `contended` refusal writes nothing: re-sync (re-run the `repository.prepare` operation), re-read `path` + `revision`, retry. STOP. Never implements. To adjust a just-landed spec or its owned sections afterwards, run `docket-groom-next ` — its explicit-id path routes an already-groomed change to `change.groom` with `outcome: revise`; never re-run this skill or hand-edit the spec file. +5. **Groom to build-ready & land** — read the created record's `path` + exact **record revision** (blob object id) from the `status` operation (with `--json`), then apply the spec atomically: the `change.groom` operation with `--repo-dir --request ` carrying the change id, the pinned `path` + `revision`, `outcome: spec`, the `spec_markdown` from step 2, and any owned proposal-section rewrites. In one metadata commit the transaction writes the spec file, sets `spec:` + `updated:`, stamps the spec's `docket:backlink` block, re-renders the `## Artifacts` block, and re-renders the inline board (pushed under an exact-lease push) — **no separate Board pass**. A revision-mismatch / `contended` refusal writes nothing: re-sync (re-run the `repository.prepare` operation), re-read `path` + `revision`, retry. STOP. Never implements. To adjust a just-landed spec or its owned sections afterwards, run `docket-groom-next ` — its explicit-id path routes an already-groomed change to `change.groom` with `outcome: revise`; never re-run this skill or hand-edit the spec file. ## Trivial path -For a small mechanical change with no real design questions: skip the brainstorm — no spec, still build-ready. It follows Brainstorm mode's steps 1 (Allocate), 3 (Scan related context), and 4 (Create the proposed change) unchanged, then step 5 grooms with `outcome: trivial` instead of `spec`: the `change.groom` operation with `--repo-dir .docket --request ` with the pinned `path` + `revision` and an owned-section edit carrying the tightened body as the trivial rationale — the transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically (no spec file, no separate Board pass). Only step 2 (Brainstorm) is skipped. +For a small mechanical change with no real design questions: skip the brainstorm — no spec, still build-ready. It follows Brainstorm mode's steps 1 (Allocate), 3 (Scan related context), and 4 (Create the proposed change) unchanged, then step 5 grooms with `outcome: trivial` instead of `spec`: the `change.groom` operation with `--repo-dir --request ` with the pinned `path` + `revision` and an owned-section edit carrying the tightened body as the trivial rationale — the transaction sets `trivial: true` + `updated:`, re-renders the `## Artifacts` block, and re-renders the inline board atomically (no spec file, no separate Board pass). Only step 2 (Brainstorm) is skipped. ## Scan mode (opt-in) -Survey TODOs, deferred changes, known gaps, and the ADR backlog; emit several lightweight `proposed` STUBS in one pass — WITHOUT specs. Scan-stubs are NOT build-ready (no spec, not trivial) — the board calls this state `needs-grooming`; they form the "ideas to brainstorm" backlog `docket-groom-next` turns build-ready. Scan-stubs still carry a `type:` — every creation path writes one, so the untyped set only ever shrinks. Scan-stubs leave `auto_groomable` unset, so none is auto-groomable until a human sets `auto_groomable: true` on it. Kept opt-in so routine runs don't generate speculative noise. Emit each stub through its own `change.create` operation transaction (resolve argv from the capability catalog) with `--repo-dir .docket --request ` (title/type/priority + why/what/scope + a stable `request_id`; no spec). Each transaction allocates the id, renders the stub's `## Artifacts` block, and re-renders the inline board atomically, pushing to `origin/docket` under an exact-lease push — so there is **no separate Board pass**. +Survey TODOs, deferred changes, known gaps, and the ADR backlog; emit several lightweight `proposed` STUBS in one pass — WITHOUT specs. Scan-stubs are NOT build-ready (no spec, not trivial) — the board calls this state `needs-grooming`; they form the "ideas to brainstorm" backlog `docket-groom-next` turns build-ready. Scan-stubs still carry a `type:` — every creation path writes one, so the untyped set only ever shrinks. Scan-stubs leave `auto_groomable` unset, so none is auto-groomable until a human sets `auto_groomable: true` on it. Kept opt-in so routine runs don't generate speculative noise. Emit each stub through its own `change.create` operation transaction (resolve argv from the capability catalog) with `--repo-dir --request ` (title/type/priority + why/what/scope + a stable `request_id`; no spec). Each transaction allocates the id, renders the stub's `## Artifacts` block, and re-renders the inline board atomically, pushing to the metadata remote under an exact-lease push — so there is **no separate Board pass**. ## Proposed-kill sub-path diff --git a/skills/docket-review/SKILL.md b/skills/docket-review/SKILL.md index 00b2028b7..853bf470d 100644 --- a/skills/docket-review/SKILL.md +++ b/skills/docket-review/SKILL.md @@ -11,7 +11,7 @@ says. ## Scope -You touch **no docket metadata**: no `.docket/`, no metadata branch, no change file, no board, no +You touch **no docket metadata**: no metadata worktree, no metadata branch, no change file, no board, no ADR, no learnings ledger. You do not open, update, or comment on a PR. You read the feature branch and return findings — the controller that dispatched you owns every write that follows from them. diff --git a/skills/docket-status/SKILL.md b/skills/docket-status/SKILL.md index 5dbe2142c..2a3866aae 100644 --- a/skills/docket-status/SKILL.md +++ b/skills/docket-status/SKILL.md @@ -64,7 +64,7 @@ There is **no** separate board pass to key on: every board-authoritative typed m ## Completion barrier — observe the sweep to its terminal result -Starting the sweep command is not finishing it. If the shell tool's foreground window expires and the harness moves the still-running sweep to the background, that is a move to background, not completion — and not a failed sweep. You stay responsible for that exact task: keep the task identity the harness returned and collect that task's terminal result through the harness's native observation/wait mechanism, and never start a second shell watcher, never poll the output file's size, never sleep-and-tail, never re-run the sweep, and never return a success report while the process remains unobserved. An output file turning nonempty, metadata commits appearing on `origin/docket`, elapsed time, or some separate command succeeding are not completion signals. +Starting the sweep command is not finishing it. If the shell tool's foreground window expires and the harness moves the still-running sweep to the background, that is a move to background, not completion — and not a failed sweep. You stay responsible for that exact task: keep the task identity the harness returned and collect that task's terminal result through the harness's native observation/wait mechanism, and never start a second shell watcher, never poll the output file's size, never sleep-and-tail, never re-run the sweep, and never return a success report while the process remains unobserved. An output file turning nonempty, metadata commits appearing on the metadata remote, elapsed time, or some separate command succeeding are not completion signals. Only the sweep's actual terminal protocol-v1 envelope completes the command. Validate it and every entry: `protocol_version` `1`, `operation` `maintenance.sweep`, a `scope` equal to the one you requested, and each entry's closed disposition. Retain the original structured output — a harness result handle or a task-local output artifact — and extract the compact summary plus any problem entries in one read/parse rather than reopening the full output repeatedly; stdout is the JSON document, and any progress diagnostics belong on a separate channel. Only after that terminal validation run the post-sweep read: the `status` operation (resolve argv from the capability catalog) with `--json`. A read taken after a failed or unvalidated sweep is diagnostic only — label it as diagnostic; it can never authorize selection. From ebe95a7e5432fc1b7c1b65b2ded3e8767c721c96 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:26:58 -0400 Subject: [PATCH 14/20] fix(repository): private init re-run adopts the published dckt branch instead of republishing A re-run inside the same second rebuilt a byte-identical init root, and the create-only push reported the up-to-date ref as applied, so a finished private init flaked to applied instead of no-op. Private init now probes the store first and adopts a branch it already holds through the shared ownership verifier (extracted as adoptPublishedMetadataRoot), publishing only when the branch is absent. The re-run test pins commit dates to cover both the identical-root and the different-root case deterministically, and a new test proves a foreign store branch still refuses untouched. Also moves operational_config_sources_test.go onto testsupport.TempDir, which the real-process TempDir repoguard requires. --- .../app/operational_config_sources_test.go | 5 +- internal/app/repository_init.go | 73 +++++++++------- internal/app/repository_init_private.go | 30 +++++-- .../repository_private_integration_test.go | 87 +++++++++++++++---- 4 files changed, 139 insertions(+), 56 deletions(-) diff --git a/internal/app/operational_config_sources_test.go b/internal/app/operational_config_sources_test.go index 7461298a8..10bbd9c71 100644 --- a/internal/app/operational_config_sources_test.go +++ b/internal/app/operational_config_sources_test.go @@ -9,16 +9,17 @@ import ( "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/testsupport" ) // privateOperationalRepo is a primary worktree whose common dir carries the // private config. The global layer is pinned to an empty temp home. func privateOperationalRepo(t *testing.T) (gitcli.Repository, layout.Layout) { t.Helper() - home := t.TempDir() + home := testsupport.TempDir(t) t.Setenv("XDG_CONFIG_HOME", home) t.Setenv("HOME", home) - primary := t.TempDir() + primary := testsupport.TempDir(t) common := filepath.Join(primary, ".git") if err := os.MkdirAll(filepath.Join(common, layout.PrivateName), 0o755); err != nil { t.Fatal(err) diff --git a/internal/app/repository_init.go b/internal/app/repository_init.go index cd1f4b9bd..bda687e8f 100644 --- a/internal/app/repository_init.go +++ b/internal/app/repository_init.go @@ -366,45 +366,52 @@ func publishOrAdoptMetadataRoot(ctx context.Context, git *gitcli.Client, repo gi case gitcli.PushApplied: return root, true, nil case gitcli.PushLeaseLost: - // The ref already exists at outcome.Remote. Fetch the object so the shared - // ownership verifier can inspect its lineage locally at the reread tip, then - // adopt a verified init-equivalent lineage (descendants preserved) or refuse - // a migration-seeded, foreign, or unreadable branch. The create-only push - // never overwrote it, and adoption of the reread tip must not either. - if _, ferr := git.FetchBranch(ctx, repo, remote, metaRef); ferr != nil { - r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateConflict, "reading the published metadata branch", ferr) - return "", false, &r - } - own := verifyMetadataOwnership(ctx, git, repo, outcome.Remote, gitcli.ObjectID(sourceRevision), defaultBranch) - switch own.Shape { - case reposetup.RootUnknown: - // Incomplete or unreadable evidence: an external failure retaining the - // probe error, never a fabricated foreign or a silent adoption. - r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateConflict, "inspecting the published metadata branch", own.Err) - return "", false, &r - case reposetup.RootForeign: - r := initRefusal(reposetup.StateConflict, - "the remote docket branch is not a verified docket metadata branch; inspect and resolve it manually, then run `docket repository check`") - return "", false, &r - } - // Verified (RootParentless). Only an init-equivalent lineage — a native - // OpInitRoot seed or a receiptless legacy-bootstrap empty root — is - // init-adoptable; a migration-seeded lineage is recognized but refused, so a - // broadened proof never becomes permission to initialize. - if !initEquivalent(own) { - r := initRefusal(reposetup.StateConflict, - "the remote docket branch is an established migrated metadata branch; `docket repository init` cannot adopt it — run `docket repository check`") - return "", false, &r - } - // Adopt the reread remote tip: descendants preserved, never re-pushed, never - // reset to the seed. - return outcome.Remote, false, nil + // The ref already exists at outcome.Remote: adopt it or refuse it. + tip, refusal := adoptPublishedMetadataRoot(ctx, git, repo, remote, metaRef, outcome.Remote, sourceRevision, defaultBranch) + return tip, false, refusal default: r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateFresh, "publishing the metadata branch", errors.New("create-only push failed")) return "", false, &r } } +// adoptPublishedMetadataRoot adopts a metadata branch the remote already holds +// at remoteTip, or refuses it. It fetches the branch so the shared ownership +// verifier can inspect its lineage locally at that tip, then adopts a verified +// init-equivalent lineage (descendants preserved) or refuses a +// migration-seeded, foreign, or unreadable branch. It never pushes: adoption of +// the reread tip must not overwrite or reset the branch to the seed. +func adoptPublishedMetadataRoot(ctx context.Context, git *gitcli.Client, repo gitcli.Repository, remote gitcli.RemoteName, metaRef gitcli.RefName, remoteTip gitcli.ObjectID, sourceRevision, defaultBranch string) (gitcli.ObjectID, *RepositoryOpResult) { + if _, ferr := git.FetchBranch(ctx, repo, remote, metaRef); ferr != nil { + r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateConflict, "reading the published metadata branch", ferr) + return "", &r + } + own := verifyMetadataOwnership(ctx, git, repo, remoteTip, gitcli.ObjectID(sourceRevision), defaultBranch) + switch own.Shape { + case reposetup.RootUnknown: + // Incomplete or unreadable evidence: an external failure retaining the + // probe error, never a fabricated foreign or a silent adoption. + r := repositoryExternalFailure(OperationRepositoryInit, reposetup.StateConflict, "inspecting the published metadata branch", own.Err) + return "", &r + case reposetup.RootForeign: + r := initRefusal(reposetup.StateConflict, + "the remote docket branch is not a verified docket metadata branch; inspect and resolve it manually, then run `docket repository check`") + return "", &r + } + // Verified (RootParentless). Only an init-equivalent lineage — a native + // OpInitRoot seed or a receiptless legacy-bootstrap empty root — is + // init-adoptable; a migration-seeded lineage is recognized but refused, so a + // broadened proof never becomes permission to initialize. + if !initEquivalent(own) { + r := initRefusal(reposetup.StateConflict, + "the remote docket branch is an established migrated metadata branch; `docket repository init` cannot adopt it — run `docket repository check`") + return "", &r + } + // Adopt the reread remote tip: descendants preserved, never re-pushed, never + // reset to the seed. + return remoteTip, nil +} + // initEquivalent reports whether a verified lineage is init-adoptable: its // verified ROOT carries the empty tree — a native OpInitRoot seed, or the // receiptless legacy-bootstrap empty root. It tests the root's tree, never the diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 60b3cc0bb..6ad3bf34a 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -172,14 +172,32 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos "the dckt git remote already points at %s, not %s; resolve it by hand (docket never rewrites a remote)", got, want))) } - // 6. The metadata branch: created create-only, or a verified init lineage - // adopted at its tip (the second-clone path). + // 6. The metadata branch: a branch the store already holds is adopted at its + // tip when it is a verified init lineage (a re-run, or the second-clone path) + // and never republished; an absent one is created create-only. Probing first + // matters: a re-run inside the same second rebuilds a byte-identical root, + // and the create-only push reports that up-to-date ref as a success, so + // publishing would claim a branch this run did not create. metaRef := metadataRef(lay) - tip, createdBranch, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, remote, metaRef, sc.sourceRevision, sc.defaultBranch) - if refusal != nil { - return fail(*refusal) + published, err := d.Git.ProbeRemoteBranch(ctx, sc.repo, remote, metaRef) + if err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt metadata branch", err)) + } + var tip gitcli.ObjectID + if published.State == gitcli.RemoteRefFound { + adopted, refusal := adoptPublishedMetadataRoot(ctx, d.Git, sc.repo, remote, metaRef, published.Commit, sc.sourceRevision, sc.defaultBranch) + if refusal != nil { + return fail(*refusal) + } + tip = adopted + } else { + created, createdBranch, refusal := publishOrAdoptMetadataRoot(ctx, d.Git, sc.repo, remote, metaRef, sc.sourceRevision, sc.defaultBranch) + if refusal != nil { + return fail(*refusal) + } + tip = created + changed = changed || createdBranch } - changed = changed || createdBranch // 7. This clone's checkout under the store, hooks off. if err := os.MkdirAll(lay.CheckoutsDir, 0o755); err != nil { diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 045c0cbfc..f3ecd37f3 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -136,25 +136,59 @@ func TestIntegrationRepoSetupPrivateFreshInitCreatesNeutralLayout(t *testing.T) } } +// TestIntegrationRepoSetupPrivateRerunIsNoOp proves a re-run of a finished +// private init reports no-op whatever the clock does between the two runs. The +// commit dates are pinned so each case is deterministic: with the same dates the +// re-run would rebuild a byte-identical init root (two runs inside one second), +// and with a later date it would build a different one. Either way the re-run +// adopts the published branch instead of republishing it. func TestIntegrationRepoSetupPrivateRerunIsNoOp(t *testing.T) { - r, data := newPrivateInitRepo(t, nil) - if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { - t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) - } - res := r.runInitWith(t, InitOptions{}) - if res.Result != ResultNoOp { - t.Fatalf("re-run = %q (%s), want no-op", res.Result, res.HumanText()) - } - lay := privateLayoutOf(t, r.invocation, data) - prep := RunRepositoryPrepare(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation}, PrepareOptions{}) - if prep.Disposition != PrepareDispositionNoOp { - t.Fatalf("prepare disposition = %q (%s), want no-op", prep.Disposition, prep.HumanText()) - } - if prep.Context == nil || prep.Context.MetadataRemote != "dckt" || prep.Context.MetadataWorktreePath != lay.MetadataWorktree { - t.Errorf("prepare context = %+v, want MetadataRemote dckt and MetadataWorktreePath %q", prep.Context, lay.MetadataWorktree) + for _, tc := range []struct { + name string + first, rerun string + }{ + {name: "identical root within one second", first: "1700000000 +0000", rerun: "1700000000 +0000"}, + {name: "different root a second later", first: "1700000000 +0000", rerun: "1700000001 +0000"}, + } { + t.Run(tc.name, func(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + pinCommitDate(t, tc.first) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) + } + lay := privateLayoutOf(t, r.invocation, data) + tip := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt") + pinCommitDate(t, tc.rerun) + res := r.runInitWith(t, InitOptions{}) + if res.Result != ResultNoOp { + t.Fatalf("re-run = %q (%s), want no-op", res.Result, res.HumanText()) + } + if res.MetadataTip != tip { + t.Errorf("re-run MetadataTip = %q, want the published tip %q", res.MetadataTip, tip) + } + if got := runGit(t, lay.DefaultBareRemote, "rev-parse", "refs/heads/dckt"); got != tip { + t.Errorf("the store's dckt branch moved from %s to %s on re-run", tip, got) + } + prep := RunRepositoryPrepare(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation}, PrepareOptions{}) + if prep.Disposition != PrepareDispositionNoOp { + t.Fatalf("prepare disposition = %q (%s), want no-op", prep.Disposition, prep.HumanText()) + } + if prep.Context == nil || prep.Context.MetadataRemote != "dckt" || prep.Context.MetadataWorktreePath != lay.MetadataWorktree { + t.Errorf("prepare context = %+v, want MetadataRemote dckt and MetadataWorktreePath %q", prep.Context, lay.MetadataWorktree) + } + }) } } +// pinCommitDate fixes the author and committer dates of every commit the next +// git client builds; gitcli passes both variables through its sanitized +// environment, and newGitClient snapshots the environment per run. +func pinCommitDate(t *testing.T, date string) { + t.Helper() + t.Setenv("GIT_AUTHOR_DATE", date) + t.Setenv("GIT_COMMITTER_DATE", date) +} + // TestIntegrationRepoSetupPrivateInterruptedAfterConfigResumes proves the // presence-encoded-state recovery: a run that died right after writing // .git/dckt/ (so the repository reads as private) but before registering the @@ -234,6 +268,29 @@ func TestIntegrationRepoSetupPrivateRemoteURLConflictRefuses(t *testing.T) { } } +// TestIntegrationRepoSetupPrivateForeignStoreBranchRefuses proves the adopt +// path a private init takes for a dckt branch the store already holds verifies +// the branch first: a foreign branch refuses and stays byte-untouched, never +// adopted and never republished. +func TestIntegrationRepoSetupPrivateForeignStoreBranchRefuses(t *testing.T) { + r, _ := newPrivateInitRepo(t, nil) + store := filepath.Join(testsupport.TempDir(t), "store.git") + runGit(t, r.root, "init", "--bare", "-q", store) + runGit(t, r.writer, "push", "-q", store, "main:refs/heads/dckt") + foreign := runGit(t, store, "rev-parse", "refs/heads/dckt") + + res := r.runInitWith(t, InitOptions{Private: true, MetadataRemote: store}) + if res.Result != ResultInvalidState { + t.Fatalf("init over a foreign store branch = %q (%s), want invalid-state", res.Result, res.HumanText()) + } + if res.RepositoryState != string(reposetup.StateConflict) { + t.Errorf("RepositoryState = %q, want conflict", res.RepositoryState) + } + if got := runGit(t, store, "rev-parse", "refs/heads/dckt"); got != foreign { + t.Errorf("the store's foreign dckt branch moved from %s to %s", foreign, got) + } +} + func TestIntegrationRepoSetupPrivateMetadataRemoteFlag(t *testing.T) { r, data := newPrivateInitRepo(t, nil) backup := filepath.Join(testsupport.TempDir(t), "backup.git") From 827f9a220e26be31ee5773b01f75e74104a449dc Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:44:39 -0400 Subject: [PATCH 15/20] fix(repository): a same-path private re-clone replaces its stale checkout instead of wedging Review finding (blocker): deleting a private clone and re-cloning it at the same path left the repository stuck. The clone id depends only on the path, so the re-clone resolved the old checkout under the surviving store, whose .git file names a gitdir the new clone no longer has. The fact gatherer read it as foreign, init's `git worktree add` failed on the non-empty directory (and its "re-run init" recovery failed the same way), and the orphan scan skips this clone's own path, so repair never pruned it. This clone's own checkout is now stale when its .git file names a gitdir that no longer exists (staleOwnCheckout). The gatherer reads a stale checkout as absent, and private init and prepare's attach remove it (re-verified on the copy they act on; a live, absent, or unreadable checkout is never touched) before attaching. TestIntegrationRepoSetupPrivateRecloneSamePathReattaches covers init --private, delete, same-path re-clone, init, prepare, plus prepare over a checkout that lost its gitdir. Each of the three sites was mutation-tested: removing any one reddens the test. --- internal/app/repository_facts.go | 8 ++- internal/app/repository_init_private.go | 6 +- internal/app/repository_prepare.go | 4 ++ internal/app/repository_private_findings.go | 33 +++++++++++ .../repository_private_integration_test.go | 58 +++++++++++++++++++ 5 files changed, 107 insertions(+), 2 deletions(-) diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index 14c7b3b1f..d33b1a351 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -310,7 +310,13 @@ func gatherRepoFacts(ctx context.Context, p setupProber, in repoFactsInput) (rep // The metadata worktree path: absent, a correctly registered owned worktree // on the metadata branch, or a foreign directory / conflicting registration. - f.DocketWorktree = docketWorktreeFact(ctx, p, in.repo, in.layout.MetadataWorktree, metaRef) + // A stale private checkout (its gitdir gone, e.g. after a same-path re-clone) + // is no worktree at all: it reads as absent, and attach replaces it. + if staleOwnCheckout(in.layout) { + f.DocketWorktree = reposetup.WorktreeFact{Presence: reposetup.PresenceAbsent} + } else { + f.DocketWorktree = docketWorktreeFact(ctx, p, in.repo, in.layout.MetadataWorktree, metaRef) + } return f, sc } diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 6ad3bf34a..efe984fe2 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -199,10 +199,14 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos changed = changed || createdBranch } - // 7. This clone's checkout under the store, hooks off. + // 7. This clone's checkout under the store, hooks off. A stale checkout at + // this clone's path (a same-path re-clone's leftover) is replaced first. if err := os.MkdirAll(lay.CheckoutsDir, 0o755); err != nil { return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "creating the checkouts folder", err)) } + if err := removeStaleOwnCheckout(lay); err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "replacing the stale metadata checkout", err)) + } createdWorktree, err := ensureMetadataWorktree(ctx, d.Git, sc.repo, lay.MetadataWorktree, metaRef, tip) if err != nil { return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "attaching the metadata checkout", err)) diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index c3dbb2588..085faab2d 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -704,6 +704,10 @@ func prepareExecute(ctx context.Context, git *gitcli.Client, sc setupContext, ve metaRef := metadataRef(sc.layout) switch verdict.action { case prepareActionAttach: + // A stale private checkout at the path reads as absent; replace it. + if err := removeStaleOwnCheckout(sc.layout); err != nil { + return err + } return prepareAttachFresh(ctx, git, sc.repo, worktreePath, metaRef, verdict) case prepareActionFastForward: return git.FastForwardCheckedOutBranch(ctx, worktreePath, metaRef, gitcli.ObjectID(verdict.observedTip), gitcli.ObjectID(verdict.targetRev)) diff --git a/internal/app/repository_private_findings.go b/internal/app/repository_private_findings.go index a986b4770..5b64e3652 100644 --- a/internal/app/repository_private_findings.go +++ b/internal/app/repository_private_findings.go @@ -110,6 +110,39 @@ func checkoutOrphaned(dir string) (bool, error) { } } +// staleOwnCheckout reports whether this clone's own private checkout path holds +// a stale checkout: one whose .git file names a gitdir that no longer exists. +// Deleting a private clone and re-cloning it at the same path leaves exactly +// that (the clone id depends only on the path, and the store survives). A +// stale checkout is no live worktree of anything, so it reads as absent and is +// replaced on attach. An unreadable .git file or a probe error is not proven +// stale. A shared layout never has one. +func staleOwnCheckout(lay layout.Layout) bool { + if lay.Mode != layout.Private || lay.MetadataWorktree == "" { + return false + } + stale, err := checkoutOrphaned(lay.MetadataWorktree) + return err == nil && stale +} + +// removeStaleOwnCheckout removes this clone's own private checkout when it is +// proven stale now, re-verified on the copy it acts on (learning +// decide-and-act-on-the-same-copy): a live checkout, an absent one, or one whose +// .git file cannot be read is left alone. Its data lives in the metadata remote. +func removeStaleOwnCheckout(lay layout.Layout) error { + if lay.Mode != layout.Private || lay.MetadataWorktree == "" { + return nil + } + stale, err := checkoutOrphaned(lay.MetadataWorktree) + if err != nil || !stale { + return err + } + if err := os.RemoveAll(lay.MetadataWorktree); err != nil { + return fmt.Errorf("removing the stale checkout %s: %w", lay.MetadataWorktree, err) + } + return nil +} + // checkoutGitdir parses the gitdir a checkout's .git file names, resolving a // relative path against the checkout. ok is false when the file cannot be // read or names no gitdir. diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index f3ecd37f3..fec48f203 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -536,6 +536,64 @@ func TestIntegrationRepoSetupPrivateBothLocalConfigsRefuse(t *testing.T) { } } +// TestIntegrationRepoSetupPrivateRecloneSamePathReattaches deletes a private +// clone and re-clones it at the same path. The store survives and the clone id +// is the same, so the old checkout still sits at the new clone's checkout path, +// its .git file naming a gitdir the new clone no longer has. Init must treat +// that stale checkout as replaceable and attach a fresh one; prepare must do +// the same when a checkout loses its registration. +func TestIntegrationRepoSetupPrivateRecloneSamePathReattaches(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) + } + checkout := privateLayoutOf(t, r.invocation, data).MetadataWorktree + if err := os.RemoveAll(r.invocation); err != nil { + t.Fatal(err) + } + runGit(t, r.root, "clone", "-q", r.origin, r.invocation) + gitIdentity(t, r.invocation) + if got := privateLayoutOf(t, r.invocation, data).MetadataWorktree; got != checkout { + t.Fatalf("re-clone resolved checkout %q, want the same path %q", got, checkout) + } + if _, err := os.Stat(checkout); err != nil { + t.Fatalf("the old checkout should survive the clone deletion: %v", err) + } + + res := r.runInitWith(t, InitOptions{Private: true}) + if res.Result != ResultApplied { + t.Fatalf("re-clone init = %q (%s), want applied", res.Result, res.HumanText()) + } + wts := runGit(t, r.invocation, "worktree", "list", "--porcelain") + if !strings.Contains(wts, "worktree "+checkout+"\n") { + t.Fatalf("re-clone init did not attach %s:\n%s", checkout, wts) + } + d := SetupDeps{Git: newGitClient(t), RepoDir: r.invocation} + if prep := RunRepositoryPrepare(context.Background(), d, PrepareOptions{}); prep.Disposition != PrepareDispositionNoOp { + t.Fatalf("prepare after re-clone init = %q (%s), want no-op", prep.Disposition, prep.HumanText()) + } + + // The checkout loses its registration (its gitdir is gone): prepare replaces + // the stale checkout and attaches a live one. + gitdir, ok := checkoutGitdir(checkout) + if !ok { + t.Fatalf("cannot read the gitdir of %s", checkout) + } + if err := os.RemoveAll(gitdir); err != nil { + t.Fatal(err) + } + prep := RunRepositoryPrepare(context.Background(), d, PrepareOptions{}) + if prep.Disposition != PrepareDispositionApplied || prep.Context == nil || prep.Context.MetadataWorktreePath != checkout { + t.Fatalf("prepare over a stale checkout = %q (%s), want %s attached", prep.Disposition, prep.HumanText(), checkout) + } + if prep = RunRepositoryPrepare(context.Background(), d, PrepareOptions{}); prep.Disposition != PrepareDispositionNoOp { + t.Fatalf("prepare re-run = %q (%s), want no-op", prep.Disposition, prep.HumanText()) + } + if orphans := findingsWithCode(checkIn(t, r.invocation).Findings, FindingOrphanedCheckout); len(orphans) != 0 { + t.Errorf("check reports orphans after the re-attach: %+v", orphans) + } +} + // TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned moves a private clone: // its old checkout under the store now points at a gitdir that no longer // exists, so check reports it orphaned and repair removes it. From 7d6cffc8626d623f65be55ba3f8cc6b8c576f01b Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:47:10 -0400 Subject: [PATCH 16/20] fix(repository): a flagless private init re-run keeps the --metadata-remote dckt remote Review finding: runPrivateInit fell back to the default bare store whenever no --metadata-remote flag was given, so re-running init on a repository set up with --metadata-remote created a stray empty default store and then refused the healthy repository with a false dckt-remote conflict. A repository that already detected private now keeps its configured dckt remote URL, and only a missing remote creates the default store. --- internal/app/repository_init_private.go | 21 ++++++++---- .../repository_private_integration_test.go | 32 +++++++++++++++++++ 2 files changed, 46 insertions(+), 7 deletions(-) diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index efe984fe2..638ef129f 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -143,9 +143,20 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos return fail(repositoryInternalFailure(OperationRepositoryInit, cls.State, "resolving the private layout", errors.New("the repository does not detect as private after the config write"))) } - // 4. The bare remote: the flag URL as given, or the default store, created - // when absent. A flag URL never creates the default store. + // 4. The bare remote: the flag URL as given; else, on a repository that + // already detected private before this run, the dckt remote it configured + // (a flagless re-run after --metadata-remote); else the default store, + // created when absent. Only that last case ever creates the default store. + remote := metadataRemote(lay) + got, err := d.Git.RemoteURL(ctx, sc.repo, remote) + unconfigured := isRemoteUnconfigured(err) + if err != nil && !unconfigured { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt git remote", err)) + } want := o.MetadataRemote + if want == "" && !unconfigured && sc.layout.Mode == layout.Private { + want = got + } if want == "" { want = lay.DefaultBareRemote _, statErr := os.Stat(want) @@ -157,16 +168,12 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos // 5. The dckt git remote: added when absent, kept when it already points at // want, refused otherwise. Docket never rewrites a remote. - remote := metadataRemote(lay) - got, err := d.Git.RemoteURL(ctx, sc.repo, remote) switch { - case isRemoteUnconfigured(err): + case unconfigured: if aerr := d.Git.AddRemote(ctx, sc.repo, remote, want); aerr != nil { return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "adding the dckt git remote", aerr)) } changed = true - case err != nil: - return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt git remote", err)) case got != want: return fail(initRefusal(reposetup.StateConflict, fmt.Sprintf( "the dckt git remote already points at %s, not %s; resolve it by hand (docket never rewrites a remote)", got, want))) diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index fec48f203..6baef2f58 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -311,6 +311,38 @@ func TestIntegrationRepoSetupPrivateMetadataRemoteFlag(t *testing.T) { } } +// TestIntegrationRepoSetupPrivateMetadataRemoteRerunIsNoOp proves a flagless +// re-run on a repository set up with --metadata-remote keeps the configured +// dckt remote: it reports no-op, never creates the default store, and never +// refuses a healthy repository with a false remote conflict. +func TestIntegrationRepoSetupPrivateMetadataRemoteRerunIsNoOp(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + backup := filepath.Join(testsupport.TempDir(t), "backup.git") + runGit(t, r.root, "init", "--bare", "-q", backup) + if res := r.runInitWith(t, InitOptions{Private: true, MetadataRemote: backup}); res.Result != ResultApplied { + t.Fatalf("first init = %q (%s), want applied", res.Result, res.HumanText()) + } + tip := runGit(t, backup, "rev-parse", "refs/heads/dckt") + + res := r.runInitWith(t, InitOptions{}) + if res.Result != ResultNoOp { + t.Fatalf("flagless re-run = %q (%s), want no-op", res.Result, res.HumanText()) + } + if res.RepositoryState != string(reposetup.StateHealthy) { + t.Errorf("RepositoryState = %q, want healthy", res.RepositoryState) + } + if res.MetadataTip != tip { + t.Errorf("re-run MetadataTip = %q, want the published tip %q", res.MetadataTip, tip) + } + if url := runGit(t, r.invocation, "config", "--get", "remote.dckt.url"); url != backup { + t.Errorf("dckt remote URL = %q, want the flag %q", url, backup) + } + lay := privateLayoutOf(t, r.invocation, data) + if _, err := os.Stat(lay.DefaultBareRemote); !os.IsNotExist(err) { + t.Errorf("default bare remote %s was created on re-run (err=%v)", lay.DefaultBareRemote, err) + } +} + func TestIntegrationRepoSetupInitModeFlagsRefuseSwitch(t *testing.T) { r := newInitRepo(t, defaultSetupYML, nil) if res := r.runInit(t); res.Result != ResultApplied { From 471a29c24ba6ae0df0dd371d5bd7f13eadad16c7 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:49:19 -0400 Subject: [PATCH 17/20] fix(repository): private init refuses beside a .docket.local.yml before any write MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review finding: runPrivateInit never checked for /.docket.local.yml, so it wrote .git/dckt/config.yml, the store, remote, branch, and checkout, and only then failed with ConflictingLocalConfigError — after which every operation, the recommended init re-run included, refused. Init now refuses up front with the same both-paths unsupported-config refusal, writing nothing. --- internal/app/repository_init_private.go | 17 +++++++ .../repository_private_integration_test.go | 51 +++++++++++++++++++ 2 files changed, 68 insertions(+) diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 638ef129f..53c25ab83 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -110,6 +110,23 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos common := sc.repo.CommonDir changed := false + // 0. A .docket.local.yml beside the private config refuses before any write: + // once .git/dckt/ exists every operation, an init re-run included, reads the + // repository as private and refuses on that conflict, so writing first would + // wedge it. The refusal is the same one config.LoadPrivateRepositorySource + // gives every private operation. + if _, err := config.LoadPrivateRepositorySource(common, sc.repo.PrimaryWorktree); err != nil { + var conflict *config.ConflictingLocalConfigError + if errors.As(err, &conflict) { + out := newRepositoryOpResult(OperationRepositoryInit, ResultUnsupportedConfig, RepositoryOpResult{ + RepositoryState: string(cls.State), + }) + out.human = fmt.Sprintf("%s: %s: %s", OperationRepositoryInit, ResultUnsupportedConfig, err.Error()) + return out + } + return repositoryExternalFailure(OperationRepositoryInit, cls.State, "inspecting the repository config", err) + } + // 1. The neutral ignore block in the clone-local exclude file. A malformed // block refuses with the file untouched. wroteExclude, err := ensureExcludeFile(filepath.Join(common, "info", "exclude")) diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 6baef2f58..156202b87 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -568,6 +568,57 @@ func TestIntegrationRepoSetupPrivateBothLocalConfigsRefuse(t *testing.T) { } } +// TestIntegrationRepoSetupPrivateInitRefusesBesideLocalConfig proves a private +// init over a repository that already carries a .docket.local.yml refuses before +// any write, with the same both-paths unsupported-config refusal every private +// operation gives: writing .git/dckt/ first would turn the repository private +// and leave every later operation, the recommended init re-run included, +// refusing. +func TestIntegrationRepoSetupPrivateInitRefusesBesideLocalConfig(t *testing.T) { + // The local file is ignored, as a shared repository's usually is, so the + // primary worktree reads clean. + r, data := newPrivateInitRepo(t, map[string]string{".gitignore": ".docket.local.yml\n"}) + local := filepath.Join(r.invocation, ".docket.local.yml") + if err := os.WriteFile(local, []byte("learnings: {cap: 250}\n"), 0o644); err != nil { + t.Fatal(err) + } + gitDir := r.gitDir(t) + excludePath := filepath.Join(gitDir, "info", "exclude") + excludeBefore, _ := os.ReadFile(excludePath) + + res := r.runInitWith(t, InitOptions{Private: true}) + if res.Result != ResultUnsupportedConfig { + t.Fatalf("init = %q (%s), want unsupported-config", res.Result, res.HumanText()) + } + top, err := filepath.EvalSymlinks(r.invocation) + if err != nil { + t.Fatal(err) + } + wantLocal, wantPrivate := filepath.Join(top, ".docket.local.yml"), filepath.Join(top, ".git", "dckt", "config.yml") + if text := res.HumanText(); !strings.Contains(text, wantLocal) || !strings.Contains(text, wantPrivate) { + t.Errorf("refusal %q must name both %s and %s", text, wantLocal, wantPrivate) + } + if strings.Contains(res.HumanText(), privateInitRecovery) { + t.Errorf("refusal %q carries the post-write recovery text; nothing was written", res.HumanText()) + } + if _, err := os.Lstat(filepath.Join(gitDir, "dckt")); !os.IsNotExist(err) { + t.Errorf(".git/dckt exists after the refusal (err=%v); init must write nothing", err) + } + if _, err := tryGit(r.invocation, "config", "--get", "remote.dckt.url"); err == nil { + t.Error("a dckt remote was added; init must write nothing") + } + lay := privateLayoutOf(t, r.invocation, data) + if _, err := os.Lstat(lay.StoreDir); !os.IsNotExist(err) { + t.Errorf("store %s exists after the refusal (err=%v); init must write nothing", lay.StoreDir, err) + } + if excludeAfter, _ := os.ReadFile(excludePath); string(excludeAfter) != string(excludeBefore) { + t.Errorf(".git/info/exclude changed to %q; init must write nothing", excludeAfter) + } + if r.remoteBranchExists(t, "docket") || r.remoteBranchExists(t, "dckt") { + t.Error("origin gained a docket or dckt branch") + } +} + // TestIntegrationRepoSetupPrivateRecloneSamePathReattaches deletes a private // clone and re-clones it at the same path. The store survives and the clone id // is the same, so the old checkout still sits at the new clone's checkout path, From 50750d70a75329fa84b41838c7e55866728e1cd6 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:51:19 -0400 Subject: [PATCH 18/20] fix(repository): private health remedies name prepare, not the refusing migrate Review finding: conditionFinding's missing-attachment remedies told a private repository (e.g. a moved clone) to run `docket repository migrate`, which refuses every private repository, and repairApplied's local-sync note named `.docket` even when the metadata worktree is the private checkout. Private repositories now name `docket repository prepare` (it attaches an absent private checkout and its local branch, idempotently), and the repair note names the resolved metadata worktree. Shared text is byte-identical. --- internal/app/repository_facts.go | 12 ++++++-- internal/app/repository_repair.go | 13 +++++---- internal/app/repository_repair_test.go | 24 ++++++++++++++-- internal/reposetup/health.go | 14 +++++++-- internal/reposetup/health_test.go | 39 ++++++++++++++++++++++++++ 5 files changed, 89 insertions(+), 13 deletions(-) diff --git a/internal/app/repository_facts.go b/internal/app/repository_facts.go index d33b1a351..36fbf9eca 100644 --- a/internal/app/repository_facts.go +++ b/internal/app/repository_facts.go @@ -218,10 +218,16 @@ type repoFactsInput struct { // private. func applyLayoutFacts(f *reposetup.Facts, l layout.Layout) { f.Private = l.Mode == layout.Private - f.MetadataWorktreeRef = layout.SharedWorktreeDir - if f.Private { - f.MetadataWorktreeRef = l.MetadataWorktree + f.MetadataWorktreeRef = metadataWorktreeRef(l) +} + +// metadataWorktreeRef is how user-facing text names the layout's metadata +// worktree: the shared `.docket` spelling, or the resolved private checkout path. +func metadataWorktreeRef(l layout.Layout) string { + if l.Mode == layout.Private { + return l.MetadataWorktree } + return layout.SharedWorktreeDir } // gatherRepoFacts is the ONE set of Git topology probes behind both the diff --git a/internal/app/repository_repair.go b/internal/app/repository_repair.go index 19a20bb74..775c55ea3 100644 --- a/internal/app/repository_repair.go +++ b/internal/app/repository_repair.go @@ -157,7 +157,7 @@ func RunRepositoryRepair(ctx context.Context, d SetupDeps, o RepairOptions) Repo } plan.orphans = pruned if len(plan.files) == 0 { - return repairApplied("", metadataTip, plan) + return repairApplied("", metadataTip, plan, sc.layout) } return executeRepositoryRepair(ctx, d.Git, sc, metadataTip, plan) } @@ -349,7 +349,7 @@ func executeRepositoryRepair(ctx context.Context, git *gitcli.Client, sc setupCo if rev.Commit != commit { return repairContended(string(rev.Commit), metadataTip) } - return repairApplied(string(commit), metadataTip, plan) + return repairApplied(string(commit), metadataTip, plan, sc.layout) } // splitDerivedFindings partitions derived-view findings into the mechanically @@ -494,14 +494,15 @@ func repairConfirmationRequired(sc setupContext, metadataTip string, plan reposi } // repairApplied is the success document: new and prior tips, the repaired file -// set, and the local sync remedy (the remote advanced; .docket fast-forwards on -// the next `docket repository prepare`), plus any orphaned checkouts removed. +// set, and the local sync remedy (the remote advanced; the layout's metadata +// worktree — `.docket`, or the private checkout — fast-forwards on the next +// `docket repository prepare`), plus any orphaned checkouts removed. // An orphan-only repair publishes nothing (newTip "") and needs no local sync. -func repairApplied(newTip, priorTip string, plan repositoryRepairPlan) RepositoryRepairResult { +func repairApplied(newTip, priorTip string, plan repositoryRepairPlan, l layout.Layout) RepositoryRepairResult { var pending []string if len(plan.files) > 0 { pending = []string{ - "fast-forward your local .docket metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision", + fmt.Sprintf("fast-forward your local %s metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision", metadataWorktreeRef(l)), } } out := newRepairResult(ResultApplied, RepositoryRepairResult{ diff --git a/internal/app/repository_repair_test.go b/internal/app/repository_repair_test.go index 7e15e46ae..4ba37116c 100644 --- a/internal/app/repository_repair_test.go +++ b/internal/app/repository_repair_test.go @@ -193,7 +193,7 @@ func TestRepairConfirmationRequiredNamesYes(t *testing.T) { // TestRepairAppliedNamesRevisionAndPending proves the applied document names the // new and prior tips, the file set, and the `docket repository prepare` sync. func TestRepairAppliedNamesRevisionAndPending(t *testing.T) { - out := repairApplied("newtip", "priortip", repositoryRepairPlan{files: []string{"docs/changes/BOARD.md"}}) + out := repairApplied("newtip", "priortip", repositoryRepairPlan{files: []string{"docs/changes/BOARD.md"}}, layout.Layout{Mode: layout.Shared}) if out.Result != ResultApplied || out.MetadataTip != "newtip" || out.SourceRevision != "priortip" { t.Fatalf("applied = %+v", out) } @@ -202,6 +202,26 @@ func TestRepairAppliedNamesRevisionAndPending(t *testing.T) { } } +// TestRepairAppliedPendingNamesResolvedWorktree proves the local-sync note names +// the layout's metadata worktree: `.docket` byte-for-byte when shared, the +// resolved private checkout path (never `.docket`) when private. +func TestRepairAppliedPendingNamesResolvedWorktree(t *testing.T) { + const store = "/data/docket/store/proj-abc/checkout" + plan := repositoryRepairPlan{files: []string{"docs/changes/BOARD.md"}} + shared := repairApplied("newtip", "priortip", plan, layout.Layout{Mode: layout.Shared}) + if want := []string{"fast-forward your local .docket metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision"}; strings.Join(shared.PendingLocal, "|") != strings.Join(want, "|") { + t.Errorf("shared PendingLocal = %q, want %q", shared.PendingLocal, want) + } + private := repairApplied("newtip", "priortip", plan, layout.Layout{Mode: layout.Private, MetadataWorktree: store}) + got := strings.Join(private.PendingLocal, " ") + if want := "fast-forward your local " + store + " metadata worktree: re-run `docket repository prepare` to sync it to the repaired metadata revision"; got != want { + t.Errorf("private PendingLocal = %q, want %q", got, want) + } + if strings.Contains(got+private.HumanText(), ".docket") { + t.Errorf("private repair note names .docket: %q", got+private.HumanText()) + } +} + // TestRepairNoOpListsManualReview proves nothing-repairable is an idempotent // no-op that still surfaces the manual-review list. func TestRepairNoOpListsManualReview(t *testing.T) { @@ -246,7 +266,7 @@ func TestRepairResultJSONFieldNames(t *testing.T) { derived: []reposetup.DerivedFinding{{Code: reposetup.CodeBoardStale, Path: "docs/changes/BOARD.md", Repairable: true}}, manual: []string{"[x] y: z"}, files: []string{"docs/changes/BOARD.md", repairArchivedPath}, - }) + }, layout.Layout{Mode: layout.Shared}) raw, err := json.Marshal(out) if err != nil { t.Fatalf("marshal: %v", err) diff --git a/internal/reposetup/health.go b/internal/reposetup/health.go index 3e0b3b4ad..a1b468996 100644 --- a/internal/reposetup/health.go +++ b/internal/reposetup/health.go @@ -450,6 +450,16 @@ func worktreeInspectable(f Facts) bool { return f.DocketWorktree.Presence == PresencePresent && !f.DocketWorktree.Foreign } +// attachmentRestorer names the idempotent command that restores a missing local +// metadata attachment: migrate on a shared repository; prepare on a private one, +// where migrate refuses and prepare attaches the absent private checkout. +func attachmentRestorer(f Facts) string { + if f.Private { + return "prepare" + } + return "migrate" +} + // conditionFinding builds the supplemental finding for one unmet condition, // or nil when a missing prerequisite's own finding already represents it // (explanatory grouping, never permission to drop an unexplained failure). @@ -478,7 +488,7 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { Code: "local-metadata-missing", Severity: SeverityError, Message: "No local docket branch exists for the present remote docket branch.", - Remedy: "Run `docket repository migrate` to restore the local metadata attachment; it is idempotent.", + Remedy: fmt.Sprintf("Run `docket repository %s` to restore the local metadata attachment; it is idempotent.", attachmentRestorer(f)), } } return &Finding{ @@ -494,7 +504,7 @@ func conditionFinding(cond HealthCondition, f Facts) *Finding { Severity: SeverityError, Ref: f.MetadataWorktreeRef, Message: fmt.Sprintf("The %s metadata worktree is missing; its registration, cleanliness, and hooks state cannot be established until it exists.", f.MetadataWorktreeRef), - Remedy: fmt.Sprintf("Run `docket repository migrate` to restore the %s worktree attachment; it is idempotent.", f.MetadataWorktreeRef), + Remedy: fmt.Sprintf("Run `docket repository %s` to restore the %s worktree attachment; it is idempotent.", attachmentRestorer(f), f.MetadataWorktreeRef), } } return &Finding{ diff --git a/internal/reposetup/health_test.go b/internal/reposetup/health_test.go index 88b97f3dc..d9cb74d98 100644 --- a/internal/reposetup/health_test.go +++ b/internal/reposetup/health_test.go @@ -1151,3 +1151,42 @@ func TestPrivateIgnoreFindingNamesExcludeFile(t *testing.T) { t.Errorf("remedy = %q, want the init remedy", fnd.Remedy) } } + +// TestAttachmentRemedyFollowsLayout proves the missing-attachment remedies name +// the command that restores the attachment in each layout: shared repositories +// keep the idempotent migrate remedy byte-for-byte, while a private repository +// (where migrate refuses) names `docket repository prepare`, which attaches an +// absent private checkout, and never migrate. +func TestAttachmentRemedyFollowsLayout(t *testing.T) { + const store = "/data/docket/store/proj-abc/checkout" + for _, tc := range []struct { + name string + cond HealthCondition + private bool + mutate func(*Facts) + want string + }{ + {"shared worktree", CondWorktreePresent, false, func(f *Facts) { f.DocketWorktree.Presence = PresenceAbsent }, + "Run `docket repository migrate` to restore the .docket worktree attachment; it is idempotent."}, + {"shared local metadata", CondLocalMetadataPresent, false, func(f *Facts) { f.LocalMetadata = BranchFact{Presence: PresenceAbsent} }, + "Run `docket repository migrate` to restore the local metadata attachment; it is idempotent."}, + {"private worktree", CondWorktreePresent, true, func(f *Facts) { f.DocketWorktree.Presence = PresenceAbsent }, + "Run `docket repository prepare` to restore the " + store + " worktree attachment; it is idempotent."}, + {"private local metadata", CondLocalMetadataPresent, true, func(f *Facts) { f.LocalMetadata = BranchFact{Presence: PresenceAbsent} }, + "Run `docket repository prepare` to restore the local metadata attachment; it is idempotent."}, + } { + f := healthyFacts() + if tc.private { + f.Private = true + f.MetadataWorktreeRef = store + } + tc.mutate(&f) + fnd := conditionFinding(tc.cond, f) + if fnd == nil { + t.Fatalf("%s: no finding", tc.name) + } + if fnd.Remedy != tc.want { + t.Errorf("%s: remedy = %q, want %q", tc.name, fnd.Remedy, tc.want) + } + } +} From 8d9ed9b4af1b665d7b8f8befd05ae6295fae217e Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:55:15 -0400 Subject: [PATCH 19/20] fix(repository): private init refuses a default store recorded for a different origin Review finding: layout.OwnerRepo collapses distinct origins (acme/web-app vs acme-web/app, or one owner/repo on two hosts) onto one - store name, and adoptPublishedMetadataRoot accepted that store's init root from any repository, so an unrelated repository silently adopted another's backlog. The default store now records the creating repository's origin URL in origin-url beside remote.git. Private init refuses (invalid-state/conflict, before creating the store, adding the dckt remote, or attaching a checkout) when the recorded origin names a different repository, and points at --metadata-remote. A store with no record is adopted and gains one. A --metadata-remote URL is not checked. --- internal/app/repository_init_private.go | 95 ++++++++++++++++++- internal/app/repository_init_private_test.go | 26 +++++ .../repository_private_integration_test.go | 74 +++++++++++++++ 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 internal/app/repository_init_private_test.go diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 53c25ab83..64fd4818b 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -174,14 +174,44 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos if want == "" && !unconfigured && sc.layout.Mode == layout.Private { want = got } - if want == "" { + createStore := want == "" + if createStore { want = lay.DefaultBareRemote + } + // The default store's name is derived from origin's URL and can collide + // across distinct origins, so the store records the origin that created it + // and a repository with a different origin refuses before attaching to it. + var recordOrigin func() (bool, error) + if want == lay.DefaultBareRemote { + origin, oerr := d.Git.RemoteURL(ctx, sc.repo, originRemote) + if oerr != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading origin's URL", oerr)) + } + record, rerr := checkStoreOrigin(lay.StoreDir, origin) + if rerr != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the metadata store's origin record", rerr)) + } + if record.foreign != "" { + return fail(initRefusal(reposetup.StateConflict, fmt.Sprintf( + "the metadata store %s belongs to origin %s, not this repository's origin %s (both derive the store name %s); re-run `docket repository init --metadata-remote ` with a bare repository of this repository's own", + lay.StoreDir, record.foreign, origin, filepath.Base(lay.StoreDir)))) + } + recordOrigin = record.write + } + if createStore { _, statErr := os.Stat(want) if err := d.Git.InitBare(ctx, want); err != nil { return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "creating the bare metadata store", err)) } changed = changed || os.IsNotExist(statErr) } + if recordOrigin != nil { + wrote, werr := recordOrigin() + if werr != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "recording the metadata store's origin", werr)) + } + changed = changed || wrote + } // 5. The dckt git remote: added when absent, kept when it already points at // want, refused otherwise. Docket never rewrites a remote. @@ -296,6 +326,69 @@ func ensureExcludeFile(path string) (bool, error) { return true, nil } +// storeOriginRecordName is the file beside the default store's remote.git that +// records the origin URL of the repository that created the store. +const storeOriginRecordName = "origin-url" + +// storeOriginCheck is checkStoreOrigin's verdict: foreign names the recorded +// origin when it is not this repository's, and write records this repository's +// origin when the store has no record yet. +type storeOriginCheck struct { + foreign string + write func() (bool, error) +} + +// checkStoreOrigin reads the default store's origin record under storeDir. A +// record naming a different origin is foreign; a matching one needs no write; an +// absent one (a new store, or one created before the record existed) is adopted +// and its write records origin once the store directory exists. +func checkStoreOrigin(storeDir, origin string) (storeOriginCheck, error) { + path := filepath.Join(storeDir, storeOriginRecordName) + b, err := os.ReadFile(path) + switch { + case err == nil: + if recorded := strings.TrimSpace(string(b)); !sameOrigin(recorded, origin) { + return storeOriginCheck{foreign: recorded}, nil + } + return storeOriginCheck{write: func() (bool, error) { return false, nil }}, nil + case !os.IsNotExist(err): + return storeOriginCheck{}, err + } + return storeOriginCheck{write: func() (bool, error) { + if _, serr := os.Stat(storeDir); serr != nil { + if os.IsNotExist(serr) { + return false, nil // no store to record (a flagless re-run whose store is gone) + } + return false, serr + } + return true, os.WriteFile(path, []byte(strings.TrimSpace(origin)+"\n"), 0o644) + }}, nil +} + +// sameOrigin reports whether two origin URLs name one repository: the +// transport (scheme), the user, and a trailing "/" or ".git" do not matter; +// host and path, case included, do. +func sameOrigin(a, b string) bool { return originIdentity(a) == originIdentity(b) } + +// originIdentity reduces an origin URL to host/path ("github.com/acme/app"), or +// to the cleaned path for a local path. +func originIdentity(u string) string { + s := strings.TrimSuffix(strings.TrimRight(strings.TrimSpace(u), "/"), ".git") + if i := strings.Index(s, "://"); i >= 0 { + s = s[i+3:] + } else if colon := strings.IndexByte(s, ':'); colon > 0 && !strings.ContainsAny(s[:colon], `/\`) { + s = s[:colon] + "/" + s[colon+1:] // scp-like host:path + } else { + return s + } + if slash := strings.IndexByte(s, '/'); slash >= 0 { + if at := strings.LastIndexByte(s[:slash], '@'); at >= 0 { + s = s[at+1:] + } + } + return s +} + // privateConfigSeed is the first content of a new private config file. const privateConfigSeed = "visibility: private\n" diff --git a/internal/app/repository_init_private_test.go b/internal/app/repository_init_private_test.go new file mode 100644 index 000000000..82645d37e --- /dev/null +++ b/internal/app/repository_init_private_test.go @@ -0,0 +1,26 @@ +package app + +import "testing" + +// TestSameOrigin pins which origin URL spellings name one repository for the +// default store's origin record: transport, user, trailing ".git" and "/" do +// not matter; host, owner and repo (case included) do. +func TestSameOrigin(t *testing.T) { + for _, tc := range []struct { + a, b string + want bool + }{ + {"git@github.com:acme/web-app.git", "https://github.com/acme/web-app", true}, + {"ssh://git@github.com/acme/web-app.git", "https://github.com/acme/web-app.git/", true}, + {"https://user@github.com/acme/web-app.git", "git@github.com:acme/web-app.git", true}, + {"/srv/git/acme/web-app.git", "/srv/git/acme/web-app", true}, + {"git@github.com:acme/web-app.git", "git@github.com:acme-web/app.git", false}, + {"git@github.com:acme/web-app.git", "git@gitlab.com:acme/web-app.git", false}, + {"https://github.com/acme/Web-App", "https://github.com/acme/web-app", false}, + {"/srv/a/acme/web-app.git", "/srv/b/acme/web-app.git", false}, + } { + if got := sameOrigin(tc.a, tc.b); got != tc.want { + t.Errorf("sameOrigin(%q, %q) = %v, want %v", tc.a, tc.b, got, tc.want) + } + } +} diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 156202b87..89e67633f 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -248,6 +248,80 @@ func TestIntegrationRepoSetupPrivateSecondCloneAdoptsSharedStore(t *testing.T) { } } +// TestIntegrationRepoSetupPrivateStoreOriginCollisionRefuses proves the default +// store records the origin that created it and a repository whose distinct +// origin derives the same - store name refuses instead of adopting +// the other repository's backlog: no dckt remote, no checkout, and the store's +// branch and record stay untouched. +func TestIntegrationRepoSetupPrivateStoreOriginCollisionRefuses(t *testing.T) { + a, data := newPrivateInitRepo(t, nil) + if res := a.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("repo A init = %q (%s), want applied", res.Result, res.HumanText()) + } + layA := privateLayoutOf(t, a.invocation, data) + recordPath := filepath.Join(layA.StoreDir, storeOriginRecordName) + record, err := os.ReadFile(recordPath) + if err != nil || strings.TrimSpace(string(record)) != a.origin { + t.Fatalf("store origin record = %q (%v), want A's origin %q", record, err, a.origin) + } + tipA := runGit(t, layA.DefaultBareRemote, "rev-parse", "refs/heads/dckt") + + // Repo B: a different origin whose last two path segments equal A's, so it + // derives the same store name — the same owner/repo on another host. + b, _ := newPrivateInitRepo(t, nil) + t.Setenv("XDG_DATA_HOME", data) + collider := filepath.Join(testsupport.TempDir(t), filepath.Base(a.root), "origin.git") + runGit(t, b.root, "clone", "-q", "--bare", b.origin, collider) + runGit(t, b.invocation, "remote", "set-url", "origin", collider) + if layB := privateLayoutOf(t, b.invocation, data); layB.StoreDir != layA.StoreDir { + t.Fatalf("fixture: B's store %q does not collide with A's %q", layB.StoreDir, layA.StoreDir) + } + + res := b.runInitWith(t, InitOptions{Private: true}) + if res.Result != ResultInvalidState || res.RepositoryState != string(reposetup.StateConflict) { + t.Fatalf("colliding init = %q/%q (%s), want invalid-state/conflict", res.Result, res.RepositoryState, res.HumanText()) + } + text := res.HumanText() + if !strings.Contains(text, a.origin) || !strings.Contains(text, collider) || !strings.Contains(text, "--metadata-remote") { + t.Errorf("refusal %q must name both origins and the --metadata-remote remedy", text) + } + if _, err := tryGit(b.invocation, "config", "--get", "remote.dckt.url"); err == nil { + t.Error("the colliding repository gained a dckt remote; the refusal must write nothing further") + } + if wts := runGit(t, b.invocation, "worktree", "list", "--porcelain"); strings.Contains(wts, layA.CheckoutsDir) { + t.Errorf("the colliding repository attached a checkout under the shared store:\n%s", wts) + } + if got := runGit(t, layA.DefaultBareRemote, "rev-parse", "refs/heads/dckt"); got != tipA { + t.Errorf("the store's dckt branch moved from %s to %s", tipA, got) + } + if got, _ := os.ReadFile(recordPath); string(got) != string(record) { + t.Errorf("store origin record changed from %q to %q", record, got) + } +} + +// TestIntegrationRepoSetupPrivateUnrecordedStoreAdoptsAndRecords proves a store +// created before the origin record existed is adopted by a matching repository +// and gains the record. +func TestIntegrationRepoSetupPrivateUnrecordedStoreAdoptsAndRecords(t *testing.T) { + r, data := newPrivateInitRepo(t, nil) + if res := r.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("clone A init = %q (%s), want applied", res.Result, res.HumanText()) + } + lay := privateLayoutOf(t, r.invocation, data) + recordPath := filepath.Join(lay.StoreDir, storeOriginRecordName) + if err := os.Remove(recordPath); err != nil { + t.Fatal(err) + } + b := r.freshClone(t) + bRepo := &initRepo{root: r.root, origin: r.origin, writer: r.writer, invocation: b} + if res := bRepo.runInitWith(t, InitOptions{Private: true}); res.Result != ResultApplied { + t.Fatalf("clone B init over an unrecorded store = %q (%s), want applied", res.Result, res.HumanText()) + } + if got, err := os.ReadFile(recordPath); err != nil || strings.TrimSpace(string(got)) != r.origin { + t.Errorf("store origin record = %q (%v), want it rewritten to %q", got, err, r.origin) + } +} + func TestIntegrationRepoSetupPrivateRemoteURLConflictRefuses(t *testing.T) { r, _ := newPrivateInitRepo(t, nil) elsewhere := filepath.Join(r.root, "elsewhere.git") From c4d4e122199f62a8cb85347679ea0938fe9009f5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 08:57:05 -0400 Subject: [PATCH 20/20] fix(private): absolutize a local --metadata-remote; skills say the metadata branch Review fixes for change 0531: - F7: a local-path --metadata-remote is made absolute and clean before AddRemote and the existing-remote comparison (URLs and scp-style host:path stay as typed); unit test TestAbsMetadataRemote. - F8: skills say "the metadata branch" instead of "the `docket` branch"; the convention's branch model names `docket` once for shared mode. Assets regenerated; convention word budget 7155 -> 7160. --- internal/app/repository_init_private.go | 20 +++++++++++- internal/app/repository_init_private_test.go | 32 ++++++++++++++++++- internal/assets/embedded/manifest.json | 20 ++++++------ .../embedded/tree/skills/docket-adr/SKILL.md | 6 ++-- .../tree/skills/docket-convention/SKILL.md | 30 ++++++++--------- .../docket-convention/references/close-out.md | 2 +- .../docket-convention/references/learnings.md | 4 +-- .../skills/docket-implement-next/SKILL.md | 16 +++++----- .../references/edge-paths.md | 4 +-- .../tree/skills/docket-new-change/SKILL.md | 2 +- .../tree/skills/docket-status/SKILL.md | 6 ++-- internal/repoguard/budgets_test.go | 2 +- skills/docket-adr/SKILL.md | 6 ++-- skills/docket-convention/SKILL.md | 30 ++++++++--------- .../docket-convention/references/close-out.md | 2 +- .../docket-convention/references/learnings.md | 4 +-- skills/docket-implement-next/SKILL.md | 16 +++++----- .../references/edge-paths.md | 4 +-- skills/docket-new-change/SKILL.md | 2 +- skills/docket-status/SKILL.md | 6 ++-- 20 files changed, 131 insertions(+), 83 deletions(-) diff --git a/internal/app/repository_init_private.go b/internal/app/repository_init_private.go index 64fd4818b..40b025bf8 100644 --- a/internal/app/repository_init_private.go +++ b/internal/app/repository_init_private.go @@ -170,7 +170,10 @@ func runPrivateInit(ctx context.Context, d SetupDeps, sc setupContext, cls repos if err != nil && !unconfigured { return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "reading the dckt git remote", err)) } - want := o.MetadataRemote + want, err := absMetadataRemote(o.MetadataRemote) + if err != nil { + return fail(repositoryExternalFailure(OperationRepositoryInit, cls.State, "resolving the --metadata-remote path", err)) + } if want == "" && !unconfigured && sc.layout.Mode == layout.Private { want = got } @@ -412,3 +415,18 @@ func ensurePrivateConfig(path, primaryWorktree string, cfg config.Effective) (bo return edited, nil }) } + +// absMetadataRemote makes a local-path --metadata-remote absolute and clean: +// metadata git operations run from a checkout outside the clone, so a relative +// path stored as typed would resolve against the wrong directory. A URL +// ("scheme://...") or an scp-style "host:path" (a colon before any slash, as +// git reads it) is returned unchanged, as is the empty value. +func absMetadataRemote(v string) (string, error) { + if v == "" || strings.Contains(v, "://") { + return v, nil + } + if c := strings.Index(v, ":"); c >= 0 && !strings.Contains(v[:c], "/") { + return v, nil + } + return filepath.Abs(v) +} diff --git a/internal/app/repository_init_private_test.go b/internal/app/repository_init_private_test.go index 82645d37e..456cb523b 100644 --- a/internal/app/repository_init_private_test.go +++ b/internal/app/repository_init_private_test.go @@ -1,6 +1,10 @@ package app -import "testing" +import ( + "os" + "path/filepath" + "testing" +) // TestSameOrigin pins which origin URL spellings name one repository for the // default store's origin record: transport, user, trailing ".git" and "/" do @@ -24,3 +28,29 @@ func TestSameOrigin(t *testing.T) { } } } + +func TestAbsMetadataRemote(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + cases := map[string]string{ + "": "", + "https://example.com/x.git": "https://example.com/x.git", + "file:///srv/x.git": "file:///srv/x.git", + "git@host:org/x.git": "git@host:org/x.git", + "host:x.git": "host:x.git", + "/srv/meta/../x.git": "/srv/x.git", + "../backup.git": filepath.Join(filepath.Dir(cwd), "backup.git"), + "./a:b/x.git": filepath.Join(cwd, "a:b", "x.git"), + } + for in, want := range cases { + got, err := absMetadataRemote(in) + if err != nil { + t.Fatalf("absMetadataRemote(%q): %v", in, err) + } + if got != want { + t.Errorf("absMetadataRemote(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 1cdc9b5b5..21238091f 100644 --- a/internal/assets/embedded/manifest.json +++ b/internal/assets/embedded/manifest.json @@ -1,7 +1,7 @@ { "format_version": 1, "asset_protocol": 1, - "asset_set_id": "sha256:3bb07c3e1dbbe67904238c314adc4e848fb5feff21fd942a7fe167faa3a1056c", + "asset_set_id": "sha256:a60f6f773af6d6e8c3441d4d710b0b2fb0a9b2dfd5592a18a98d1b99ede03d4a", "entries": [ { "path": ".docket.example.yml", @@ -274,7 +274,7 @@ "role": "skill", "mode": 420, "size": 7758, - "sha256": "c6da9306b5cf1ce3299886ebe8454d2b3492cbbe149252803317775abd8639b7" + "sha256": "7420286e6fbd71001ca612e99a512627154e44af53cbea2e2cd85d144c893e21" }, { "path": "skills/docket-auto-groom/SKILL.md", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 52114, - "sha256": "c2f68748785f18d39c17a124ba1ad86b2f1eceb9483448c5d78b665c53e0e777" + "size": 52148, + "sha256": "99b19e49adb1bc60b729ad1595563f3e44ba3fe058849a03defe2526f181c6b1" }, { "path": "skills/docket-convention/references/agent-layer.md", @@ -337,14 +337,14 @@ "role": "skill", "mode": 420, "size": 9591, - "sha256": "cea60cb641edc0df8e2b650adabfe8c986603be876201456884f6e8268bbaf45" + "sha256": "262291f95ac8a29797bec0861bd202c36f96040ef7073f3cd43f35eda0f87fb1" }, { "path": "skills/docket-convention/references/learnings.md", "role": "skill", "mode": 420, "size": 3331, - "sha256": "f0eaa4684470f5c3624f3cebbd3b42b0cb98cbe63ad0c4c29835159a74e36ff2" + "sha256": "6d030f9d7be9a0dda61e2d890cad18fbdea10b37e1e0c21380a7531524da2cc1" }, { "path": "skills/docket-convention/references/stacked-changes.md", @@ -379,14 +379,14 @@ "role": "skill", "mode": 420, "size": 56932, - "sha256": "839e04ee35d24e43fef82b34ad24de6a2857c4abd080c7002b19fe02f78f96dc" + "sha256": "49c4ba1d6b87f657d4cd2bad004c3a3d0f090880b1d9b784a883b635b4f94b37" }, { "path": "skills/docket-implement-next/references/edge-paths.md", "role": "skill", "mode": 420, "size": 8374, - "sha256": "af43f15755edc0ceac519149b56270f9f1d4134d71e5ae4dadd21df86a657141" + "sha256": "79c7f698415b7733f811e68815b9378c32b807dc3d4b05429da4f06e8a38c639" }, { "path": "skills/docket-implement-next/references/fix-pass.md", @@ -407,7 +407,7 @@ "role": "skill", "mode": 420, "size": 11229, - "sha256": "6cc4221bf184ef0e2d8c57305c92b17eb8ab08fff0ae7e48b6eb6549e8b6eda6" + "sha256": "b86edf4e97716f9b66bc0c705f01f04cda4ba421738802a7fbbe22e22233b385" }, { "path": "skills/docket-review/SKILL.md", @@ -421,7 +421,7 @@ "role": "skill", "mode": 420, "size": 19822, - "sha256": "e08d782fbd265aee6c55c3a6300f28e63e4cdd3c7b02c05cfb46e4d13dcae824" + "sha256": "1c263eed9cbc6bca0c1defd5ad8aec480591333047de1fa549b83421e7277097" } ] } diff --git a/internal/assets/embedded/tree/skills/docket-adr/SKILL.md b/internal/assets/embedded/tree/skills/docket-adr/SKILL.md index 895bff8f9..9cda4fabb 100644 --- a/internal/assets/embedded/tree/skills/docket-adr/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-adr/SKILL.md @@ -43,7 +43,7 @@ One validated transaction lands atomically, in a single metadata commit: the nex **Return the number** — read the allocated ADR id from the operation's result envelope so the caller (e.g. `docket-implement-next` step 6) can cite it in the change's `adrs:` field. -**Where an ADR lives** — the ADR and its index live on the `docket` branch; no ADR, change-tied or standalone, is copied to the integration branch. +**Where an ADR lives** — the ADR and its index live on the metadata branch; no ADR, change-tied or standalone, is copied to the integration branch. ### Supersede / reverse @@ -59,7 +59,7 @@ adr.supersede --request - # resolve argv from the capability catalog - `target` — the ADR being replaced, as `{id, path, revision}`. The target must be `Accepted`, else the transaction refuses. - `successor` — the new ADR, as a full record request (the same fields as *Create*'s `ADRRecordRequest`; give it its own producing `change` if one exists). -One transaction lands atomically: the new ADR (carrying its `supersedes:`/`reverses:` edge to the old one), the old ADR's `status:` line flipped to `"Superseded by ADR-NN"` / `"Reversed by ADR-NN"` (its frozen body otherwise byte-for-byte unchanged — that status value is the **only** change to the old file), and the re-rendered index. There is no separate index commit. In the index the old ADR's row shows its `Superseded by ADR-NN` / `Reversed by ADR-NN` status, and the new ADR's row (in the Active group) shows `→ supersedes ADR-NN` / `→ reverses ADR-NN`. A typed conflict or refusal returns without writing — re-read and retry rather than hand-editing. The status flip lands on the `docket` branch with the re-rendered index. +One transaction lands atomically: the new ADR (carrying its `supersedes:`/`reverses:` edge to the old one), the old ADR's `status:` line flipped to `"Superseded by ADR-NN"` / `"Reversed by ADR-NN"` (its frozen body otherwise byte-for-byte unchanged — that status value is the **only** change to the old file), and the re-rendered index. There is no separate index commit. In the index the old ADR's row shows its `Superseded by ADR-NN` / `Reversed by ADR-NN` status, and the new ADR's row (in the Active group) shows `→ supersedes ADR-NN` / `→ reverses ADR-NN`. A typed conflict or refusal returns without writing — re-read and retry rather than hand-editing. The status flip lands on the metadata branch with the re-rendered index. ### Update note @@ -81,6 +81,6 @@ surfaces the drift as a structured finding (`adr-index-stale` when the bytes dif repository.repair # resolve argv from the capability catalog; previews only ``` -Repair re-renders only the canonical derived bytes it owns (marker order and balance validated first — a malformed index refuses and leaves the file untouched) and never edits an authored ADR body. It re-proves the pinned revision before writing and commits on the `docket` branch. On a git conflict on the index, re-run the repair rather than hand-merging (the regenerate-don't-3-way-merge rule). +Repair re-renders only the canonical derived bytes it owns (marker order and balance validated first — a malformed index refuses and leaves the file untouched) and never edits an authored ADR body. It re-proves the pinned revision before writing and commits on the metadata branch. On a git conflict on the index, re-run the repair rather than hand-merging (the regenerate-don't-3-way-merge rule). Validate the ledger the same way — the `repository.check` operation runs the ADR-ledger consistency findings (numbering gaps, dangling `supersedes:`/`reverses:`/`relates_to:` links, status inconsistencies) alongside the derived-view drift findings, each as one structured finding; a non-`Repairable` finding (illegal ADR evolution, a missing referenced record) is left for manual review. diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index f75d91d6a..a31d0b404 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -93,11 +93,11 @@ When dispatch is genuinely unavailable the kinds are **not** equivalent, so the | Fallback | Dispatch | Posture | |---|---|---| -| **`inline` — deterministic** | the `docket-status` and `docket-adr` composition dispatches | Run the same work **inline** — a **first-class equivalent path**, neither a degradation nor a warning, because the contract is git state on the `docket` branch, not an in-context return. Every obligation holds unchanged: re-sync before reading, derive from fresh origin, never adopt or commit another agent's uncommitted files. | +| **`inline` — deterministic** | the `docket-status` and `docket-adr` composition dispatches | Run the same work **inline** — a **first-class equivalent path**, neither a degradation nor a warning, because the contract is git state on the metadata branch, not an in-context return. Every obligation holds unchanged: re-sync before reading, derive from fresh origin, never adopt or commit another agent's uncommitted files. | | **`abstain` — adversarial** | the `docket-auto-groom-critic` gate | **Abstain**, per *Autonomous grooming*. Self-critique by the agent that drafted the spec is not an adversarial gate, and the abstain is a path that skill already owns. | | **`halt` — discipline** | the **plan-writer dispatch**, the `build` role skill's required nested dispatches, the controller's own review-tier dispatch (the `docket-review` skill itself never dispatches), and the in-branch fix workers (which run the `build` role's own contract) | **`halt`** — the run stops: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded through `change.halt`. **No new status, no new field** — the reclaim lease self-heals an abandoned claim. | -**Outside the table — `no-fallback`.** `docket-finalize-change`'s two finalize-gate dispatches — `docket-rebase-resolver` and `docket-integration-repair` — sit **outside** this table rather than in a row of it, because their contract is an **in-context report** gating the merge, not git state on the `docket` branch. Neither fallback can be borrowed for them: `inline`'s first-class-equivalent path presupposes a git-state transition to reproduce, and `halt` belongs to the workflow roles, and these dispatches are not a role. When dispatch is genuinely unavailable for either — established per the resolution rule above, never from a tool name — the posture is finalize's own pre-existing **abort-and-report**: the gate stops, the PR stays open, the change stays `implemented`, and the reason is recorded through the three channels `docket-finalize-change`'s failure reference owns. **Inline substitution is forbidden** for both, and that is the point of `no-fallback` rather than a table row: reconciling the conflict, or authoring the repair, inside the very agent that would then merge that work is the same self-approval shape `abstain` exists to avoid for the critic. +**Outside the table — `no-fallback`.** `docket-finalize-change`'s two finalize-gate dispatches — `docket-rebase-resolver` and `docket-integration-repair` — sit **outside** this table rather than in a row of it, because their contract is an **in-context report** gating the merge, not git state on the metadata branch. Neither fallback can be borrowed for them: `inline`'s first-class-equivalent path presupposes a git-state transition to reproduce, and `halt` belongs to the workflow roles, and these dispatches are not a role. When dispatch is genuinely unavailable for either — established per the resolution rule above, never from a tool name — the posture is finalize's own pre-existing **abort-and-report**: the gate stops, the PR stays open, the change stays `implemented`, and the reason is recorded through the three channels `docket-finalize-change`'s failure reference owns. **Inline substitution is forbidden** for both, and that is the point of `no-fallback` rather than a table row: reconciling the conflict, or authoring the repair, inside the very agent that would then merge that work is the same self-approval shape `abstain` exists to avoid for the critic. The *Skill layer*'s **missing-skill rule** and `halt` are two conditions with two postures: a role skill that cannot be **invoked** is done inline by the running agent, with a prominent warning, while an invoked role skill whose required nested **dispatch** cannot run is `halt`. One symptom in the run log, two causes. @@ -109,7 +109,7 @@ Each **autonomous** docket skill can run as a model/effort-pinned **subagent** i A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=`) flows back in-context, while the proof is **git state on the metadata branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -120,7 +120,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: | Role | Skill | Invoked by | Final artifact — stop-point | |---|---|---|---| | brainstorm | `superpowers:brainstorming` | `docket-new-change` §2, `docket-groom-next` | a spec file at the configured spec path; stop at the spec | -| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the `docket` branch, recorded in `plan:` | +| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the metadata branch, recorded in `plan:` | | build | `docket-build` | `docket-implement-next` §5 | the plan executed on the feature branch | | review | `docket-review` | `docket-implement-next` §6 | a whole-branch review before the PR opens, over a branch whose build evidence is green | @@ -154,7 +154,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: / # default docs/adrs/ — flat; ADRs are NEVER archived -.md # immutable once Accepted (only its status: line ever changes) README.md # generated ADR index -/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the `docket` branch; NEVER archived) +/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the metadata branch; NEVER archived) --results.md ``` @@ -162,7 +162,7 @@ The `archive/` filename date prefix is **UTC**: the **merge commit's** date for All of the above lives on the metadata branch, written through the persistent **metadata worktree**: `/.docket/` in a shared repository (gitignored, deliberately not under `.worktrees/` — slug collisions, prune blast radius), or a checkout outside the clone in a private one; skills always use `metadata_worktree_path` (see *Branch model*). -**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `/--results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. +**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the metadata branch at `/--results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. ### Change manifest (frontmatter at the top of each change file) @@ -183,9 +183,9 @@ stacked_on: # optional: parent change id whose branch this one is related: [4, 6] # cross-links the reconcile pass reads discovered_from: [62] # change id(s) whose work surfaced this one; informational like related:, never a readiness gate adrs: [24] # ADRs this change cites or produces -spec: # superpowers design doc path; set at brainstorm (propose) time, on the `docket` branch -plan: # plan FILE on the `docket` branch; FILE and FIELD written together by change.attach-plan at build time -results: # results FILE on the `docket` branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition +spec: # superpowers design doc path; set at brainstorm (propose) time, on the metadata branch +plan: # plan FILE on the metadata branch; FILE and FIELD written together by change.attach-plan at build time +results: # results FILE on the metadata branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition trivial: false # true = no spec needed (small mechanical change); still build-ready auto_groomable: # `true` makes a stub auto-groomable; unset or `false` means not branch_prefix: # optional one unqualified branch-path component; overrides at mint, consumed only at claim, inert once branch: is populated, survives reclaim @@ -214,7 +214,7 @@ change, never in the frozen artifact. `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`` / ``); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `` / ``) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the metadata branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. @@ -279,7 +279,7 @@ An `Accepted` ADR is immutable except its `status:` line; a non-reversing contex | `done` | PR merged, filed away (happy final status) | `archive/` | | `killed` | abandoned — obsolete or never shipped (sad final status) | `archive/` | -**Rules.** `active/` holds every non-final status; `archive/` holds the two final outcomes. `stacked-merged` is non-final for that reason: merged into its stack parent, not the integration branch, so it stays in `active/` until the stack close-out promotes it when the stack root lands. The single physical move (`active/ → archive/`, date-prefixed) happens once on the final transition and is **idempotent**: re-pull, re-read `status` on the `docket` branch, no-op if already final. `deferred` may be entered from `proposed` or `in-progress` (add `## Why deferred`) and revived to `proposed`; clear a blocker with `change.unblock` (`blocked` → `in-progress`, clearing `blocked_by`) and revive with `change.revive` (`deferred` → `proposed`) — typed operations, no file move. A change whose `depends_on` is unsatisfied is *implicitly* blocked — the selector skips it and the board shows it **waiting on #N**. A dependency is **satisfied when it reaches `done`**; if `#N` is still `implemented` the board flags **waiting on #N — needs your merge**, distinct from **waiting on #N — not yet built**. Reserve explicit `blocked` for external blockers the system can't infer. +**Rules.** `active/` holds every non-final status; `archive/` holds the two final outcomes. `stacked-merged` is non-final for that reason: merged into its stack parent, not the integration branch, so it stays in `active/` until the stack close-out promotes it when the stack root lands. The single physical move (`active/ → archive/`, date-prefixed) happens once on the final transition and is **idempotent**: re-pull, re-read `status` on the metadata branch, no-op if already final. `deferred` may be entered from `proposed` or `in-progress` (add `## Why deferred`) and revived to `proposed`; clear a blocker with `change.unblock` (`blocked` → `in-progress`, clearing `blocked_by`) and revive with `change.revive` (`deferred` → `proposed`) — typed operations, no file move. A change whose `depends_on` is unsatisfied is *implicitly* blocked — the selector skips it and the board shows it **waiting on #N**. A dependency is **satisfied when it reaches `done`**; if `#N` is still `implemented` the board flags **waiting on #N — needs your merge**, distinct from **waiting on #N — not yet built**. Reserve explicit `blocked` for external blockers the system can't infer. **Reclaim edge (`in-progress → proposed`).** An `in-progress` change whose claim lease (`claimed_at:` + `reclaim.lease_ttl`) has expired AND that has no feature branch is flipped back to `proposed` by the reclaim operation (opt-in via `reclaim.auto`, or an explicit `change.reclaim`), clearing `branch:`/`claimed_at:` and resetting `reconciled: false` so a fresh reconcile runs on re-claim. The has-branch case is never auto-reclaimed (it may carry real work) — it stays flagged for a human. @@ -307,7 +307,7 @@ minted or discarded** — a human captures reported work deliberately with `dock ### Learnings ledger `/learnings/` — the project's **learnings ledger**: one curated finding per file, -on the `docket` branch only, never published to the integration branch. `LEARNINGS.md` remains as a +on the metadata branch only, never published to the integration branch. `LEARNINGS.md` remains as a pointer stub to the earlier single-file ledger. The finding files are written by the `learning.record` and `learning.update` operations; the index (`learnings/README.md`) is a **derived view** that no operation refreshes, and its recorded bytes are what readers consult. @@ -348,12 +348,12 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr ### Branch model -Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. +Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch (`docket` in a shared repository) via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. -A change's feature branch is minted at claim as `/`, or `/` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. +A change's feature branch is minted at claim as `/`, or `/` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/`** — the metadata branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. The metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. -On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. +On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the metadata branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md index 81cb2c9d7..cfdb72deb 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md @@ -31,7 +31,7 @@ immediately. ``` `--input` carries only the optional authored closeout notes (`verification_outcomes`, - `late_findings`; `-` for stdin); the `results:` file already sits on the `docket` branch and is + `late_findings`; `-` for stdin); the `results:` file already sits on the metadata branch and is never passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a stack) ⇒ the change is marked done and relocated to the dated archive path — idempotent if already archived, including across a day boundary. This ONE metadata commit atomically owns the diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/learnings.md b/internal/assets/embedded/tree/skills/docket-convention/references/learnings.md index a28bbb2df..2de1ba019 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/learnings.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/learnings.md @@ -44,7 +44,7 @@ promoted_to: # set only when promoted: the agent-instruct The `learning.record` operation **creates** a new finding (`slug`, `hook`, `topics`, `changes`, `## Apply`, `## War story`) under its `request_id` idempotency key; the `learning.update` operation **extends** an existing one, pinned by its exact record revision (append a dated -`## War story` entry, add the change id to `changes:`). Both write only the one finding file on the `docket` branch and refuse +`## War story` entry, add the change id to `changes:`). Both write only the one finding file on the metadata branch and refuse when `learnings.enabled` is not `true`. Neither **merges two distinct findings** — that is a human act. Zero findings is normal; kills are not recorded. @@ -52,7 +52,7 @@ act. Zero findings is normal; kills are not recorded. Tiering criterion: *"will the agent know to search for this?"* A rule that must fire **unprompted** graduates; a war story stays in retrieval. A candidate carries -`promotion_state: candidate` on the `docket` branch and **never touches the integration branch** +`promotion_state: candidate` on the metadata branch and **never touches the integration branch** (ADR-0005). A human lands the graduation in the integration-branch agent-instructions file (`AGENTS.md`/`CLAUDE.md`, symlink-aware; `AGENTS.md` is the neutral spelling when neither exists) and flips `promoted` + `promoted_to:`. A promoted finding leaves the topic groups for a diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md index f20360103..1fc7507fc 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -73,7 +73,7 @@ workspace.prepare --id --revision # resolve argv from the cap The `workspace.prepare` operation reloads the authoritative snapshot, resolves the effective base through the landed domain policy (the app adds NO base or stack policy of its own), constructs the workspace target, and returns the canonical owned workspace path, exact base commit, feature ref, and disposition. It requires the change to be `in-progress` at that exact `--revision` (a losing claimant creates no workspace); a `contended`/`unknown` disposition passes through verbatim, never a force. For a change carrying `stacked_on:`, the resolved base is the parent's effective base, not `origin/`, and the step-7 PR targets it; **read [`../docket-convention/references/stacked-changes.md`](../docket-convention/references/stacked-changes.md) now (blocking)**. The `workspace.inspect` operation with `--id ` re-reads a prepared or resumed workspace (ownership + live-Git checks) idempotently. -the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/` in the ordinary case). +the metadata branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/` in the ordinary case). **Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync the metadata worktree immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id `: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id --revision ` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. @@ -110,7 +110,7 @@ It also contains the branch and base ref, change title and scope, relevant learn ### Step 6.5 — Results (required) -The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `/--results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the `docket` branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. +The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `/--results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the metadata branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. **Human actions and testing** labels every item **Important** or **Optional**, written for a mid-level engineer with little knowledge of Docket internals. An **Important** item marks a meaningful verification gap or necessary human judgment (never a new automated merge requirement) — give the reason, when the action matters, and what remains uncertain if skipped. An Optional walkthrough MAY exercise behavior automated tests already cover — a routine suite rerun is not a functional walkthrough. Every item carries prerequisites and setup, concrete steps with observable expected results, and cleanup when it changes persistent state — complete enough to run without reconstructing missing commands. Do not infer coverage from a green suite: inspect the relevant tests and evidence, and where coverage is genuinely uncertain report that uncertainty under Known issues and follow-ups or Verification performed rather than authoring a speculative checklist. The section holds only what a human should do or check — never a record of what the run already checked, which belongs under Verification performed. @@ -153,12 +153,12 @@ Each step below is complete only when its row holds — read from **git**, never | Step | Complete only when | |---|---| -| 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the `docket` branch **and landed** (local tip == remote tip). | -| 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the `docket` branch — or, on the kill path, the change archived. | -| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `/` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the `docket` branch, the feature HEAD unchanged by planning. | +| 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the metadata branch **and landed** (local tip == remote tip). | +| 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the metadata branch — or, on the kill path, the change archived. | +| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `/` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the metadata branch, the feature HEAD unchanged by planning. | | 5 Build | the executed plan committed on `/`, with a build-evidence record at `result: green` (or `skipped` / `build-gate-off` under `build.gate: off`) whose `head_sha` **equals branch HEAD**. | | 6 Review + ADRs | that record still present at `head_sha` == HEAD **after** any fix commits, and every ADR the run produced landed in `adrs:`. Known-weak row: on a clean review this reduces to Step 5's, because whether a reviewer ran is not a fact about git. | -| 7 PR + stop | the branch pushed (`origin//` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink on the `docket` branch, and the record's `## Build evidence` section naming the PR head. | +| 7 PR + stop | the branch pushed (`origin//` resolves), the PR open, and `status: implemented` + `pr:` landed on the metadata branch; `results:` set and the final results file with its backlink on the metadata branch, and the record's `## Build evidence` section naming the PR head. | Rows 3, 4, 6 and 7 each carry, in addition, the **field-write rule**'s conditions on every metadata commit they name: the `claimed_at` re-stamp, and — for any link-bearing field write — the regenerated `## Artifacts` block in the same commit. @@ -173,7 +173,7 @@ Every run ends by declaring exactly **one** of four dispositions, so any driver | `advanced` | Built a change → PR opened (Step 7 reached). | continue | | `contended` | Selected a change but lost the conflict-checked claim (Step 2); **nothing built**. | continue — re-select next | | `drained` | No build-ready+claimable change in scope (Step 1's empty queue). | **stop** | -| `halted` | Stopped needing a human — fundamentally-invalidated design (Step 3) or a hard error. Recorded on the `docket` branch as a `## Run halted` section through the `change.halt` operation — see Step 3 for the write; the `run.verify` operation reads it back as the `run-halted` verdict. | **stop + surface** | +| `halted` | Stopped needing a human — fundamentally-invalidated design (Step 3) or a hard error. Recorded on the metadata branch as a `## Run halted` section through the `change.halt` operation — see Step 3 for the write; the `run.verify` operation reads it back as the `run-halted` verdict. | **stop + surface** | The driver's decision is binary: **continue on `advanced`/`contended`, stop on `drained`/`halted`.** The contract is **driver-agnostic** — it names run outcomes, not any one driver's mechanics; `/loop` is *recommended*, not required (see the README drain-pattern doc). @@ -209,4 +209,4 @@ New change ⇒ the `workspace.prepare` operation with `--id --revision /` — the plan and results files and their fields are metadata writes through their attach transactions, and build evidence lands in the change record. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree write is `workspace.commit-spec`'s spec copy, an operation-owned commit.) -This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/` and the `docket` branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. +This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/` and the metadata branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md b/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md index 6f932067e..13ee3756a 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/references/edge-paths.md @@ -102,8 +102,8 @@ to force a workspace, a commit, or a HEAD move. **Best-effort PR→issue reference.** If the change carries an `issue:` value, add a plain `#` reference to the PR body — but **never `Closes #N`**, so merging the PR never auto-closes the referenced issue. Skip silently when `issue:` is unset — the reference is a one-time courtesy, not a build gate. -**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change — ` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. +**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the metadata branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. **Review outcome.** The authored body names the tier that reviewed and carries the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, recorded, or reported. The table's states are defined in `fix-pass.md`; do not redefine them here. -**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the `docket` branch; never author it by hand. +**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the metadata branch; never author it by hand. diff --git a/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md index 085297768..f501f7adb 100644 --- a/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-new-change/SKILL.md @@ -30,7 +30,7 @@ The default path for any non-trivial new change. Five steps: 1. **Allocate** — id allocation is owned by the `change.create` operation transaction (step 4): it reads the next id from fresh origin state, derives the slug from the title, serializes the canonical proposed record, and lands it under an exact-lease push — so there is **no** hand-scan of `active/` + `archive/`, no `max + 1` by hand, and no manual rename-on-collision. Supply a stable `request_id` on the create request so a lost-response retry converges by idempotent replay rather than minting a duplicate. The startup-check `repository.prepare` operation already synced the metadata working tree. -2. **Brainstorm** — invoke `superpowers:brainstorming` WITH THE HUMAN. This is the decision point. When `superpowers:brainstorming` cannot be invoked, run the brainstorm inline with the human, warning prominently; the artifact is unchanged: a spec, then stop. If the human asks for a consultant-written spec, invoke `docket-brainstorm` for this run instead — human steering of an interactive session always wins (see the README's consultant-brainstorm section). STOP AT THE SPEC — do NOT continue to `writing-plans` (that is build-time). The settled design becomes the **spec's Markdown body**, authored and held for step 5 — do NOT hand-write the spec file, hand-set `spec:`, or hand-render the `## Artifacts`/`docket:backlink` blocks: the step-5 `change.groom` operation writes the spec file under `docs/superpowers/specs/…` on the `docket` branch, sets `spec:`, stamps the reciprocal `docket:backlink` block, and re-renders the `## Artifacts` block atomically. A typed refusal (malformed markers, spec-path-taken) leaves the files untouched — surface it, do not hand-edit. +2. **Brainstorm** — invoke `superpowers:brainstorming` WITH THE HUMAN. This is the decision point. When `superpowers:brainstorming` cannot be invoked, run the brainstorm inline with the human, warning prominently; the artifact is unchanged: a spec, then stop. If the human asks for a consultant-written spec, invoke `docket-brainstorm` for this run instead — human steering of an interactive session always wins (see the README's consultant-brainstorm section). STOP AT THE SPEC — do NOT continue to `writing-plans` (that is build-time). The settled design becomes the **spec's Markdown body**, authored and held for step 5 — do NOT hand-write the spec file, hand-set `spec:`, or hand-render the `## Artifacts`/`docket:backlink` blocks: the step-5 `change.groom` operation writes the spec file under `docs/superpowers/specs/…` on the metadata branch, sets `spec:`, stamps the reciprocal `docket:backlink` block, and re-renders the `## Artifacts` block atomically. A typed refusal (malformed markers, spec-path-taken) leaves the files untouched — surface it, do not hand-edit. 3. **Scan related context** — scan neighbouring changes (`active/` + recent `archive/`) and the ADR index to pre-fill `related`, `depends_on`, `adrs`, and — when the human names the change(s) whose work surfaced this one (or scan mode infers an origin) — `discovered_from` (informational, like `related:`; empty for deliberately planned work). In practice, do this quick read just *before* step 2 so the brainstorm is informed by neighbouring work; record the resulting `related`/`depends_on`/`adrs`/`discovered_from` after the design settles. diff --git a/internal/assets/embedded/tree/skills/docket-status/SKILL.md b/internal/assets/embedded/tree/skills/docket-status/SKILL.md index 2a3866aae..7d5da142f 100644 --- a/internal/assets/embedded/tree/skills/docket-status/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-status/SKILL.md @@ -105,11 +105,11 @@ The mechanics below live entirely in the Go operations — this is a compact map Renders each surface in `board_surfaces` (config; default `[inline]`) from the same one dependency-resolution pass, computed once. Readiness cells: a dependency-waiting change shows **⏳ waiting on #N — not yet built** or **⏳ waiting on #N — needs your merge**; a `proposed` change with no spec, not `trivial: true`, and not waiting shows **needs-grooming** — or **auto-groom blocked — needs you** when its body carries an `## Auto-groom blocked` section. -When `board_surfaces` includes `inline`, the docket app is the single gated writer of `BOARD.md`: every board-authoritative typed mutation re-renders it inside the owning metadata transaction and commits the result to the `docket` branch in the same commit as the record it reflects, only when it actually changed, so nothing else ever touches the file. This skill **never hand-edits `BOARD.md`, never hand-renders it, and never 3-way merges it**; on a rebase conflict, let the owning operation regenerate it — never a hand-merge — and continue. When `board_surfaces` omits `inline`, there is simply no board. Where present, `BOARD.md` is the live planning view and stays on `docket` — never published to the integration branch. +When `board_surfaces` includes `inline`, the docket app is the single gated writer of `BOARD.md`: every board-authoritative typed mutation re-renders it inside the owning metadata transaction and commits the result to the metadata branch in the same commit as the record it reflects, only when it actually changed, so nothing else ever touches the file. This skill **never hand-edits `BOARD.md`, never hand-renders it, and never 3-way merges it**; on a rebase conflict, let the owning operation regenerate it — never a hand-merge — and continue. When `board_surfaces` omits `inline`, there is simply no board. Where present, `BOARD.md` is the live planning view and stays on `docket` — never published to the integration branch. ### Merge sweep -The bulk safety net: every `implemented` change whose PR has merged gets archived on the `docket` branch and its branch cleaned up, chaining the same close-out sequence (`close-out.md`) `docket-finalize-change` uses. No archived record is copied onto the `integration_branch`. Runs inside the `maintenance.preflight` operation at implementation scope (`docket-implement-next` Step 0 runs that operation inline on its selection path — no id or an id set), and in full scope on any explicit refresh/cleanup invocation. +The bulk safety net: every `implemented` change whose PR has merged gets archived on the metadata branch and its branch cleaned up, chaining the same close-out sequence (`close-out.md`) `docket-finalize-change` uses. No archived record is copied onto the `integration_branch`. Runs inside the `maintenance.preflight` operation at implementation scope (`docket-implement-next` Step 0 runs that operation inline on its selection path — no id or an id set), and in full scope on any explicit refresh/cleanup invocation. The finalize gate lives in `docket-finalize-change`'s merge step and is **finalize-only** — the sweep only archives PRs that are already merged, it never merges, so the gate has nothing to act on here. @@ -121,6 +121,6 @@ The pass reads nothing and writes nothing under `learnings/`, and nothing refres ### Findings -Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the `docket` branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. +Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the metadata branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. One judgment check stays in-model, on top of the operations: `blocked_by:` re-examination (see *Judgment follow-ups* above) — warn-only, never auto-fix. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 35c2b3739..7a93a5972 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,7 +196,7 @@ var skillBudgets = []skillBudget{ {"docket-build/references/gate-driver.md", 138, 1552}, // 0502 review: the raw-verb section names finalize's repaired-head re-gate (137/1546 -> 138/1552); 0502: "loop" means /loop only (137/1550 -> 137/1546); 0502: finalize's repaired-head re-gate is a direct caller (136/1523 -> 137/1550); 0502: the gate-execution intro reference is deleted (138/1555 -> 136/1523); 0502: renamed from gate-caller-loop.md; 0497: +halted-gate tree-survives finding relay (134/1510 -> 138/1555); 0491: the run id is retired (134/1513 -> 134/1510); 0490: the admission section describes the supervisor-held worktree lock; launch-unconfirmed is no longer a refusal (136/1511 -> 134/1513); 0488 review fix: finalize reaches the driver only through finalize.rebase; task-era handoff clauses dropped (word ceiling 1504 -> 1511); 0488 review: start row carries --change-id (word ceiling 1491 -> 1504); 0488: worker-scope/takeover/acknowledge rows and the parent-takeover section removed; callers are the full-suite gates (175/1872 -> 136/1491); 0467: +prepare-scope --run-id and scope-inherited start run id (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/task-routing.md", 50, 500}, {"docket-build-task/SKILL.md", 168, 1640}, // 0531: the metadata worktree and remote come from the repository.prepare context (168/1637 -> 168/1640); 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) - {"docket-convention/SKILL.md", 359, 7155}, // 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) + {"docket-convention/SKILL.md", 359, 7160}, // 0531 review: skills say "the metadata branch", naming `docket` once for shared mode (359/7155 -> 359/7160); 0531: the metadata worktree and remote come from the repository.prepare context (357/7062 -> 359/7155); 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 0154: docket-convention/github-board-mirror.md removed — the GitHub mirror is // retired (unsupported, mutation-blocking); its budget row is deleted with it. {"docket-convention/references/agent-layer.md", 148, 1419}, // 0502: rewritten around user-level wrappers and global-only pins (202/2346 -> 148/1419); 0502: the legacy agents-file migration clause is deleted (202/2349 -> 202/2346) diff --git a/skills/docket-adr/SKILL.md b/skills/docket-adr/SKILL.md index 895bff8f9..9cda4fabb 100644 --- a/skills/docket-adr/SKILL.md +++ b/skills/docket-adr/SKILL.md @@ -43,7 +43,7 @@ One validated transaction lands atomically, in a single metadata commit: the nex **Return the number** — read the allocated ADR id from the operation's result envelope so the caller (e.g. `docket-implement-next` step 6) can cite it in the change's `adrs:` field. -**Where an ADR lives** — the ADR and its index live on the `docket` branch; no ADR, change-tied or standalone, is copied to the integration branch. +**Where an ADR lives** — the ADR and its index live on the metadata branch; no ADR, change-tied or standalone, is copied to the integration branch. ### Supersede / reverse @@ -59,7 +59,7 @@ adr.supersede --request - # resolve argv from the capability catalog - `target` — the ADR being replaced, as `{id, path, revision}`. The target must be `Accepted`, else the transaction refuses. - `successor` — the new ADR, as a full record request (the same fields as *Create*'s `ADRRecordRequest`; give it its own producing `change` if one exists). -One transaction lands atomically: the new ADR (carrying its `supersedes:`/`reverses:` edge to the old one), the old ADR's `status:` line flipped to `"Superseded by ADR-NN"` / `"Reversed by ADR-NN"` (its frozen body otherwise byte-for-byte unchanged — that status value is the **only** change to the old file), and the re-rendered index. There is no separate index commit. In the index the old ADR's row shows its `Superseded by ADR-NN` / `Reversed by ADR-NN` status, and the new ADR's row (in the Active group) shows `→ supersedes ADR-NN` / `→ reverses ADR-NN`. A typed conflict or refusal returns without writing — re-read and retry rather than hand-editing. The status flip lands on the `docket` branch with the re-rendered index. +One transaction lands atomically: the new ADR (carrying its `supersedes:`/`reverses:` edge to the old one), the old ADR's `status:` line flipped to `"Superseded by ADR-NN"` / `"Reversed by ADR-NN"` (its frozen body otherwise byte-for-byte unchanged — that status value is the **only** change to the old file), and the re-rendered index. There is no separate index commit. In the index the old ADR's row shows its `Superseded by ADR-NN` / `Reversed by ADR-NN` status, and the new ADR's row (in the Active group) shows `→ supersedes ADR-NN` / `→ reverses ADR-NN`. A typed conflict or refusal returns without writing — re-read and retry rather than hand-editing. The status flip lands on the metadata branch with the re-rendered index. ### Update note @@ -81,6 +81,6 @@ surfaces the drift as a structured finding (`adr-index-stale` when the bytes dif repository.repair # resolve argv from the capability catalog; previews only ``` -Repair re-renders only the canonical derived bytes it owns (marker order and balance validated first — a malformed index refuses and leaves the file untouched) and never edits an authored ADR body. It re-proves the pinned revision before writing and commits on the `docket` branch. On a git conflict on the index, re-run the repair rather than hand-merging (the regenerate-don't-3-way-merge rule). +Repair re-renders only the canonical derived bytes it owns (marker order and balance validated first — a malformed index refuses and leaves the file untouched) and never edits an authored ADR body. It re-proves the pinned revision before writing and commits on the metadata branch. On a git conflict on the index, re-run the repair rather than hand-merging (the regenerate-don't-3-way-merge rule). Validate the ledger the same way — the `repository.check` operation runs the ADR-ledger consistency findings (numbering gaps, dangling `supersedes:`/`reverses:`/`relates_to:` links, status inconsistencies) alongside the derived-view drift findings, each as one structured finding; a non-`Repairable` finding (illegal ADR evolution, a missing referenced record) is left for manual review. diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index f75d91d6a..a31d0b404 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -93,11 +93,11 @@ When dispatch is genuinely unavailable the kinds are **not** equivalent, so the | Fallback | Dispatch | Posture | |---|---|---| -| **`inline` — deterministic** | the `docket-status` and `docket-adr` composition dispatches | Run the same work **inline** — a **first-class equivalent path**, neither a degradation nor a warning, because the contract is git state on the `docket` branch, not an in-context return. Every obligation holds unchanged: re-sync before reading, derive from fresh origin, never adopt or commit another agent's uncommitted files. | +| **`inline` — deterministic** | the `docket-status` and `docket-adr` composition dispatches | Run the same work **inline** — a **first-class equivalent path**, neither a degradation nor a warning, because the contract is git state on the metadata branch, not an in-context return. Every obligation holds unchanged: re-sync before reading, derive from fresh origin, never adopt or commit another agent's uncommitted files. | | **`abstain` — adversarial** | the `docket-auto-groom-critic` gate | **Abstain**, per *Autonomous grooming*. Self-critique by the agent that drafted the spec is not an adversarial gate, and the abstain is a path that skill already owns. | | **`halt` — discipline** | the **plan-writer dispatch**, the `build` role skill's required nested dispatches, the controller's own review-tier dispatch (the `docket-review` skill itself never dispatches), and the in-branch fix workers (which run the `build` role's own contract) | **`halt`** — the run stops: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded through `change.halt`. **No new status, no new field** — the reclaim lease self-heals an abandoned claim. | -**Outside the table — `no-fallback`.** `docket-finalize-change`'s two finalize-gate dispatches — `docket-rebase-resolver` and `docket-integration-repair` — sit **outside** this table rather than in a row of it, because their contract is an **in-context report** gating the merge, not git state on the `docket` branch. Neither fallback can be borrowed for them: `inline`'s first-class-equivalent path presupposes a git-state transition to reproduce, and `halt` belongs to the workflow roles, and these dispatches are not a role. When dispatch is genuinely unavailable for either — established per the resolution rule above, never from a tool name — the posture is finalize's own pre-existing **abort-and-report**: the gate stops, the PR stays open, the change stays `implemented`, and the reason is recorded through the three channels `docket-finalize-change`'s failure reference owns. **Inline substitution is forbidden** for both, and that is the point of `no-fallback` rather than a table row: reconciling the conflict, or authoring the repair, inside the very agent that would then merge that work is the same self-approval shape `abstain` exists to avoid for the critic. +**Outside the table — `no-fallback`.** `docket-finalize-change`'s two finalize-gate dispatches — `docket-rebase-resolver` and `docket-integration-repair` — sit **outside** this table rather than in a row of it, because their contract is an **in-context report** gating the merge, not git state on the metadata branch. Neither fallback can be borrowed for them: `inline`'s first-class-equivalent path presupposes a git-state transition to reproduce, and `halt` belongs to the workflow roles, and these dispatches are not a role. When dispatch is genuinely unavailable for either — established per the resolution rule above, never from a tool name — the posture is finalize's own pre-existing **abort-and-report**: the gate stops, the PR stays open, the change stays `implemented`, and the reason is recorded through the three channels `docket-finalize-change`'s failure reference owns. **Inline substitution is forbidden** for both, and that is the point of `no-fallback` rather than a table row: reconciling the conflict, or authoring the repair, inside the very agent that would then merge that work is the same self-approval shape `abstain` exists to avoid for the critic. The *Skill layer*'s **missing-skill rule** and `halt` are two conditions with two postures: a role skill that cannot be **invoked** is done inline by the running agent, with a prominent warning, while an invoked role skill whose required nested **dispatch** cannot run is `halt`. One symptom in the run log, two causes. @@ -109,7 +109,7 @@ Each **autonomous** docket skill can run as a model/effort-pinned **subagent** i A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on the metadata remote, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on the metadata branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -120,7 +120,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: | Role | Skill | Invoked by | Final artifact — stop-point | |---|---|---|---| | brainstorm | `superpowers:brainstorming` | `docket-new-change` §2, `docket-groom-next` | a spec file at the configured spec path; stop at the spec | -| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the `docket` branch, recorded in `plan:` | +| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the metadata branch, recorded in `plan:` | | build | `docket-build` | `docket-implement-next` §5 | the plan executed on the feature branch | | review | `docket-review` | `docket-implement-next` §6 | a whole-branch review before the PR opens, over a branch whose build evidence is green | @@ -154,7 +154,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: <adrs_dir>/ # default docs/adrs/ — flat; ADRs are NEVER archived <NNNN>-<slug>.md # immutable once Accepted (only its status: line ever changes) README.md # generated ADR index -<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the `docket` branch; NEVER archived) +<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the metadata branch; NEVER archived) <YYYY-MM-DD>-<slug>-results.md ``` @@ -162,7 +162,7 @@ The `archive/` filename date prefix is **UTC**: the **merge commit's** date for All of the above lives on the metadata branch, written through the persistent **metadata worktree**: `<primary>/.docket/` in a shared repository (gitignored, deliberately not under `.worktrees/` — slug collisions, prune blast radius), or a checkout outside the clone in a private one; skills always use `metadata_worktree_path` (see *Branch model*). -**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. +**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the metadata branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. ### Change manifest (frontmatter at the top of each change file) @@ -183,9 +183,9 @@ stacked_on: # optional: parent change id whose branch this one is related: [4, 6] # cross-links the reconcile pass reads discovered_from: [62] # change id(s) whose work surfaced this one; informational like related:, never a readiness gate adrs: [24] # ADRs this change cites or produces -spec: # superpowers design doc path; set at brainstorm (propose) time, on the `docket` branch -plan: # plan FILE on the `docket` branch; FILE and FIELD written together by change.attach-plan at build time -results: # results FILE on the `docket` branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition +spec: # superpowers design doc path; set at brainstorm (propose) time, on the metadata branch +plan: # plan FILE on the metadata branch; FILE and FIELD written together by change.attach-plan at build time +results: # results FILE on the metadata branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition trivial: false # true = no spec needed (small mechanical change); still build-ready auto_groomable: # `true` makes a stub auto-groomable; unset or `false` means not branch_prefix: # optional one unqualified branch-path component; overrides <type> at mint, consumed only at claim, inert once branch: is populated, survives reclaim @@ -214,7 +214,7 @@ change, never in the frozen artifact. `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the metadata branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. @@ -279,7 +279,7 @@ An `Accepted` ADR is immutable except its `status:` line; a non-reversing contex | `done` | PR merged, filed away (happy final status) | `archive/` | | `killed` | abandoned — obsolete or never shipped (sad final status) | `archive/` | -**Rules.** `active/` holds every non-final status; `archive/` holds the two final outcomes. `stacked-merged` is non-final for that reason: merged into its stack parent, not the integration branch, so it stays in `active/` until the stack close-out promotes it when the stack root lands. The single physical move (`active/ → archive/`, date-prefixed) happens once on the final transition and is **idempotent**: re-pull, re-read `status` on the `docket` branch, no-op if already final. `deferred` may be entered from `proposed` or `in-progress` (add `## Why deferred`) and revived to `proposed`; clear a blocker with `change.unblock` (`blocked` → `in-progress`, clearing `blocked_by`) and revive with `change.revive` (`deferred` → `proposed`) — typed operations, no file move. A change whose `depends_on` is unsatisfied is *implicitly* blocked — the selector skips it and the board shows it **waiting on #N**. A dependency is **satisfied when it reaches `done`**; if `#N` is still `implemented` the board flags **waiting on #N — needs your merge**, distinct from **waiting on #N — not yet built**. Reserve explicit `blocked` for external blockers the system can't infer. +**Rules.** `active/` holds every non-final status; `archive/` holds the two final outcomes. `stacked-merged` is non-final for that reason: merged into its stack parent, not the integration branch, so it stays in `active/` until the stack close-out promotes it when the stack root lands. The single physical move (`active/ → archive/`, date-prefixed) happens once on the final transition and is **idempotent**: re-pull, re-read `status` on the metadata branch, no-op if already final. `deferred` may be entered from `proposed` or `in-progress` (add `## Why deferred`) and revived to `proposed`; clear a blocker with `change.unblock` (`blocked` → `in-progress`, clearing `blocked_by`) and revive with `change.revive` (`deferred` → `proposed`) — typed operations, no file move. A change whose `depends_on` is unsatisfied is *implicitly* blocked — the selector skips it and the board shows it **waiting on #N**. A dependency is **satisfied when it reaches `done`**; if `#N` is still `implemented` the board flags **waiting on #N — needs your merge**, distinct from **waiting on #N — not yet built**. Reserve explicit `blocked` for external blockers the system can't infer. **Reclaim edge (`in-progress → proposed`).** An `in-progress` change whose claim lease (`claimed_at:` + `reclaim.lease_ttl`) has expired AND that has no feature branch is flipped back to `proposed` by the reclaim operation (opt-in via `reclaim.auto`, or an explicit `change.reclaim`), clearing `branch:`/`claimed_at:` and resetting `reconciled: false` so a fresh reconcile runs on re-claim. The has-branch case is never auto-reclaimed (it may carry real work) — it stays flagged for a human. @@ -307,7 +307,7 @@ minted or discarded** — a human captures reported work deliberately with `dock ### Learnings ledger `<changes_dir>/learnings/` — the project's **learnings ledger**: one curated finding per file, -on the `docket` branch only, never published to the integration branch. `LEARNINGS.md` remains as a +on the metadata branch only, never published to the integration branch. `LEARNINGS.md` remains as a pointer stub to the earlier single-file ledger. The finding files are written by the `learning.record` and `learning.update` operations; the index (`learnings/README.md`) is a **derived view** that no operation refreshes, and its recorded bytes are what readers consult. @@ -348,12 +348,12 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr ### Branch model -Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. +Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch (`docket` in a shared repository) via the persistent **metadata worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to the metadata remote immediately**: `origin` in a shared repository (the planning surface stays browsable there), the local `dckt` bare remote in a private one. The `repository.prepare` context values — `metadata_worktree_path`, `metadata_remote`, `metadata_tracking_ref` — are authoritative; never assume a worktree path or remote. A private repository (`.git/dckt/` present) spells the metadata branch and remote `dckt`, keeps its config in `.git/dckt/config.yml`, and never reads `.docket.yml`. -A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. +A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the metadata branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. The metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. -On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. +On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the metadata branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/skills/docket-convention/references/close-out.md b/skills/docket-convention/references/close-out.md index 81cb2c9d7..cfdb72deb 100644 --- a/skills/docket-convention/references/close-out.md +++ b/skills/docket-convention/references/close-out.md @@ -31,7 +31,7 @@ immediately. ``` `--input` carries only the optional authored closeout notes (`verification_outcomes`, - `late_findings`; `-` for stdin); the `results:` file already sits on the `docket` branch and is + `late_findings`; `-` for stdin); the `results:` file already sits on the metadata branch and is never passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a stack) ⇒ the change is marked done and relocated to the dated archive path — idempotent if already archived, including across a day boundary. This ONE metadata commit atomically owns the diff --git a/skills/docket-convention/references/learnings.md b/skills/docket-convention/references/learnings.md index a28bbb2df..2de1ba019 100644 --- a/skills/docket-convention/references/learnings.md +++ b/skills/docket-convention/references/learnings.md @@ -44,7 +44,7 @@ promoted_to: # set only when promoted: the agent-instruct The `learning.record` operation **creates** a new finding (`slug`, `hook`, `topics`, `changes`, `## Apply`, `## War story`) under its `request_id` idempotency key; the `learning.update` operation **extends** an existing one, pinned by its exact record revision (append a dated -`## War story` entry, add the change id to `changes:`). Both write only the one finding file on the `docket` branch and refuse +`## War story` entry, add the change id to `changes:`). Both write only the one finding file on the metadata branch and refuse when `learnings.enabled` is not `true`. Neither **merges two distinct findings** — that is a human act. Zero findings is normal; kills are not recorded. @@ -52,7 +52,7 @@ act. Zero findings is normal; kills are not recorded. Tiering criterion: *"will the agent know to search for this?"* A rule that must fire **unprompted** graduates; a war story stays in retrieval. A candidate carries -`promotion_state: candidate` on the `docket` branch and **never touches the integration branch** +`promotion_state: candidate` on the metadata branch and **never touches the integration branch** (ADR-0005). A human lands the graduation in the integration-branch agent-instructions file (`AGENTS.md`/`CLAUDE.md`, symlink-aware; `AGENTS.md` is the neutral spelling when neither exists) and flips `promoted` + `promoted_to:`. A promoted finding leaves the topic groups for a diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index f20360103..1fc7507fc 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -73,7 +73,7 @@ workspace.prepare --id <id> --revision <revision> # resolve argv from the cap The `workspace.prepare` operation reloads the authoritative snapshot, resolves the effective base through the landed domain policy (the app adds NO base or stack policy of its own), constructs the workspace target, and returns the canonical owned workspace path, exact base commit, feature ref, and disposition. It requires the change to be `in-progress` at that exact `--revision` (a losing claimant creates no workspace); a `contended`/`unknown` disposition passes through verbatim, never a force. For a change carrying `stacked_on:`, the resolved base is the parent's effective base, not `origin/<integration_branch>`, and the step-7 PR targets it; **read [`../docket-convention/references/stacked-changes.md`](../docket-convention/references/stacked-changes.md) now (blocking)**. The `workspace.inspect` operation with `--id <id>` re-reads a prepared or resumed workspace (ownership + live-Git checks) idempotently. -the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/<integration_branch>` in the ordinary case). +the metadata branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/<integration_branch>` in the ordinary case). **Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync the metadata worktree immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id <id>`: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id <id> --revision <revision>` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. @@ -110,7 +110,7 @@ It also contains the branch and base ref, change title and scope, relevant learn ### Step 6.5 — Results (required) -The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the `docket` branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. +The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the metadata branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. **Human actions and testing** labels every item **Important** or **Optional**, written for a mid-level engineer with little knowledge of Docket internals. An **Important** item marks a meaningful verification gap or necessary human judgment (never a new automated merge requirement) — give the reason, when the action matters, and what remains uncertain if skipped. An Optional walkthrough MAY exercise behavior automated tests already cover — a routine suite rerun is not a functional walkthrough. Every item carries prerequisites and setup, concrete steps with observable expected results, and cleanup when it changes persistent state — complete enough to run without reconstructing missing commands. Do not infer coverage from a green suite: inspect the relevant tests and evidence, and where coverage is genuinely uncertain report that uncertainty under Known issues and follow-ups or Verification performed rather than authoring a speculative checklist. The section holds only what a human should do or check — never a record of what the run already checked, which belongs under Verification performed. @@ -153,12 +153,12 @@ Each step below is complete only when its row holds — read from **git**, never | Step | Complete only when | |---|---| -| 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the `docket` branch **and landed** (local tip == remote tip). | -| 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the `docket` branch — or, on the kill path, the change archived. | -| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `<type>/<slug>` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the `docket` branch, the feature HEAD unchanged by planning. | +| 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the metadata branch **and landed** (local tip == remote tip). | +| 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the metadata branch — or, on the kill path, the change archived. | +| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `<type>/<slug>` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the metadata branch, the feature HEAD unchanged by planning. | | 5 Build | the executed plan committed on `<type>/<slug>`, with a build-evidence record at `result: green` (or `skipped` / `build-gate-off` under `build.gate: off`) whose `head_sha` **equals branch HEAD**. | | 6 Review + ADRs | that record still present at `head_sha` == HEAD **after** any fix commits, and every ADR the run produced landed in `adrs:`. Known-weak row: on a clean review this reduces to Step 5's, because whether a reviewer ran is not a fact about git. | -| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink on the `docket` branch, and the record's `## Build evidence` section naming the PR head. | +| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the metadata branch; `results:` set and the final results file with its backlink on the metadata branch, and the record's `## Build evidence` section naming the PR head. | Rows 3, 4, 6 and 7 each carry, in addition, the **field-write rule**'s conditions on every metadata commit they name: the `claimed_at` re-stamp, and — for any link-bearing field write — the regenerated `## Artifacts` block in the same commit. @@ -173,7 +173,7 @@ Every run ends by declaring exactly **one** of four dispositions, so any driver | `advanced` | Built a change → PR opened (Step 7 reached). | continue | | `contended` | Selected a change but lost the conflict-checked claim (Step 2); **nothing built**. | continue — re-select next | | `drained` | No build-ready+claimable change in scope (Step 1's empty queue). | **stop** | -| `halted` | Stopped needing a human — fundamentally-invalidated design (Step 3) or a hard error. Recorded on the `docket` branch as a `## Run halted` section through the `change.halt` operation — see Step 3 for the write; the `run.verify` operation reads it back as the `run-halted` verdict. | **stop + surface** | +| `halted` | Stopped needing a human — fundamentally-invalidated design (Step 3) or a hard error. Recorded on the metadata branch as a `## Run halted` section through the `change.halt` operation — see Step 3 for the write; the `run.verify` operation reads it back as the `run-halted` verdict. | **stop + surface** | The driver's decision is binary: **continue on `advanced`/`contended`, stop on `drained`/`halted`.** The contract is **driver-agnostic** — it names run outcomes, not any one driver's mechanics; `/loop` is *recommended*, not required (see the README drain-pattern doc). @@ -209,4 +209,4 @@ New change ⇒ the `workspace.prepare` operation with `--id <id> --revision <rev Metadata commits (per the **field-write rule**) happen in the metadata working tree; only code commits happen in the **feature worktree** on `<type>/<slug>` — the plan and results files and their fields are metadata writes through their attach transactions, and build evidence lands in the change record. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree write is `workspace.commit-spec`'s spec copy, an operation-owned commit.) -This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/<integration_branch>` and the `docket` branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. +This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/<integration_branch>` and the metadata branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. diff --git a/skills/docket-implement-next/references/edge-paths.md b/skills/docket-implement-next/references/edge-paths.md index 6f932067e..13ee3756a 100644 --- a/skills/docket-implement-next/references/edge-paths.md +++ b/skills/docket-implement-next/references/edge-paths.md @@ -102,8 +102,8 @@ to force a workspace, a commit, or a HEAD move. **Best-effort PR→issue reference.** If the change carries an `issue:` value, add a plain `#<issue>` reference to the PR body — but **never `Closes #N`**, so merging the PR never auto-closes the referenced issue. Skip silently when `issue:` is unset — the reference is a one-time courtesy, not a build gate. -**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. +**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the metadata branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. **Review outcome.** The authored body names the tier that reviewed and carries the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, recorded, or reported. The table's states are defined in `fix-pass.md`; do not redefine them here. -**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the `docket` branch; never author it by hand. +**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the metadata branch; never author it by hand. diff --git a/skills/docket-new-change/SKILL.md b/skills/docket-new-change/SKILL.md index 085297768..f501f7adb 100644 --- a/skills/docket-new-change/SKILL.md +++ b/skills/docket-new-change/SKILL.md @@ -30,7 +30,7 @@ The default path for any non-trivial new change. Five steps: 1. **Allocate** — id allocation is owned by the `change.create` operation transaction (step 4): it reads the next id from fresh origin state, derives the slug from the title, serializes the canonical proposed record, and lands it under an exact-lease push — so there is **no** hand-scan of `active/` + `archive/`, no `max + 1` by hand, and no manual rename-on-collision. Supply a stable `request_id` on the create request so a lost-response retry converges by idempotent replay rather than minting a duplicate. The startup-check `repository.prepare` operation already synced the metadata working tree. -2. **Brainstorm** — invoke `superpowers:brainstorming` WITH THE HUMAN. This is the decision point. When `superpowers:brainstorming` cannot be invoked, run the brainstorm inline with the human, warning prominently; the artifact is unchanged: a spec, then stop. If the human asks for a consultant-written spec, invoke `docket-brainstorm` for this run instead — human steering of an interactive session always wins (see the README's consultant-brainstorm section). STOP AT THE SPEC — do NOT continue to `writing-plans` (that is build-time). The settled design becomes the **spec's Markdown body**, authored and held for step 5 — do NOT hand-write the spec file, hand-set `spec:`, or hand-render the `## Artifacts`/`docket:backlink` blocks: the step-5 `change.groom` operation writes the spec file under `docs/superpowers/specs/…` on the `docket` branch, sets `spec:`, stamps the reciprocal `docket:backlink` block, and re-renders the `## Artifacts` block atomically. A typed refusal (malformed markers, spec-path-taken) leaves the files untouched — surface it, do not hand-edit. +2. **Brainstorm** — invoke `superpowers:brainstorming` WITH THE HUMAN. This is the decision point. When `superpowers:brainstorming` cannot be invoked, run the brainstorm inline with the human, warning prominently; the artifact is unchanged: a spec, then stop. If the human asks for a consultant-written spec, invoke `docket-brainstorm` for this run instead — human steering of an interactive session always wins (see the README's consultant-brainstorm section). STOP AT THE SPEC — do NOT continue to `writing-plans` (that is build-time). The settled design becomes the **spec's Markdown body**, authored and held for step 5 — do NOT hand-write the spec file, hand-set `spec:`, or hand-render the `## Artifacts`/`docket:backlink` blocks: the step-5 `change.groom` operation writes the spec file under `docs/superpowers/specs/…` on the metadata branch, sets `spec:`, stamps the reciprocal `docket:backlink` block, and re-renders the `## Artifacts` block atomically. A typed refusal (malformed markers, spec-path-taken) leaves the files untouched — surface it, do not hand-edit. 3. **Scan related context** — scan neighbouring changes (`active/` + recent `archive/`) and the ADR index to pre-fill `related`, `depends_on`, `adrs`, and — when the human names the change(s) whose work surfaced this one (or scan mode infers an origin) — `discovered_from` (informational, like `related:`; empty for deliberately planned work). In practice, do this quick read just *before* step 2 so the brainstorm is informed by neighbouring work; record the resulting `related`/`depends_on`/`adrs`/`discovered_from` after the design settles. diff --git a/skills/docket-status/SKILL.md b/skills/docket-status/SKILL.md index 2a3866aae..7d5da142f 100644 --- a/skills/docket-status/SKILL.md +++ b/skills/docket-status/SKILL.md @@ -105,11 +105,11 @@ The mechanics below live entirely in the Go operations — this is a compact map Renders each surface in `board_surfaces` (config; default `[inline]`) from the same one dependency-resolution pass, computed once. Readiness cells: a dependency-waiting change shows **⏳ waiting on #N — not yet built** or **⏳ waiting on #N — needs your merge**; a `proposed` change with no spec, not `trivial: true`, and not waiting shows **needs-grooming** — or **auto-groom blocked — needs you** when its body carries an `## Auto-groom blocked` section. -When `board_surfaces` includes `inline`, the docket app is the single gated writer of `BOARD.md`: every board-authoritative typed mutation re-renders it inside the owning metadata transaction and commits the result to the `docket` branch in the same commit as the record it reflects, only when it actually changed, so nothing else ever touches the file. This skill **never hand-edits `BOARD.md`, never hand-renders it, and never 3-way merges it**; on a rebase conflict, let the owning operation regenerate it — never a hand-merge — and continue. When `board_surfaces` omits `inline`, there is simply no board. Where present, `BOARD.md` is the live planning view and stays on `docket` — never published to the integration branch. +When `board_surfaces` includes `inline`, the docket app is the single gated writer of `BOARD.md`: every board-authoritative typed mutation re-renders it inside the owning metadata transaction and commits the result to the metadata branch in the same commit as the record it reflects, only when it actually changed, so nothing else ever touches the file. This skill **never hand-edits `BOARD.md`, never hand-renders it, and never 3-way merges it**; on a rebase conflict, let the owning operation regenerate it — never a hand-merge — and continue. When `board_surfaces` omits `inline`, there is simply no board. Where present, `BOARD.md` is the live planning view and stays on `docket` — never published to the integration branch. ### Merge sweep -The bulk safety net: every `implemented` change whose PR has merged gets archived on the `docket` branch and its branch cleaned up, chaining the same close-out sequence (`close-out.md`) `docket-finalize-change` uses. No archived record is copied onto the `integration_branch`. Runs inside the `maintenance.preflight` operation at implementation scope (`docket-implement-next` Step 0 runs that operation inline on its selection path — no id or an id set), and in full scope on any explicit refresh/cleanup invocation. +The bulk safety net: every `implemented` change whose PR has merged gets archived on the metadata branch and its branch cleaned up, chaining the same close-out sequence (`close-out.md`) `docket-finalize-change` uses. No archived record is copied onto the `integration_branch`. Runs inside the `maintenance.preflight` operation at implementation scope (`docket-implement-next` Step 0 runs that operation inline on its selection path — no id or an id set), and in full scope on any explicit refresh/cleanup invocation. The finalize gate lives in `docket-finalize-change`'s merge step and is **finalize-only** — the sweep only archives PRs that are already merged, it never merges, so the gate has nothing to act on here. @@ -121,6 +121,6 @@ The pass reads nothing and writes nothing under `learnings/`, and nothing refres ### Findings -Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the `docket` branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. +Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the metadata branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. One judgment check stays in-model, on top of the operations: `blocked_by:` re-examination (see *Judgment follow-ups* above) — warn-only, never auto-fix.