From 771dde09d2a65485c8067439d328df115efe41e7 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 11:04:50 -0400 Subject: [PATCH 01/13] Add design spec: Implement private visibility for PRs, commits, and shipped files --- ...et-out-of-prs-commits-and-shippe-design.md | 117 ++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 docs/superpowers/specs/2026-10-05-private-visibility-keep-docket-out-of-prs-commits-and-shippe-design.md diff --git a/docs/superpowers/specs/2026-10-05-private-visibility-keep-docket-out-of-prs-commits-and-shippe-design.md b/docs/superpowers/specs/2026-10-05-private-visibility-keep-docket-out-of-prs-commits-and-shippe-design.md new file mode 100644 index 000000000..42d3d89c8 --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-private-visibility-keep-docket-out-of-prs-commits-and-shippe-design.md @@ -0,0 +1,117 @@ +# Implement private visibility for PRs, commits, and shipped files: design + +Change 0532 — Implement private visibility for PRs, commits, and shipped files + +Change #532, groomed interactively on 2026-10-05. Third in the private-visibility series (#529–#533). Depends on #530, which moves build artifacts off the feature branch, and #531, which adds private mode itself. + +## Summary + +In a private repository, other people see what docket ships: feature branches, PRs, commits, the spec copy, and code. None of them use docket. After #530 and #531, the remaining traces are PR-description blocks and lines, finalize's PR comments, and model habits (change ids in commit subjects and PR titles, docket vocabulary in shipped text). Repository-level instruction files are #535's concern. + +This change removes those traces and adds a **blocking** leak check as the backstop, in private repositories only. The goal is that nothing reaching `origin` through a private repository's feature branch or PR contains a docket or `dckt` fingerprint. + +Two related pieces were split out of this change: #535 loads a private repository's agent instructions without repository files, and #534 installs the `dckt` alias it relies on. + +## Evidence gathered at grooming + +- **PR body.** + - `pr.publish` (`assemblePRBody`, `internal/app/pr_publish.go`) always inserts the backlink block rendered by `render.BacklinkContent`. #530 removes the evidence block. + - `skills/docket-implement-next/references/edge-paths.md` adds a `↩ Change — ` line and a best-effort `#<issue>` reference. + - The coordinator writes PR titles freely; an observed habit is a `(0507)` suffix. +- **PR comments.** `finalize.block` posts a comment carrying the docket attempt marker (`finalizeBlockedCommentMarker` in `internal/app/finalize_block.go`, through `githubcli.Client.EnsureComment`). +- **Commit text.** + - `skills/docket-build-task/SKILL.md` ("The commit") sets no message format. + - The fix-pass reference (`skills/docket-implement-next/references/fix-pass.md`) produces fix commits. + - Observed `docs(plan): change 0507 …` subjects are inherited habit, not a template, and change ids leak into commit subjects through plan text. +- **Spec copy.** #530 ships the spec as the feature branch's first commit. Specs habitually carry `Change #N, groomed …`, `.docket/` paths, ADR numbers, and backlog references. +- **Feature-branch pushes** go through `workspace.Service.PublishHead` (`internal/workspace/publish.go`). PR create and edit go through `pr.publish`. Finalize's force-push after a rebase goes through `finalize.publish`. + +## Decisions (settled with the human) + +1. In private repositories PR descriptions are plain authored prose: no backlink block, no evidence block, no change line, no `#issue` reference. +2. A finalize block in a private repository is recorded on the change record (`## Finalize blocked`) only. No PR comment is posted. +3. Writing rules apply to everything that ships: no docket or `dckt` vocabulary and no change ids. +4. The leak check **blocks**, in private repositories only. This is a deliberate exception to docket's report-only default for new checks: a pushed leak is irreversible and outward-facing, and the check cannot affect shared repositories. +5. The check also matches `dckt`. Matching the bare word "docket" can be switched off for codebases where it is an ordinary domain word (court or shipping dockets). +6. A `docket` or `dckt` branch appearing on `origin` in a private repository is reported, never blocked. +7. Sparse documentation: `.docket.example.yml`, command help, and skills only. +## Design + +### 1. PR text and comments + +- `pr.publish` in a private repository submits the authored body verbatim, inserting no docket-owned block. `finalize.publish` and `evidence.recertify` make no PR-body writes in private repositories; after #530 the evidence lives in the record anyway. +- The implement-next PR-authoring instructions omit the `↩ Change` line and the `#issue` reference in private repositories. The title carries no change id. +- `finalize.block` in a private repository records `## Finalize blocked` on the change and skips `EnsureComment`. Comment idempotency keyed on the marker is unaffected in shared repositories. +- Retargeting stacked children's PR bases carries no text, so it is unchanged. + +### 2. Writing rules + +One rule, stated once in docket-convention and referenced from each caller: + +> In a private repository, nothing that ships through the feature branch or the PR may contain the words "docket" or "dckt", docket marker comments, `Docket-` trailers, `.docket`/`dckt` paths, change ids or change references ("change 0612", "#0612", "(0612)"), docket ADR numbers, or references to the backlog or board. The metadata branch's own files (plan, results, records) are exempt and must not be copied from. + +The rule applies to: + +- **Grooming in private repositories:** `docket-new-change`, `docket-groom-next`, `docket-auto-groom`. The spec ships, so the spec body follows the rule, and the `Change #N, groomed …` line is omitted. #530's spec-copy operation omits its `Change NNNN — title` line in private repositories. +- **Build-task and fix workers:** commit messages, code, comments, and test names. +- **The implement-next coordinator:** PR title and description. + +The repository's mode reaches each worker through the payload or context it already receives. Shared repositories are unaffected. + +### 3. The leak check + +- **Where:** before any feature-branch push (`workspace.publish` / `PublishHead`, and `finalize.publish`'s post-rebase push) and before any PR create or edit (`pr.publish`). Private repositories only. +- **What it scans:** + - every commit in `<effective base>..<head>`: subject, body, and trailers; + - every added line in that range's diff, and added file paths; + - the PR title and description. + - The spec copy is an added file, so it is covered. +- **Patterns** (case-insensitive; the plan finalizes exact expressions with tests for each): + - the word `dckt`; + - the word `docket`, unless `leak_check.match_word: false`; + - `docket:` and `dckt:` marker prefixes; + - `Docket-` trailer keys; + - `.docket` and `.git/dckt` path fragments; + - change references to ids that exist in the backlog ("change 0612", zero-padded `#0612` and `(0612)`). The zero-padded form avoids matching years. +- **Outcome.** A typed refusal (new finding code `leak-detected`) lists each hit as {commit, file, line, matched text, rule}. Nothing is pushed and no PR is created or edited. The coordinator ends the run `halted` through `change.halt`, carrying the hit list. Nothing retries automatically. A human, or a later fix run, rewords the commit or line and re-runs. +- **Config.** `leak_check.match_word` (bool, default `true`) is an ordinary key in all layers, consulted only in private repositories and documented in `.docket.example.yml`. +- **Shared repositories never invoke the scanner.** A test pins that a shared-mode publish performs no scan. + +### 4. Metadata on the shared remote + +In a private repository, `repository check` and `repository prepare` probe `origin` for a `docket` or `dckt` branch and report a warning finding (`metadata-on-shared-remote`). The remedy says to delete the branch, or, once #533 lands, to use `set-visibility private --delete-shared-branch`. The finding is report-only. + +### 5. Documentation + +- `leak_check.match_word` is documented in `.docket.example.yml` and its twin. +- Skills carry the writing rule and the leak-check halt handling. +- No `docs/` pages. + +## Acceptance criteria + +1. **Plain PR.** A private-repository run produces a PR whose description is exactly the authored prose, whose title has no change id, and whose commits contain no fingerprint. +2. **Each seeded leak is refused before anything reaches `origin`:** + - a commit subject with "(0612)"; + - an added line containing `.docket/`; + - a spec copy containing "docket"; + - a PR title with "change 0612"; + - a commit containing `dckt`. + + The run halts with the hit list. +3. **`leak_check.match_word: false`** lets the bare word "docket" through, and still blocks markers, trailers, paths, `dckt`, and change ids. +4. **Year and ADR safety.** A year such as "(2026)" and a host repository's own ADR references are not matched. +5. **Shared mode unchanged.** Shared-mode PR bodies and finalize comments are unchanged, and no scan runs (pinned). +6. **Finalize block.** In a private repository it posts no PR comment and records `## Finalize blocked`. +7. **Shared-remote finding.** A `docket` or `dckt` branch on `origin` of a private repository produces the warning finding, with no refusal. +8. **Mutation.** Removing the scanner call before a push turns a test red. +## ADRs expected + +The leak check blocks: a deliberate, scoped exception to report-only checks, justified because a pushed leak is irreversible and outward-facing. + +## Out of scope + +- The metadata branch's content, which stays private and may use docket vocabulary freely. +- Rewriting already-pushed history. +- Shared-mode PR bodies and comments. +- Scanning for references to the host repository's own ADRs or tickets. +- Agent instructions for private repositories (#535) and the `dckt` alias (#534). From 94cfd2dfd6abceaa3328e3952a0e8610e0eb7c7d Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 11:34:12 -0400 Subject: [PATCH 02/13] feat(leakscan): fingerprint rules for text a private repository publishes --- internal/leakscan/leakscan.go | 285 +++++++++++++++++++++++++++++ internal/leakscan/leakscan_test.go | 98 ++++++++++ 2 files changed, 383 insertions(+) create mode 100644 internal/leakscan/leakscan.go create mode 100644 internal/leakscan/leakscan_test.go diff --git a/internal/leakscan/leakscan.go b/internal/leakscan/leakscan.go new file mode 100644 index 000000000..2b2e24409 --- /dev/null +++ b/internal/leakscan/leakscan.go @@ -0,0 +1,285 @@ +// Package leakscan holds the fingerprint rules for text a private repository +// publishes: commit messages, added paths and lines, and PR text. It is pure +// and stdlib-only. Each line yields at most one hit; the rules are tried in a +// fixed order (marker, trailer, path, alias, change-ref, word). +package leakscan + +import ( + "strconv" + "strings" +) + +// Rule names the fingerprint rule a match came from. +type Rule string + +const ( + RuleMarker Rule = "marker" // docket:/dckt: followed by a letter + RuleTrailer Rule = "trailer" // Docket-<Key>: at line start + RulePath Rule = "path" // .docket or .git/dckt + RuleAlias Rule = "alias" // the word dckt + RuleChangeRef Rule = "change-ref" // a reference to a backlog change id + RuleWord Rule = "word" // the bare word docket (Options.MatchWord) +) + +// Source names where a scanned piece of text came from. +type Source string + +const ( + SourceCommitMessage Source = "commit-message" + SourceAddedLine Source = "added-line" + SourceAddedPath Source = "added-path" + SourcePRTitle Source = "pr-title" + SourcePRBody Source = "pr-body" +) + +// Options configures a scan. MatchWord enables the bare-word docket rule; +// ChangeIDs is the backlog's change ids, and a nil map matches no change +// reference. +type Options struct { + MatchWord bool + ChangeIDs map[int]bool +} + +// Match is one line's fingerprint: the matched text only and its rule. +type Match struct { + Text string + Rule Rule +} + +// Hit is an attributed match. Commit is set for a commit-message hit, File for +// an added line or path; Line is 1-based (0 for a path); Text is the matched +// text only, never the whole line. +type Hit struct { + Source Source + Commit string + File string + Line int + Text string + Rule Rule +} + +// Commit is one outgoing commit's id and full message. +type Commit struct{ ID, Message string } + +// AddedLine is one line a push adds, with its file and 1-based line number. +type AddedLine struct { + Path string + Line int + Text string +} + +// PRText is a pull request's title and body. +type PRText struct{ Title, Body string } + +// Input is everything one publish exposes. PR is nil when no PR text is written. +type Input struct { + Commits []Commit + AddedPaths []string + AddedLines []AddedLine + PR *PRText +} + +// Scan checks every input in order: each commit message, each added path, each +// added line, then the PR title and body when PR is non-nil. +func Scan(in Input, opts Options) []Hit { + var hits []Hit + for _, c := range in.Commits { + for _, h := range Text(SourceCommitMessage, c.Message, opts) { + h.Commit = c.ID + hits = append(hits, h) + } + } + for _, p := range in.AddedPaths { + if m, ok := Line(p, opts); ok { + hits = append(hits, Hit{Source: SourceAddedPath, File: p, Text: m.Text, Rule: m.Rule}) + } + } + for _, l := range in.AddedLines { + if m, ok := Line(l.Text, opts); ok { + hits = append(hits, Hit{Source: SourceAddedLine, File: l.Path, Line: l.Line, Text: m.Text, Rule: m.Rule}) + } + } + if in.PR != nil { + hits = append(hits, Text(SourcePRTitle, in.PR.Title, opts)...) + hits = append(hits, Text(SourcePRBody, in.PR.Body, opts)...) + } + return hits +} + +// Text splits text on "\n", trims a trailing "\r", and checks each line, +// numbering lines from 1. +func Text(src Source, text string, opts Options) []Hit { + var hits []Hit + for n, line := range strings.Split(text, "\n") { + if m, ok := Line(strings.TrimSuffix(line, "\r"), opts); ok { + hits = append(hits, Hit{Source: src, Line: n + 1, Text: m.Text, Rule: m.Rule}) + } + } + return hits +} + +// Line checks one line, trying marker, trailer, path, alias, change-ref, and +// word, in that order, and returns the first match. +func Line(line string, opts Options) (Match, bool) { + low := asciiLower(line) + for _, w := range []string{"docket", "dckt"} { + for _, i := range wordAt(line, low, w) { + j := i + len(w) + if j+1 < len(line) && line[j] == ':' && isLetter(line[j+1]) { + return Match{line[i : j+1], RuleMarker}, true + } + } + } + if t, ok := trailerKey(line, low); ok { + return Match{t, RuleTrailer}, true + } + for _, frag := range []string{".docket", ".git/dckt"} { + if i := fragmentAt(low, frag); i >= 0 { + return Match{line[i : i+len(frag)], RulePath}, true + } + } + if is := wordAt(line, low, "dckt"); len(is) > 0 { + return Match{line[is[0] : is[0]+4], RuleAlias}, true + } + if t, ok := changeRef(line, low, opts.ChangeIDs); ok { + return Match{t, RuleChangeRef}, true + } + if opts.MatchWord { + if is := wordAt(line, low, "docket"); len(is) > 0 { + return Match{line[is[0] : is[0]+6], RuleWord}, true + } + } + return Match{}, false +} + +// wordAt returns the offsets of w in low not glued to a letter on either side, +// except across a camelCase boundary ("docketBranch", "myDocket" match; +// "dockets", "pocketdocket" do not). asciiLower keeps byte offsets. +func wordAt(line, low, w string) []int { + var out []int + for from := 0; ; { + k := strings.Index(low[from:], w) + if k < 0 { + return out + } + i, j := from+k, from+k+len(w) + prevOK := i == 0 || !isLetter(line[i-1]) || (isUpper(line[i]) && isLower(line[i-1])) + nextOK := j == len(line) || !isLetter(line[j]) || (isUpper(line[j]) && isLower(line[j-1])) + if prevOK && nextOK { + out = append(out, i) + } + from = i + 1 + } +} + +// fragmentAt returns the first offset of frag not followed by a letter, or -1. +func fragmentAt(low, frag string) int { + for from := 0; ; { + k := strings.Index(low[from:], frag) + if k < 0 { + return -1 + } + i := from + k + if j := i + len(frag); j == len(low) || !isLetter(low[j]) { + return i + } + from = i + 1 + } +} + +// trailerKey matches a "Docket-<Key>:" trailer key at line start (after +// leading blanks), case-insensitively. +func trailerKey(line, low string) (string, bool) { + start := len(line) - len(strings.TrimLeft(line, " \t")) + rest, lrest := line[start:], low[start:] + const p = "docket-" + if !strings.HasPrefix(lrest, p) { + return "", false + } + k := len(p) + for k < len(rest) && (isLetter(rest[k]) || isDigit(rest[k]) || rest[k] == '-') { + k++ + } + if k == len(p) || k >= len(rest) || rest[k] != ':' { + return "", false + } + return rest[:k+1], true +} + +// changeRef matches "change[s] #N" or "change[s] NNNN+", "#0NNN", and "(0NNN)"; +// the id must be in ids. "(2026)" and "#612" never match (not zero-padded). +func changeRef(line, low string, ids map[int]bool) (string, bool) { + if len(ids) == 0 { + return "", false + } + for _, w := range []string{"change", "changes"} { + for _, i := range wordAt(line, low, w) { + j := i + len(w) + k := j + for k < len(line) && (line[k] == ' ' || line[k] == '\t') { + k++ + } + if k == j { + continue + } + hash := k < len(line) && line[k] == '#' + if hash { + k++ + } + if d, end := digitsAt(line, k); d != "" && (hash || len(d) >= 4) && inBacklog(d, ids) { + return line[i:end], true + } + } + } + for i := 0; i < len(line); i++ { + switch line[i] { + case '#': + if i > 0 && (isLetter(line[i-1]) || isDigit(line[i-1])) { + continue + } + if d, end := digitsAt(line, i+1); padded(d) && inBacklog(d, ids) { + return line[i:end], true + } + case '(': + if d, end := digitsAt(line, i+1); padded(d) && end < len(line) && line[end] == ')' && inBacklog(d, ids) { + return line[i : end+1], true + } + } + } + return "", false +} + +// digitsAt returns 1–9 digits at i not followed by a letter, and their end; +// "" otherwise. +func digitsAt(line string, i int) (string, int) { + j := i + for j < len(line) && isDigit(line[j]) { + j++ + } + if j == i || j-i > 9 || (j < len(line) && isLetter(line[j])) { + return "", i + } + return line[i:j], j +} + +func padded(d string) bool { return len(d) >= 4 && d[0] == '0' } + +func inBacklog(d string, ids map[int]bool) bool { + n, err := strconv.Atoi(d) + return err == nil && ids[n] +} + +func asciiLower(s string) string { + b := []byte(s) + for i, c := range b { + if isUpper(c) { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +func isLetter(c byte) bool { return isLower(c) || isUpper(c) } +func isLower(c byte) bool { return c >= 'a' && c <= 'z' } +func isUpper(c byte) bool { return c >= 'A' && c <= 'Z' } +func isDigit(c byte) bool { return c >= '0' && c <= '9' } diff --git a/internal/leakscan/leakscan_test.go b/internal/leakscan/leakscan_test.go new file mode 100644 index 000000000..7a44c3578 --- /dev/null +++ b/internal/leakscan/leakscan_test.go @@ -0,0 +1,98 @@ +package leakscan + +import ( + "reflect" + "testing" +) + +func TestLineRules(t *testing.T) { + ids := map[int]bool{1: true, 12: true, 36: true, 612: true} + cases := []struct { + line string + w bool + ok bool + text string + rule Rule + }{ + {"<!-- docket:backlink:start (generated) -->", true, true, "docket:", RuleMarker}, + {"<!-- docket:backlink:start -->", false, true, "docket:", RuleMarker}, + {"# dckt:start", false, true, "dckt:", RuleMarker}, + {"Docket: 12-345 was filed", false, false, "", ""}, + {"Docket: 12-345 was filed", true, true, "Docket", RuleWord}, + {"Docket-Operation: change.claim", false, true, "Docket-Operation:", RuleTrailer}, + {" docket-request-id: abc", false, true, "docket-request-id:", RuleTrailer}, + {"see .docket/docs/x.md", false, true, ".docket", RulePath}, + {"edit .docket.yml", false, true, ".docket", RulePath}, + {"cat .git/dckt/config.yml", false, true, ".git/dckt", RulePath}, + {"run dckt status", false, true, "dckt", RuleAlias}, + {"the dckts list", true, false, "", ""}, + {"fix the widget (0612)", false, true, "(0612)", RuleChangeRef}, + {"change 0612 lands", false, true, "change 0612", RuleChangeRef}, + {"Changes #612 and more", false, true, "Changes #612", RuleChangeRef}, + {"tracked as #0612", false, true, "#0612", RuleChangeRef}, + {"the (2026) layout", true, false, "", ""}, + // 612 is a backlog id, but "(612)" is not zero-padded, so it passes. + {"the (612) layout", true, false, "", ""}, + {"fixes #612", true, false, "", ""}, + {"change 3 files", true, false, "", ""}, + {"per ADR-0036 and ADR 0012", true, false, "", ""}, + {"fix (0613)", true, false, "", ""}, + {"the docket was filed", true, true, "docket", RuleWord}, + {"the docket was filed", false, false, "", ""}, + {"docketBranch := x", true, true, "docket", RuleWord}, + {"myDocket := x", true, true, "Docket", RuleWord}, + {"court dockets", true, false, "", ""}, + {"pocketdocket", true, false, "", ""}, + {"DOCKET", true, true, "DOCKET", RuleWord}, + {"docket_branch", true, true, "docket", RuleWord}, + } + for _, c := range cases { + m, ok := Line(c.line, Options{MatchWord: c.w, ChangeIDs: ids}) + if ok != c.ok { + t.Errorf("Line(%q, W=%v) ok = %v, want %v (got %+v)", c.line, c.w, ok, c.ok, m) + continue + } + if ok && (m.Text != c.text || m.Rule != c.rule) { + t.Errorf("Line(%q, W=%v) = %+v, want {%q %s}", c.line, c.w, m, c.text, c.rule) + } + } +} + +func TestTextLineNumbersAndCRLF(t *testing.T) { + got := Text(SourcePRBody, "ok\r\nsee .docket/x\r\n", Options{MatchWord: true}) + want := []Hit{{Source: SourcePRBody, Line: 2, Rule: RulePath, Text: ".docket"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("Text = %+v, want %+v", got, want) + } +} + +func TestScanAttributesEverySource(t *testing.T) { + in := Input{ + Commits: []Commit{{ID: "c1", Message: "Add widget\n\nRefs (0612)\n"}}, + AddedPaths: []string{"notes/.docket-old.md"}, + AddedLines: []AddedLine{{Path: "a.go", Line: 7, Text: "// dckt"}}, + PR: &PRText{Title: "change 0012", Body: "plain\n"}, + } + got := Scan(in, Options{MatchWord: true, ChangeIDs: map[int]bool{12: true, 612: true}}) + want := []Hit{ + {Source: SourceCommitMessage, Commit: "c1", Line: 3, Text: "(0612)", Rule: RuleChangeRef}, + {Source: SourceAddedPath, File: "notes/.docket-old.md", Text: ".docket", Rule: RulePath}, + {Source: SourceAddedLine, File: "a.go", Line: 7, Text: "dckt", Rule: RuleAlias}, + {Source: SourcePRTitle, Line: 1, Text: "change 0012", Rule: RuleChangeRef}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("Scan =\n%+v\nwant\n%+v", got, want) + } +} + +func TestScanNilPRAndNilIDs(t *testing.T) { + in := Input{ + Commits: []Commit{{ID: "c1", Message: "fix (0612) and change 0012\n"}}, + AddedLines: []AddedLine{{Path: "a.go", Line: 1, Text: "tracked as #0612"}}, + } + for _, h := range Scan(in, Options{MatchWord: true}) { + if h.Rule == RuleChangeRef { + t.Fatalf("nil ChangeIDs produced a change-ref hit: %+v", h) + } + } +} From 8b3f130e6be8d043509daa29075a9f8429c678ac Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 11:35:25 -0400 Subject: [PATCH 03/13] feat(config): add leak_check.match_word --- .docket.example.yml | 12 ++++++++++++ internal/app/config.go | 1 + internal/assets/embedded/manifest.json | 6 +++--- internal/assets/embedded/tree/.docket.example.yml | 12 ++++++++++++ internal/config/config.go | 5 +++++ internal/config/decode_test.go | 2 ++ internal/config/defaults.go | 1 + internal/config/defaults_test.go | 1 + internal/config/fixtures_test.go | 1 + internal/config/resolve.go | 1 + internal/config/resolve_test.go | 2 ++ internal/config/schema.go | 5 +++++ internal/config/schema_test.go | 2 ++ internal/config/setting_paths_test.go | 1 + 14 files changed, 49 insertions(+), 3 deletions(-) diff --git a/.docket.example.yml b/.docket.example.yml index 92782caa0..61fe04df7 100644 --- a/.docket.example.yml +++ b/.docket.example.yml @@ -161,6 +161,18 @@ reclaim: auto: false +# ═══ leak_check — keeping docket out of a private repository's shared surfaces ═══════════ + +leak_check: + # match_word — true (default): in a private repository, a push or pull request whose commits, + # added lines, added paths, or PR text contain the bare word "docket" is refused. false: let + # the bare word through, for a codebase where it is an ordinary word (court or shipping + # dockets); markers, trailers, `.docket` paths, `dckt`, and change ids are still refused. + # A shared repository never runs the check. + # scope: any layer + match_word: true + + # ═══ learnings — the learnings ledger ═════════════════════════════════════════════════════ learnings: diff --git a/internal/app/config.go b/internal/app/config.go index 9cf8f2049..2ee9012f6 100644 --- a/internal/app/config.go +++ b/internal/app/config.go @@ -253,6 +253,7 @@ func effectiveLines(eff *config.Effective) []effectiveLine { leafLine("learnings.enabled", strconv.FormatBool(eff.Learnings.Enabled.Value), eff.Learnings.Enabled.Provenance), leafLine("reclaim.lease_ttl", strconv.Itoa(eff.Reclaim.LeaseTTL.Value), eff.Reclaim.LeaseTTL.Provenance), leafLine("reclaim.auto", strconv.FormatBool(eff.Reclaim.Auto.Value), eff.Reclaim.Auto.Provenance), + leafLine("leak_check.match_word", strconv.FormatBool(eff.LeakCheck.MatchWord.Value), eff.LeakCheck.MatchWord.Provenance), leafLine("review.min_fix_severity", textValue(eff.Review.MinFixSeverity.Value), eff.Review.MinFixSeverity.Provenance), leafLine("review.max_fix_tasks", strconv.Itoa(eff.Review.MaxFixTasks.Value), eff.Review.MaxFixTasks.Provenance), leafLine("gate_observation_budget", strconv.Itoa(eff.GateObservation.Value), eff.GateObservation.Provenance), diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 21238091f..9d09532cf 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:a60f6f773af6d6e8c3441d4d710b0b2fb0a9b2dfd5592a18a98d1b99ede03d4a", + "asset_set_id": "sha256:f32b5c725729f6d397517ab006f4e5416bd10e8bb8c569680a67e60d33b51db6", "entries": [ { "path": ".docket.example.yml", "role": "config-schema", "mode": 420, - "size": 19549, - "sha256": "e80e598968cd632f39095c9e580442c203c919319936cae434a4bda2503c2d23" + "size": 20147, + "sha256": "7f19aeb4b5f1fbe3d4fd6c004925373e1350c7516556c8250190fdecf63e0c05" }, { "path": "agents/docket-adr.md", diff --git a/internal/assets/embedded/tree/.docket.example.yml b/internal/assets/embedded/tree/.docket.example.yml index 92782caa0..61fe04df7 100644 --- a/internal/assets/embedded/tree/.docket.example.yml +++ b/internal/assets/embedded/tree/.docket.example.yml @@ -161,6 +161,18 @@ reclaim: auto: false +# ═══ leak_check — keeping docket out of a private repository's shared surfaces ═══════════ + +leak_check: + # match_word — true (default): in a private repository, a push or pull request whose commits, + # added lines, added paths, or PR text contain the bare word "docket" is refused. false: let + # the bare word through, for a codebase where it is an ordinary word (court or shipping + # dockets); markers, trailers, `.docket` paths, `dckt`, and change ids are still refused. + # A shared repository never runs the check. + # scope: any layer + match_word: true + + # ═══ learnings — the learnings ledger ═════════════════════════════════════════════════════ learnings: diff --git a/internal/config/config.go b/internal/config/config.go index b666af406..200cc1154 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -129,6 +129,7 @@ type Effective struct { Run Run `json:"run"` Learnings Learnings `json:"learnings"` Reclaim Reclaim `json:"reclaim"` + LeakCheck LeakCheck `json:"leak_check"` Review Review `json:"review"` GateObservation Value[int] `json:"gate_observation_budget"` // minutes BoardSurfaces Value[[]string] `json:"board_surfaces"` @@ -187,6 +188,10 @@ type Reclaim struct { Auto Value[bool] `json:"auto"` } +type LeakCheck struct { + MatchWord Value[bool] `json:"match_word"` +} + type Review struct { MinFixSeverity Value[string] `json:"min_fix_severity"` // minor|important|blocker MaxFixTasks Value[int] `json:"max_fix_tasks"` diff --git a/internal/config/decode_test.go b/internal/config/decode_test.go index 0c80f58c5..fcb10f4a3 100644 --- a/internal/config/decode_test.go +++ b/internal/config/decode_test.go @@ -89,6 +89,8 @@ func decodeAcceptanceCases() []decodeCase { block: "reclaim:\n lease_ttl: 24\n", flow: "reclaim: {lease_ttl: 24}\n", value: 24}, {row: "reclaim.auto", path: "reclaim.auto", block: "reclaim:\n auto: true\n", flow: "reclaim: {auto: true}\n", value: true}, + {row: "leak_check.match_word", path: "leak_check.match_word", + block: "leak_check:\n match_word: false\n", flow: "leak_check: {match_word: false}\n", value: false}, {row: "build.checkpoint", path: "build.checkpoint", block: "build:\n checkpoint: true\n", flow: "build: {checkpoint: true}\n", value: true}, diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 913bda2b0..4764fff84 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -49,6 +49,7 @@ func builtinEffective() Effective { LeaseTTL: builtinValue(72), Auto: builtinValue(false), }, + LeakCheck: LeakCheck{MatchWord: builtinValue(true)}, Review: Review{ MinFixSeverity: builtinValue("minor"), MaxFixTasks: builtinValue(10), diff --git a/internal/config/defaults_test.go b/internal/config/defaults_test.go index 8a39f6c84..dbbc95040 100644 --- a/internal/config/defaults_test.go +++ b/internal/config/defaults_test.go @@ -157,6 +157,7 @@ func TestBuiltinEffectiveMatchesRegistryDefaults(t *testing.T) { "learnings.enabled": eff.Learnings.Enabled.Value, "reclaim.lease_ttl": eff.Reclaim.LeaseTTL.Value, "reclaim.auto": eff.Reclaim.Auto.Value, + "leak_check.match_word": eff.LeakCheck.MatchWord.Value, "review.min_fix_severity": eff.Review.MinFixSeverity.Value, "review.max_fix_tasks": eff.Review.MaxFixTasks.Value, "gate_observation_budget": eff.GateObservation.Value, diff --git a/internal/config/fixtures_test.go b/internal/config/fixtures_test.go index 12cd9b224..585c7e428 100644 --- a/internal/config/fixtures_test.go +++ b/internal/config/fixtures_test.go @@ -183,6 +183,7 @@ func TestFixtureSparseDefaults(t *testing.T) { {"learnings.enabled", eff.Learnings.Enabled.Explicit, eff.Learnings.Enabled.Provenance.Layer}, {"reclaim.lease_ttl", eff.Reclaim.LeaseTTL.Explicit, eff.Reclaim.LeaseTTL.Provenance.Layer}, {"reclaim.auto", eff.Reclaim.Auto.Explicit, eff.Reclaim.Auto.Provenance.Layer}, + {"leak_check.match_word", eff.LeakCheck.MatchWord.Explicit, eff.LeakCheck.MatchWord.Provenance.Layer}, {"review.min_fix_severity", eff.Review.MinFixSeverity.Explicit, eff.Review.MinFixSeverity.Provenance.Layer}, {"review.max_fix_tasks", eff.Review.MaxFixTasks.Explicit, eff.Review.MaxFixTasks.Provenance.Layer}, {"gate_observation_budget", eff.GateObservation.Explicit, eff.GateObservation.Provenance.Layer}, diff --git a/internal/config/resolve.go b/internal/config/resolve.go index 2a1e6268d..076fe7cfc 100644 --- a/internal/config/resolve.go +++ b/internal/config/resolve.go @@ -317,6 +317,7 @@ func (r *resolution) assemble(byLayer map[LayerKind]map[string]leafDecl) (Effect set(assign(&eff.Learnings.Enabled, r.declared, "learnings.enabled")) set(assign(&eff.Reclaim.LeaseTTL, r.declared, "reclaim.lease_ttl")) set(assign(&eff.Reclaim.Auto, r.declared, "reclaim.auto")) + set(assign(&eff.LeakCheck.MatchWord, r.declared, "leak_check.match_word")) set(assign(&eff.Review.MinFixSeverity, r.declared, "review.min_fix_severity")) set(assign(&eff.Review.MaxFixTasks, r.declared, "review.max_fix_tasks")) set(assign(&eff.GateObservation, r.declared, "gate_observation_budget")) diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index 77c27df73..4bdc71594 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -90,6 +90,8 @@ func effectiveLeaf(t *testing.T, eff Effective, path string) (any, Provenance, b return eff.Reclaim.LeaseTTL.Value, eff.Reclaim.LeaseTTL.Provenance, eff.Reclaim.LeaseTTL.Explicit case "reclaim.auto": return eff.Reclaim.Auto.Value, eff.Reclaim.Auto.Provenance, eff.Reclaim.Auto.Explicit + case "leak_check.match_word": + return eff.LeakCheck.MatchWord.Value, eff.LeakCheck.MatchWord.Provenance, eff.LeakCheck.MatchWord.Explicit case "review.min_fix_severity": return eff.Review.MinFixSeverity.Value, eff.Review.MinFixSeverity.Provenance, eff.Review.MinFixSeverity.Explicit case "review.max_fix_tasks": diff --git a/internal/config/schema.go b/internal/config/schema.go index 87af44e58..561c01081 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -284,6 +284,11 @@ func buildRegistry() []pathSpec { {path: "reclaim.auto", kind: kindBool, def: false, merge: mergeScalar, scope: scopeAny, disp: dispSupported, validate: boolLeaf()}, + // leak_check: whether the private-repository leak check matches the bare + // word "docket". A shared repository never runs the check. + {path: "leak_check.match_word", kind: kindBool, def: true, + merge: mergeScalar, scope: scopeAny, disp: dispSupported, validate: boolLeaf()}, + // 15: build. The build role's own gate policy (change 0374): build.gate // and build.test_command resolve independently of the finalize pair. {path: "build.checkpoint", kind: kindBool, def: false, diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index 4ec107566..cdedf28eb 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -22,6 +22,7 @@ func TestRegistryPathSetMatchesV092(t *testing.T) { "finalize.skip_results_only_delta", "learnings.enabled", "learnings.cap", "reclaim.lease_ttl", "reclaim.auto", + "leak_check.match_word", "build.checkpoint", "build.gate", "build.test_command", "build.max_attempts", "run.max_attempts", "review.min_fix_severity", "review.max_fix_tasks", @@ -122,6 +123,7 @@ func TestRegistryDefaults(t *testing.T) { "learnings.cap": 300, "reclaim.lease_ttl": 72, "reclaim.auto": false, + "leak_check.match_word": true, "build.checkpoint": false, "build.gate": "local", "build.test_command": "", diff --git a/internal/config/setting_paths_test.go b/internal/config/setting_paths_test.go index 76833b8fd..47e7d788b 100644 --- a/internal/config/setting_paths_test.go +++ b/internal/config/setting_paths_test.go @@ -31,6 +31,7 @@ func TestSettingPathsSupportSplit(t *testing.T) { "run.max_attempts", "review.min_fix_severity", "review.max_fix_tasks", "reclaim.lease_ttl", "reclaim.auto", + "leak_check.match_word", "learnings.enabled", "gate_observation_budget", "board_surfaces", "board.section_order", From d19e05ae6f58185911c8c362cc3ad09694771ae3 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 11:44:36 -0400 Subject: [PATCH 04/13] feat(gitcli): read what a push exposes, list named remote refs, push branches only --- .../gitcli/network_classification_test.go | 1 + internal/gitcli/outgoing.go | 322 ++++++++++++++++++ internal/gitcli/outgoing_integration_test.go | 153 +++++++++ internal/gitcli/outgoing_test.go | 209 ++++++++++++ internal/gitcli/push.go | 11 + internal/gitcli/push_guard_test.go | 37 ++ internal/gitcli/remoteheads.go | 63 ++++ .../gitcli/remoteheads_integration_test.go | 52 +++ 8 files changed, 848 insertions(+) create mode 100644 internal/gitcli/outgoing.go create mode 100644 internal/gitcli/outgoing_integration_test.go create mode 100644 internal/gitcli/outgoing_test.go create mode 100644 internal/gitcli/push_guard_test.go diff --git a/internal/gitcli/network_classification_test.go b/internal/gitcli/network_classification_test.go index e22c6691e..5d2d11f8e 100644 --- a/internal/gitcli/network_classification_test.go +++ b/internal/gitcli/network_classification_test.go @@ -34,6 +34,7 @@ func TestEveryNetworkSiteIsReadWriteClassified(t *testing.T) { "classifyFetchFailure": false, // ls-remote failure-classification probe "ProbeRemoteBranch": false, // ls-remote <ref> "ListRemoteHeads": false, // ls-remote --heads + "ListRemoteRefs": false, // ls-remote --refs <patterns> "PushLease": true, // push --force-with-lease "PushCreateLease": true, // push (create) "DeleteRemoteRefLease": true, // push --delete (lease) diff --git a/internal/gitcli/outgoing.go b/internal/gitcli/outgoing.go new file mode 100644 index 000000000..6fd314129 --- /dev/null +++ b/internal/gitcli/outgoing.go @@ -0,0 +1,322 @@ +package gitcli + +import ( + "bytes" + "context" + "errors" + "fmt" + "strconv" + "strings" +) + +// readOutgoingOp labels every Failure raised by ReadOutgoing. +const readOutgoingOp Operation = "read-outgoing" + +// OutgoingCommit is one commit a push would expose, with its raw message (%B): +// subject, body, and trailers exactly as stored. +type OutgoingCommit struct { + Commit ObjectID + Message string +} + +// OutgoingLine is one added line: its path, its 1-based line number in head's +// version of the file, and its text without the leading '+'. +type OutgoingLine struct { + Path string + Line int + Text string +} + +// Outgoing is everything a push of head over base exposes that base does not +// already carry: the commits in base..head, plus the paths added and the lines +// added in the merge-base diff base...head. +type Outgoing struct { + Commits []OutgoingCommit + AddedPaths []string + AddedLines []OutgoingLine +} + +// ReadOutgoing reads what pushing head exposes beyond base, from three local +// reads in the primary worktree: +// +// - `log -z --format=%H%x01%B <base>..<head>` — every commit head carries that +// base does not, a merge commit included; +// - `diff --name-status -z --diff-filter=A <base>...<head>` — the added paths; +// - `diff -U0 <base>...<head>` — the added lines, attributed to their file. +// +// The diffs are three-dot (merge-base) diffs on purpose: they compare head with +// the merge base of base and head, so upstream content merged into the branch — +// or rewritten upstream after that merge — is never reported as the branch's own +// added lines, while a two-tree diff against base would report it. Every diff +// option that user or repository configuration could otherwise change (quoting, +// prefixes, renames, external or textconv drivers, inter-hunk context, binary +// detection, submodule rendering) is pinned on the command line, and replace refs +// and grafts are ignored, so the lines read are the objects a push sends. +// +// Every failure is an error and never an empty or partial result: an invalid id +// is invalid-request, a non-zero exit — including base and head sharing no merge +// base — is command-failed, and output that does not parse is invalid-output. A +// caller must treat an error as "unverified", never as "nothing outgoing". +func (c *Client) ReadOutgoing(ctx context.Context, repo Repository, base, head ObjectID) (Outgoing, error) { + if err := validateObjectID(base); err != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidRequest, "invalid base id", err) + } + if err := validateObjectID(head); err != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidRequest, "invalid head id", err) + } + twoDot := string(base) + ".." + string(head) + threeDot := string(base) + "..." + string(head) + + logOut, err := c.readOutgoingRun(ctx, repo, "git log", + "log", "--no-show-signature", "-z", "--format=%H%x01%B", twoDot) + if err != nil { + return Outgoing{}, err + } + commits, perr := parseOutgoingLog(logOut) + if perr != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed outgoing log output", perr) + } + + namesOut, err := c.readOutgoingRun(ctx, repo, "git diff --name-status", + "diff", "--name-status", "-z", "--no-renames", "--no-relative", "--diff-filter=A", threeDot) + if err != nil { + return Outgoing{}, err + } + paths, perr := parseAddedPaths(namesOut) + if perr != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed added-paths output", perr) + } + + patchOut, err := c.readOutgoingRun(ctx, repo, "git diff", + "-c", "core.quotePath=false", "-c", "diff.suppressBlankEmpty=false", + "diff", "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", + "--text", "--submodule=short", "--inter-hunk-context=0", + "--src-prefix=a/", "--dst-prefix=b/", "-U0", threeDot) + if err != nil { + return Outgoing{}, err + } + lines, perr := parseAddedLines(patchOut) + if perr != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed patch output", perr) + } + + return Outgoing{Commits: commits, AddedPaths: paths, AddedLines: lines}, nil +} + +// readOutgoingRun runs one local read for ReadOutgoing with replace refs and +// grafts disabled, returning stdout on a clean exit and a command-failed +// *Failure (carrying a stderr excerpt) on any other. +func (c *Client) readOutgoingRun(ctx context.Context, repo Repository, what string, args ...string) ([]byte, error) { + res, f := c.run(ctx, runRequest{ + op: readOutgoingOp, + dir: repo.PrimaryWorktree, + args: append([]string{"--no-replace-objects"}, args...), + env: []string{"GIT_GRAFT_FILE="}, + }) + if f != nil { + return nil, f + } + if res.exitCode != 0 { + return nil, newFailure(readOutgoingOp, KindCommandFailed, what+" failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + return res.stdout, nil +} + +// parseOutgoingLog parses the NUL-delimited `git log -z --format=%H%x01%B` +// records: each non-empty record is "<hash>\x01<raw message>". A record without +// the separator, or with a malformed hash, is an error — never a skipped commit. +func parseOutgoingLog(out []byte) ([]OutgoingCommit, error) { + var commits []OutgoingCommit + for _, rec := range bytes.Split(out, []byte{0}) { + if len(rec) == 0 { + continue + } + sep := bytes.IndexByte(rec, trailerSeparator) + if sep < 0 { + return nil, errors.New("gitcli: outgoing log record missing its separator") + } + hash := ObjectID(rec[:sep]) + if err := validateObjectID(hash); err != nil { + return nil, err + } + commits = append(commits, OutgoingCommit{Commit: hash, Message: string(rec[sep+1:])}) + } + return commits, nil +} + +// parseAddedPaths parses `diff --name-status -z --diff-filter=A` output: NUL +// tokens alternating the status "A" and a verbatim path. An odd token count, a +// status other than A, or an empty path is an error. +func parseAddedPaths(out []byte) ([]string, error) { + if len(out) == 0 { + return nil, nil + } + toks := strings.Split(strings.TrimSuffix(string(out), "\x00"), "\x00") + if len(toks)%2 != 0 { + return nil, errors.New("gitcli: added-paths output has an odd token count") + } + paths := make([]string, 0, len(toks)/2) + for i := 0; i < len(toks); i += 2 { + if toks[i] != "A" { + return nil, fmt.Errorf("gitcli: added-paths output has status %q, want A", toks[i]) + } + if toks[i+1] == "" { + return nil, errors.New("gitcli: added-paths output has an empty path") + } + paths = append(paths, toks[i+1]) + } + return paths, nil +} + +// parseAddedLines reads a unified patch (as ReadOutgoing requests it: -U0, a/ and +// b/ prefixes, no renames, no color) and returns every added line with its file +// and 1-based line number in head's version. +// +// Hunk bodies are consumed by the counts in their "@@" header, never by line +// shape: an added line whose content begins "++ " arrives as "+++ …" inside a +// hunk and is counted as content, never read as a file header. A "\ No newline at +// end of file" marker counts toward neither side, and a context line (' ') +// counts toward both. Parsing is strict so that a misread can never silently +// drop lines: a body shorter or longer than its header's counts, a "+", "-", or +// " " line outside any hunk, a second "+++" header in one file section, a hunk +// before its file's "+++" header, an added line in a deleted file, and a +// malformed header or path are each an error, and an error returns no lines. +func parseAddedLines(patch []byte) ([]OutgoingLine, error) { + var out []OutgoingLine + lines := strings.Split(string(patch), "\n") + var ( + inFile bool // a "diff --git" section is open + haveNew bool // its "+++" header has been read + inHunks bool // a hunk of this section has been read + filePath string // head-side path; "" for a deleted file (+++ /dev/null) + ) + for i := 0; i < len(lines); i++ { + l := lines[i] + switch { + case strings.HasPrefix(l, "diff --git "): + inFile, haveNew, inHunks, filePath = true, false, false, "" + case strings.HasPrefix(l, "+++ "): + if !inFile || haveNew || inHunks { + return nil, errors.New("gitcli: unexpected +++ header in patch") + } + p, err := patchPath(strings.TrimPrefix(l, "+++ ")) + if err != nil { + return nil, err + } + filePath, haveNew = p, true + case strings.HasPrefix(l, "--- "): + if !inFile || haveNew || inHunks { + return nil, errors.New("gitcli: unexpected --- header in patch") + } + case strings.HasPrefix(l, "@@ "): + if !haveNew { + return nil, errors.New("gitcli: patch hunk precedes its file header") + } + inHunks = true + oldCount, newStart, newCount, err := parseHunkHeader(l) + if err != nil { + return nil, err + } + oldSeen, newSeen := 0, 0 + for oldSeen < oldCount || newSeen < newCount { + i++ + if i >= len(lines) { + return nil, errors.New("gitcli: patch hunk is truncated") + } + body := lines[i] + switch { + case strings.HasPrefix(body, `\`): // "\ No newline at end of file" + case strings.HasPrefix(body, "-"): + oldSeen++ + case strings.HasPrefix(body, "+"): + if filePath == "" { + return nil, errors.New("gitcli: patch adds a line to a deleted file") + } + out = append(out, OutgoingLine{Path: filePath, Line: newStart + newSeen, Text: body[1:]}) + newSeen++ + case strings.HasPrefix(body, " "): + oldSeen++ + newSeen++ + default: + return nil, errors.New("gitcli: unexpected line inside a patch hunk") + } + if oldSeen > oldCount || newSeen > newCount { + return nil, errors.New("gitcli: patch hunk exceeds its header counts") + } + } + case strings.HasPrefix(l, "+"), strings.HasPrefix(l, "-"), strings.HasPrefix(l, " "): + // A body line outside any hunk means a hunk ran longer than its header + // said: refuse rather than drop it. + return nil, errors.New("gitcli: patch body line outside any hunk") + } + } + return out, nil +} + +// parseHunkHeader reads "@@ -a[,b] +c[,d] @@[ section]"; an omitted count is 1. +func parseHunkHeader(l string) (oldCount, newStart, newCount int, err error) { + f := strings.Fields(l) + if len(f) < 4 || f[0] != "@@" || f[3] != "@@" || !strings.HasPrefix(f[1], "-") || !strings.HasPrefix(f[2], "+") { + return 0, 0, 0, errors.New("gitcli: malformed hunk header") + } + if _, oldCount, err = hunkRange(f[1][1:]); err != nil { + return 0, 0, 0, err + } + newStart, newCount, err = hunkRange(f[2][1:]) + return oldCount, newStart, newCount, err +} + +// hunkRange parses "start[,count]" (count defaults to 1). Each part must be a +// non-empty run of ASCII digits; a sign or any other byte is an error. +func hunkRange(s string) (start, count int, err error) { + startText, countText, hasCount := strings.Cut(s, ",") + if start, err = hunkNumber(startText); err != nil { + return 0, 0, err + } + count = 1 + if hasCount { + if count, err = hunkNumber(countText); err != nil { + return 0, 0, err + } + } + return start, count, nil +} + +// hunkNumber parses one unsigned decimal hunk-header number. +func hunkNumber(s string) (int, error) { + if s == "" { + return 0, errors.New("gitcli: empty hunk header number") + } + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return 0, fmt.Errorf("gitcli: malformed hunk header number %q", s) + } + } + n, err := strconv.Atoi(s) + if err != nil { + return 0, fmt.Errorf("gitcli: malformed hunk header number %q: %w", s, err) + } + return n, nil +} + +// patchPath reads the path of a "+++ " header: /dev/null is "" (a deleted file), +// a C-quoted path is unquoted, the trailing TAB Git appends to a name containing +// a space is dropped, and the b/ prefix is required and removed. +func patchPath(s string) (string, error) { + s = strings.TrimSuffix(s, "\t") + if s == "/dev/null" { + return "", nil + } + if strings.HasPrefix(s, `"`) { + u, err := strconv.Unquote(s) + if err != nil { + return "", fmt.Errorf("gitcli: unquoting patch path: %w", err) + } + s = u + } + p, ok := strings.CutPrefix(s, "b/") + if !ok || p == "" { + return "", errors.New("gitcli: patch path lacks the b/ prefix") + } + return p, nil +} diff --git a/internal/gitcli/outgoing_integration_test.go b/internal/gitcli/outgoing_integration_test.go new file mode 100644 index 000000000..a9c37cd0a --- /dev/null +++ b/internal/gitcli/outgoing_integration_test.go @@ -0,0 +1,153 @@ +//go:build integration + +package gitcli + +import ( + "context" + "reflect" + "sort" + "testing" +) + +// TestIntegrationRepoReadOutgoing proves ReadOutgoing returns exactly what a +// push of a feature branch exposes against real git: the branch's own commits +// (the merge included, the merged-in upstream commit excluded), the paths it +// adds (a space, non-ASCII, and a '"' kept verbatim), and its added lines from +// the merge-base diff — so upstream content merged into the branch is never +// attributed to it, even once the upstream base has moved on past the merge. +// A file Git deems binary still has its lines read, and diff configuration in +// the clone (no prefixes, forced color, inter-hunk context, copy detection) +// changes nothing. An unrelated base (no merge base) is an error, never an empty result. +func TestIntegrationRepoReadOutgoing(t *testing.T) { + ctx := context.Background() + r := newMainModeRepos(t) + c := newRealClient(t) + repo := mustDiscover(t, c, r.Invocation) + inv := r.Invocation + + // Configuration a developer may carry must not change what is read: no + // prefixes, forced color, wide inter-hunk context, rename/copy detection. + for k, v := range map[string]string{ + "diff.noprefix": "true", + "color.ui": "always", + "diff.interHunkContext": "5", + "diff.renames": "copies", + } { + gitOut(t, inv, "config", k, v) + } + + gitOut(t, inv, "checkout", "-q", "-b", "feat") + odd := map[string]string{ + "has space.txt": "space line", + "café.txt": "accent line", + `we"ird.txt`: "quote line", + } + writeWorktreeFile(t, inv, "notes.txt", "a\nb\n") + // A NUL byte makes Git treat the file as binary; its lines are still pushed, + // so they must still be read. + writeWorktreeFile(t, inv, "blob.bin", "\x00bin\nhidden line\n") + for p, line := range odd { + writeWorktreeFile(t, inv, p, line+"\n") + } + gitOut(t, inv, "add", "--", "notes.txt", "blob.bin", "has space.txt", "café.txt", `we"ird.txt`) + gitOut(t, inv, "commit", "-q", "-m", "Add notes", "-m", "Body (0007)") + commitA := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + writeWorktreeFile(t, inv, "notes.txt", "a\nB\nc\n") + gitOut(t, inv, "commit", "-q", "-am", "Edit notes") + commitB := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + + upstream := r.writerCommit(t, "main", map[string]string{"upstream.txt": "docket\n"}) + gitOut(t, inv, "fetch", "-q", "origin") + gitOut(t, inv, "merge", "-q", "--no-edit", "origin/main") + head := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + merge := head + base := ObjectID(gitOut(t, inv, "rev-parse", "origin/main")) + if base != upstream { + t.Fatalf("fetched origin/main = %s, want the upstream commit %s", base, upstream) + } + + check := func(t *testing.T, base ObjectID) { + t.Helper() + got, err := c.ReadOutgoing(ctx, repo, base, head) + if err != nil { + t.Fatalf("ReadOutgoing: %v", err) + } + + commits := map[ObjectID]string{} + for _, oc := range got.Commits { + commits[oc.Commit] = oc.Message + } + if len(got.Commits) != 3 || len(commits) != 3 { + t.Fatalf("commits = %+v, want exactly (a), (b), and the merge", got.Commits) + } + for _, want := range []ObjectID{commitA, commitB, merge} { + if _, ok := commits[want]; !ok { + t.Errorf("commit %s missing from %+v", want, got.Commits) + } + } + if _, ok := commits[upstream]; ok { + t.Errorf("merged-in upstream commit %s reported as outgoing", upstream) + } + if msg := commits[commitA]; msg != "Add notes\n\nBody (0007)\n" { + t.Errorf("commit (a) message = %q, want the raw body", msg) + } + + paths := append([]string(nil), got.AddedPaths...) + sort.Strings(paths) + wantPaths := []string{"blob.bin", "café.txt", "has space.txt", "notes.txt", `we"ird.txt`} + sort.Strings(wantPaths) + if !reflect.DeepEqual(paths, wantPaths) { + t.Errorf("added paths = %q, want %q", paths, wantPaths) + } + + have := map[OutgoingLine]bool{} + for _, l := range got.AddedLines { + have[l] = true + if l.Path == "upstream.txt" || l.Text == "docket" { + t.Errorf("upstream line attributed to the branch: %+v", l) + } + } + want := []OutgoingLine{ + {Path: "notes.txt", Line: 2, Text: "B"}, + {Path: "notes.txt", Line: 3, Text: "c"}, + {Path: "blob.bin", Line: 2, Text: "hidden line"}, + } + for p, line := range odd { + want = append(want, OutgoingLine{Path: p, Line: 1, Text: line}) + } + for _, w := range want { + if !have[w] { + t.Errorf("added lines missing %+v; got %+v", w, got.AddedLines) + } + } + } + + t.Run("base is the merged upstream tip", func(t *testing.T) { check(t, base) }) + + // Upstream moves on past the merge and rewrites the line the branch merged + // in. Against this newer base the branch still exposes only its own work: a + // plain two-tree diff would report the branch's stale "docket" line as added. + r.writerCommit(t, "main", map[string]string{"upstream.txt": "rewritten\n"}) + gitOut(t, inv, "fetch", "-q", "origin") + later := ObjectID(gitOut(t, inv, "rev-parse", "origin/main")) + t.Run("base moved past the merge", func(t *testing.T) { check(t, later) }) + + t.Run("unrelated base is an error", func(t *testing.T) { + orphan := ObjectID(gitOut(t, inv, "commit-tree", "HEAD^{tree}", "-m", "orphan")) + got, err := c.ReadOutgoing(ctx, repo, orphan, head) + if err == nil { + t.Fatalf("ReadOutgoing with no merge base succeeded: %+v", got) + } + assertKind(t, err, KindCommandFailed) + if !reflect.DeepEqual(got, Outgoing{}) { + t.Fatalf("failed read returned a partial result: %+v", got) + } + }) + + t.Run("malformed id is an invalid request", func(t *testing.T) { + _, err := c.ReadOutgoing(ctx, repo, "main", head) + assertKind(t, err, KindInvalidRequest) + _, err = c.ReadOutgoing(ctx, repo, base, "HEAD") + assertKind(t, err, KindInvalidRequest) + }) +} diff --git a/internal/gitcli/outgoing_test.go b/internal/gitcli/outgoing_test.go new file mode 100644 index 000000000..375ae178c --- /dev/null +++ b/internal/gitcli/outgoing_test.go @@ -0,0 +1,209 @@ +package gitcli + +import ( + "reflect" + "strings" + "testing" +) + +// TestParseAddedLines proves the -U0 patch reader attributes every added line to +// its file and its 1-based line number in head's version, across the patch +// shapes a push can carry: a modified file, a new file, a deletion, a +// "\ No newline at end of file" marker inside a hunk and after one, inter-hunk +// context lines, a C-quoted "+++" header, a "+++" header with Git's trailing +// TAB, and an added line whose own content looks like a header. Every malformed +// shape is an error, never a shorter list: a misread patch would otherwise +// silently un-scan lines. +func TestParseAddedLines(t *testing.T) { + patch := strings.Join([]string{ + "diff --git a/notes.txt b/notes.txt", + "index 1111111..2222222 100644", + "--- a/notes.txt", + "+++ b/notes.txt", + "@@ -2 +2 @@", + "-b", + "+B", + "@@ -3,0 +4,2 @@ func context", + "+c", + "+++ looks like a header", + "diff --git a/new.txt b/new.txt", + "new file mode 100644", + "index 0000000..3333333", + "--- /dev/null", + "+++ b/new.txt", + "@@ -0,0 +1,2 @@", + "+one", + "+two", + "diff --git a/gone.txt b/gone.txt", + "deleted file mode 100644", + "index 4444444..0000000", + "--- a/gone.txt", + "+++ /dev/null", + "@@ -1,2 +0,0 @@", + "-x", + "-y", + "diff --git a/tail.txt b/tail.txt", + "index 5555555..6666666 100644", + "--- a/tail.txt", + "+++ b/tail.txt", + "@@ -1 +1,2 @@", + "-old", + `\ No newline at end of file`, + "+new", + "+more", + `\ No newline at end of file`, + "diff --git a/ctx.txt b/ctx.txt", + "index 7777777..8888888 100644", + "--- a/ctx.txt", + "+++ b/ctx.txt", + "@@ -1,3 +1,3 @@", + "-p", + "+P", + " q", + "-r", + "+R", + `diff --git "a/we\"ird.txt" "b/we\"ird.txt"`, + "new file mode 100644", + "index 0000000..9999999", + "--- /dev/null", + `+++ "b/we\"ird.txt"`, + "@@ -0,0 +1 @@", + "+q", + "diff --git a/has space.txt b/has space.txt", + "new file mode 100644", + "index 0000000..aaaaaaa", + "--- /dev/null", + "+++ b/has space.txt\t", + "@@ -0,0 +1 @@", + "+s", + "", + }, "\n") + + got, err := parseAddedLines([]byte(patch)) + if err != nil { + t.Fatalf("parseAddedLines: %v", err) + } + want := []OutgoingLine{ + {Path: "notes.txt", Line: 2, Text: "B"}, + {Path: "notes.txt", Line: 4, Text: "c"}, + {Path: "notes.txt", Line: 5, Text: "++ looks like a header"}, + {Path: "new.txt", Line: 1, Text: "one"}, + {Path: "new.txt", Line: 2, Text: "two"}, + {Path: "tail.txt", Line: 1, Text: "new"}, + {Path: "tail.txt", Line: 2, Text: "more"}, + {Path: "ctx.txt", Line: 1, Text: "P"}, + {Path: "ctx.txt", Line: 3, Text: "R"}, + {Path: `we"ird.txt`, Line: 1, Text: "q"}, + {Path: "has space.txt", Line: 1, Text: "s"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("added lines mismatch\n got: %+v\nwant: %+v", got, want) + } + + empty, err := parseAddedLines(nil) + if err != nil || len(empty) != 0 { + t.Fatalf("empty patch = %+v, %v; want no lines, no error", empty, err) + } + + head := "diff --git a/x b/x\n--- a/x\n+++ b/x\n" + bad := []struct { + name string + patch string + }{ + {"truncated hunk", head + "@@ -0,0 +1,2 @@\n+a\n"}, + {"overlong old side", head + "@@ -1 +1,2 @@\n-a\n-b\n+c\n+d\n"}, + {"overlong new side", head + "@@ -1,2 +1 @@\n+a\n+b\n-c\n-d\n"}, + {"added line after its hunk", head + "@@ -0,0 +1 @@\n+a\n+b\n"}, + {"removed line after its hunk", head + "@@ -1 +0,0 @@\n-a\n-b\n"}, + {"context line after its hunk", head + "@@ -0,0 +1 @@\n+a\n b\n"}, + {"unexpected line inside a hunk", head + "@@ -0,0 +1,2 @@\n+a\nb\n"}, + {"second +++ header in one file", head + "@@ -0,0 +1 @@\n+a\n+++ b/y\n"}, + {"+++ header before any diff --git", "+++ b/x\n@@ -0,0 +1 @@\n+a\n"}, + {"hunk before its +++ header", "diff --git a/x b/x\n@@ -0,0 +1 @@\n+a\n"}, + {"added line in a deleted file", "diff --git a/x b/x\n--- a/x\n+++ /dev/null\n@@ -1 +1 @@\n-a\n+b\n"}, + {"header path lacks b/", "diff --git a/x b/x\n--- a/x\n+++ x\n@@ -0,0 +1 @@\n+a\n"}, + {"unterminated quoted path", "diff --git a/x b/x\n--- a/x\n+++ \"b/x\n@@ -0,0 +1 @@\n+a\n"}, + {"malformed hunk header", head + "@@ -0,0 @@\n+a\n"}, + {"non-numeric hunk count", head + "@@ -0,0 +1,z @@\n+a\n"}, + {"signed hunk start", head + "@@ -0,0 +-1 @@\n+a\n"}, + {"hunk header without closing marker", head + "@@ -0,0 +1\n+a\n"}, + } + for _, tc := range bad { + t.Run(tc.name, func(t *testing.T) { + lines, err := parseAddedLines([]byte(tc.patch)) + if err == nil { + t.Fatalf("parseAddedLines accepted a malformed patch: %+v", lines) + } + if lines != nil { + t.Fatalf("malformed patch returned a partial list: %+v", lines) + } + }) + } +} + +// TestParseOutgoingLog proves the NUL-delimited "<hash>\x01<raw message>" log +// records parse into commits carrying each message verbatim, and that a record +// without its separator, or with a malformed hash, is an error rather than a +// skipped commit. +func TestParseOutgoingLog(t *testing.T) { + const a = "1111111111111111111111111111111111111111" + const b = "2222222222222222222222222222222222222222" + out := a + "\x01Add notes\n\nBody (0007)\n\x00" + b + "\x01Edit notes\n\x00" + + got, err := parseOutgoingLog([]byte(out)) + if err != nil { + t.Fatalf("parseOutgoingLog: %v", err) + } + want := []OutgoingCommit{ + {Commit: a, Message: "Add notes\n\nBody (0007)\n"}, + {Commit: b, Message: "Edit notes\n"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("commits mismatch\n got: %+v\nwant: %+v", got, want) + } + + if none, err := parseOutgoingLog(nil); err != nil || len(none) != 0 { + t.Fatalf("empty log = %+v, %v; want no commits, no error", none, err) + } + + for name, bad := range map[string]string{ + "record without separator": a + "\x01ok\n\x00" + b + "no separator\n\x00", + "malformed hash": "abc\x01msg\n\x00", + } { + t.Run(name, func(t *testing.T) { + commits, err := parseOutgoingLog([]byte(bad)) + if err == nil { + t.Fatalf("parseOutgoingLog accepted %q: %+v", bad, commits) + } + }) + } +} + +// TestParseAddedPaths proves the `--name-status -z --diff-filter=A` reader keeps +// each path verbatim (spaces, quotes, non-ASCII, a newline) and that an odd +// token count or a status other than A is an error. +func TestParseAddedPaths(t *testing.T) { + out := "A\x00has space.txt\x00A\x00we\"ird.txt\x00A\x00café.txt\x00A\x00new\nline.txt\x00" + got, err := parseAddedPaths([]byte(out)) + if err != nil { + t.Fatalf("parseAddedPaths: %v", err) + } + want := []string{"has space.txt", `we"ird.txt`, "café.txt", "new\nline.txt"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("paths = %q, want %q", got, want) + } + if none, err := parseAddedPaths(nil); err != nil || len(none) != 0 { + t.Fatalf("empty output = %q, %v; want no paths, no error", none, err) + } + for name, bad := range map[string]string{ + "odd token count": "A\x00x.txt\x00A\x00", + "non-A status": "M\x00x.txt\x00", + "empty path": "A\x00\x00", + } { + t.Run(name, func(t *testing.T) { + if paths, err := parseAddedPaths([]byte(bad)); err == nil { + t.Fatalf("parseAddedPaths accepted %q: %q", bad, paths) + } + }) + } +} diff --git a/internal/gitcli/push.go b/internal/gitcli/push.go index b9fd0777e..19328f1a4 100644 --- a/internal/gitcli/push.go +++ b/internal/gitcli/push.go @@ -12,6 +12,11 @@ const ( isAncestorOp Operation = "is-ancestor" ) +// branchOnlyPushDetail is the refusal both lease pushes give a ref outside +// refs/heads/: docket publishes branches only, so its local refs/docket/ scratch +// refs (and tags, notes, or any other namespace) can never reach a remote. +const branchOnlyPushDetail = "docket pushes branches only; a ref outside refs/heads/ (its local refs/docket/ scratch refs included) never leaves the clone" + // PushDisposition is the structural outcome of a lease push. type PushDisposition string @@ -51,6 +56,9 @@ func (c *Client) PushLease(ctx context.Context, repo Repository, remote RemoteNa if err := validateRefName(ref); err != nil { return PushOutcome{}, newFailure(pushLeaseOp, KindInvalidRequest, "invalid ref name", err) } + if !strings.HasPrefix(string(ref), "refs/heads/") { + return PushOutcome{}, newFailure(pushLeaseOp, KindInvalidRequest, branchOnlyPushDetail, nil) + } if err := validateObjectID(commit); err != nil { return PushOutcome{}, newFailure(pushLeaseOp, KindInvalidRequest, "invalid commit id", err) } @@ -123,6 +131,9 @@ func (c *Client) PushCreateLease(ctx context.Context, repo Repository, remote Re if err := validateRefName(ref); err != nil { return PushOutcome{}, newFailure(pushCreateLeaseOp, KindInvalidRequest, "invalid ref name", err) } + if !strings.HasPrefix(string(ref), "refs/heads/") { + return PushOutcome{}, newFailure(pushCreateLeaseOp, KindInvalidRequest, branchOnlyPushDetail, nil) + } if err := validateObjectID(commit); err != nil { return PushOutcome{}, newFailure(pushCreateLeaseOp, KindInvalidRequest, "invalid commit id", err) } diff --git a/internal/gitcli/push_guard_test.go b/internal/gitcli/push_guard_test.go new file mode 100644 index 000000000..dc6e3fbe4 --- /dev/null +++ b/internal/gitcli/push_guard_test.go @@ -0,0 +1,37 @@ +package gitcli + +import ( + "context" + "testing" + + "github.com/danielhanold/docket/internal/testsupport" +) + +// TestPushRefusesNonBranchRefs proves both lease pushes refuse any ref outside +// refs/heads/ as an invalid request before git runs, so a local refs/docket/ +// scratch ref (or any other non-branch ref) can never reach a remote. The repo is +// an empty directory: were the guard missing, git would run there and the push +// would come back as a plain failed disposition with no error, not this refusal. +func TestPushRefusesNonBranchRefs(t *testing.T) { + ctx := context.Background() + c := newRealClient(t) + repo := Repository{PrimaryWorktree: testsupport.TempDir(t)} + const commit = ObjectID("1111111111111111111111111111111111111111") + const expected = ObjectID("2222222222222222222222222222222222222222") + + for _, ref := range []RefName{"refs/docket/finalize/7/orig", "refs/dckt/x", "refs/tags/v1"} { + t.Run(string(ref), func(t *testing.T) { + out, err := c.PushLease(ctx, repo, "origin", ref, commit, expected) + if out != (PushOutcome{}) { + t.Errorf("PushLease(%s) outcome = %+v, want zero", ref, out) + } + assertKind(t, err, KindInvalidRequest) + + out, err = c.PushCreateLease(ctx, repo, "origin", ref, commit) + if out != (PushOutcome{}) { + t.Errorf("PushCreateLease(%s) outcome = %+v, want zero", ref, out) + } + assertKind(t, err, KindInvalidRequest) + }) + } +} diff --git a/internal/gitcli/remoteheads.go b/internal/gitcli/remoteheads.go index 3ec2d9ad8..d18ae98a2 100644 --- a/internal/gitcli/remoteheads.go +++ b/internal/gitcli/remoteheads.go @@ -69,3 +69,66 @@ func (c *Client) ListRemoteHeads(ctx context.Context, repo Repository, remote Re } return heads, nil } + +// Operation label for the named remote-refs listing. +const listRemoteRefsOp Operation = "list-remote-refs" + +// ListRemoteRefs reads the remote refs matching patterns via +// `git ls-remote --refs <remote> <patterns...>` (a network READ on the read +// budget) and returns each as a map from its fully qualified RefName to the full +// ObjectID it points at. Patterns use ls-remote's own matching (a pattern +// matches a ref equal to it or ending in "/" plus it, and may carry globs), so a +// caller that needs exact names filters the result. Unlike ListRemoteHeads, any +// refs/ name may be returned. At least one pattern is required (no patterns +// would list every ref), and each must be non-empty and must not begin with "-". +// +// A clean exit with no match returns an empty NON-NIL map — a proven absence. +// The advertisement is parsed as strictly as ListRemoteHeads': a malformed line, +// a malformed id or ref name, or a ref advertised twice is invalid-output, and +// any non-zero exit (an unreachable remote included) is command-failed — never +// a partial or empty map a caller could misread as "no such refs". +func (c *Client) ListRemoteRefs(ctx context.Context, repo Repository, remote RemoteName, patterns []string) (map[RefName]ObjectID, error) { + if err := validateRemoteName(remote); err != nil { + return nil, newFailure(listRemoteRefsOp, KindInvalidRequest, "invalid remote name", err) + } + if len(patterns) == 0 { + return nil, newFailure(listRemoteRefsOp, KindInvalidRequest, "at least one ref pattern is required", nil) + } + for _, p := range patterns { + if p == "" || strings.HasPrefix(p, "-") { + return nil, newFailure(listRemoteRefsOp, KindInvalidRequest, "invalid ref pattern", nil) + } + } + res, f := c.run(ctx, runRequest{ + op: listRemoteRefsOp, + dir: repo.PrimaryWorktree, + args: append([]string{"ls-remote", "--refs", string(remote)}, patterns...), + network: true, + }) + if f != nil { + return nil, f + } + if res.exitCode != 0 { + return nil, newFailure(listRemoteRefsOp, KindCommandFailed, "ls-remote --refs failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) + } + + refs := make(map[RefName]ObjectID) + for _, line := range stdoutLines(res.stdout) { + id, name, ok := splitLsRemoteLine(line) + if !ok { + return nil, newFailure(listRemoteRefsOp, KindInvalidOutput, "malformed ls-remote line", nil) + } + if err := validateObjectID(id); err != nil { + return nil, newFailure(listRemoteRefsOp, KindInvalidOutput, "ls-remote produced a malformed object id", err) + } + ref := RefName(name) + if err := validateRefName(ref); err != nil { + return nil, newFailure(listRemoteRefsOp, KindInvalidOutput, "ls-remote produced an invalid ref name", err) + } + if _, dup := refs[ref]; dup { + return nil, newFailure(listRemoteRefsOp, KindInvalidOutput, "ls-remote advertised a ref more than once", nil) + } + refs[ref] = id + } + return refs, nil +} diff --git a/internal/gitcli/remoteheads_integration_test.go b/internal/gitcli/remoteheads_integration_test.go index d55ef7c42..efe2317de 100644 --- a/internal/gitcli/remoteheads_integration_test.go +++ b/internal/gitcli/remoteheads_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "github.com/danielhanold/docket/internal/testsupport" "path/filepath" + "reflect" "testing" ) @@ -125,3 +126,54 @@ func TestIntegrationListRemoteHeadsTransportFailureIsError(t *testing.T) { } assertKind(t, err, KindCommandFailed) } + +// TestIntegrationRepoListRemoteRefs proves ListRemoteRefs returns exactly the +// remote refs its patterns name, each at the exact object id the origin holds +// (oracle read straight from origin): docket-named branches and a refs/docket/ +// namespace ref match, while an unrelated feature branch does not. An +// unreachable remote is an error, never an empty map a caller could read as +// "no such refs", and a dash-led pattern is refused before git runs. +func TestIntegrationRepoListRemoteRefs(t *testing.T) { + requireGit(t) + ctx := context.Background() + c := newRealClient(t) + r := newMainModeRepos(t) + r.writerCommit(t, "docket", map[string]string{"d.md": "d\n"}) + r.writerCommit(t, "dckt", map[string]string{"k.md": "k\n"}) + r.writerCommit(t, "feature/q", map[string]string{"q.md": "q\n"}) + gitOut(t, r.Writer, "push", "-q", "origin", "HEAD:refs/docket/x/y") + repo := mustDiscover(t, c, r.Invocation) + + want := map[RefName]ObjectID{ + "refs/heads/docket": ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/docket")), + "refs/heads/dckt": ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/heads/dckt")), + "refs/docket/x/y": ObjectID(gitOut(t, r.Origin, "rev-parse", "refs/docket/x/y")), + } + got, err := c.ListRemoteRefs(ctx, repo, "origin", []string{"refs/heads/docket", "refs/heads/dckt", "refs/docket/*"}) + if err != nil { + t.Fatalf("ListRemoteRefs: %v", err) + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("ListRemoteRefs = %+v, want %+v", got, want) + } + + none, err := c.ListRemoteRefs(ctx, repo, "origin", []string{"refs/heads/no-such-branch"}) + if err != nil { + t.Fatalf("ListRemoteRefs with no match: %v", err) + } + if none == nil || len(none) != 0 { + t.Fatalf("no match = %#v, want an empty non-nil map", none) + } + + gitOut(t, r.Invocation, "remote", "add", "gone", filepath.Join(testsupport.TempDir(t), "missing.git")) + unreachable, err := c.ListRemoteRefs(ctx, repo, "gone", []string{"refs/heads/docket"}) + if unreachable != nil { + t.Fatalf("unreachable remote produced a map: %+v", unreachable) + } + assertKind(t, err, KindCommandFailed) + + for _, bad := range [][]string{{"-x"}, {""}, nil} { + _, err := c.ListRemoteRefs(ctx, repo, "origin", bad) + assertKind(t, err, KindInvalidRequest) + } +} From 4b31da08f3ee5f60ba0f7dade3eb61c181f4bedc Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 11:49:21 -0400 Subject: [PATCH 05/13] feat(app): private repositories publish verbatim PR prose and a line-free spec copy --- internal/app/pr_publish.go | 44 ++++++++------ internal/app/repository_prepare.go | 5 ++ internal/app/repository_prepare_test.go | 27 +++++++++ internal/app/workflow_integration_test.go | 3 +- .../app/workflow_private_integration_test.go | 58 ++++++++++++++++++- internal/app/workspace_spec.go | 25 +++++--- internal/app/workspace_spec_test.go | 37 ++++++++++-- 7 files changed, 165 insertions(+), 34 deletions(-) diff --git a/internal/app/pr_publish.go b/internal/app/pr_publish.go index d4a2958dc..35687a401 100644 --- a/internal/app/pr_publish.go +++ b/internal/app/pr_publish.go @@ -9,6 +9,7 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository" ) @@ -36,7 +37,9 @@ import ( // is a typed refusal and gh is never invoked. // - Redaction. The authored PR prose is preserved byte-for-byte while only the // Docket-owned backlink block is inserted/replaced (via the loss-preserving -// document patch). The evidence is a head gate only: it is never woven into +// document patch). In a private repository nothing is inserted: the +// authored prose is published verbatim, with no backlink and no artifacts +// block. The evidence is a head gate only: it is never woven into // the PR body — its durable home is the change record's "## Build evidence" // section, written by `change mark-implemented`. The result never carries the // PR body bytes — no Body/Title field exists on it. @@ -226,20 +229,24 @@ func PRPublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } // (6) Resolve the change record for the deterministic backlink block. - change, link, refusal := resolvePRChange(ctx, deps, repoDir, req.ID) + change, pin, refusal := resolvePRChange(ctx, deps, repoDir, req.ID) if refusal != nil { return *refusal } - backlink, err := render.BacklinkContent(change, link) - if err != nil { - return prRefusal(ResultInternalError, ReasonStatusInternalError, err.Error(), req.ID) - } // (7) Assemble the PR body: authored prose preserved byte-for-byte, only the - // backlink block and the plan/results links block inserted/replaced. - body, err := assemblePRBody([]byte(req.Body), backlink, render.PRArtifactLinksContent(change, link)) - if err != nil { - return prRefusal(ResultInvalidState, ReasonPRBodyAssemblyFailed, err.Error(), req.ID) + // backlink block and the plan/results links block inserted/replaced. A + // private repository publishes the authored prose verbatim. + body := []byte(req.Body) + if pin.Layout.Mode != layout.Private { + link := linkContextOf(pin) + backlink, err := render.BacklinkContent(change, link) + if err != nil { + return prRefusal(ResultInternalError, ReasonStatusInternalError, err.Error(), req.ID) + } + if body, err = assemblePRBody([]byte(req.Body), backlink, render.PRArtifactLinksContent(change, link)); err != nil { + return prRefusal(ResultInvalidState, ReasonPRBodyAssemblyFailed, err.Error(), req.ID) + } } // (8a) Run mutation fence (change 0375 Task 11): PR creation is an external @@ -305,27 +312,28 @@ func prFenceRefusal(id int, ferr error) PRPublishResult { // resolvePRChange pins context once, reads the corpus once, builds the snapshot, // and returns the change named by id (a typed unknown/ambiguous refusal otherwise) -// with the link context its backlink renders under. A validation error relevant +// with the pinned context (its layout decides the visibility mode; its link +// context is what the backlink renders under). A validation error relevant // to the change (namedPreEffectErrors, change 0449) is a typed record-invalid // refusal. -func resolvePRChange(ctx context.Context, deps PlanningDeps, repoDir string, id int) (domain.Change, render.LinkContext, *PRPublishResult) { +func resolvePRChange(ctx context.Context, deps PlanningDeps, repoDir string, id int) (domain.Change, StatusPin, *PRPublishResult) { pin, err := deps.Reader.PinContext(ctx, repoDir) if err != nil { result, reason := classifyStatusError(ctx, err) r := prRefusal(result, reason, err.Error(), id) - return domain.Change{}, render.LinkContext{}, &r + return domain.Change{}, StatusPin{}, &r } blobs, err := deps.Reader.ReadCorpus(ctx, pin) if err != nil { result, reason := classifyStatusError(ctx, err) r := prRefusal(result, reason, err.Error(), id) - return domain.Change{}, render.LinkContext{}, &r + return domain.Change{}, StatusPin{}, &r } inputs, _ := parseCorpus(blobs) build, err := repository.BuildSnapshot(repository.BuildInput{Config: pin.Config.Effective, Documents: inputs}) if err != nil { r := prRefusal(ResultInternalError, ReasonStatusInternalError, err.Error(), id) - return domain.Change{}, render.LinkContext{}, &r + return domain.Change{}, StatusPin{}, &r } c, out := build.Snapshot.Change(domain.ChangeID(id)) if out != domain.LookupFound { @@ -336,7 +344,7 @@ func resolvePRChange(ctx context.Context, deps PlanningDeps, repoDir string, id msg = fmt.Sprintf("more than one record claims change id %04d; refusing to choose", id) } r := prRefusal(result, reason, msg, id) - return domain.Change{}, render.LinkContext{}, &r + return domain.Change{}, StatusPin{}, &r } // B must pass relevant validation before the GitHub publication (change // 0449): an error on B or on a record B structurally requires refuses here, @@ -345,9 +353,9 @@ func resolvePRChange(ctx context.Context, deps PlanningDeps, repoDir string, id if bad := namedPreEffectErrors(build, id, c.Path()); len(bad) > 0 { r := prRefusal(ResultInvalidState, ReasonPRRecordInvalid, namedPreEffectMessage(id, bad), id) r.Findings = findingsToStatus(bad) - return domain.Change{}, render.LinkContext{}, &r + return domain.Change{}, StatusPin{}, &r } - return c, linkContextOf(pin), nil + return c, pin, nil } // assemblePRBody weaves the Docket-owned blocks into the authored PR prose diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index 085faab2d..b815b5f28 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -159,6 +159,10 @@ type PrepareContext struct { // resolved remote instead of assuming origin. MetadataRemote string `json:"metadata_remote"` MetadataTrackingRef string `json:"metadata_tracking_ref"` + // Visibility is the repository's visibility mode ("shared" or "private"), + // so a workflow knows whether what it writes ships into a private + // repository. + Visibility string `json:"visibility"` ChangesDir string `json:"changes_dir"` AdrsDir string `json:"adrs_dir"` @@ -547,6 +551,7 @@ func buildPrepareContext(cfg config.Effective, sc setupContext, f reposetup.Fact MetadataBranchRevision: f.RemoteMetadata.Tip, MetadataRemote: sc.layout.MetadataRemote, MetadataTrackingRef: sc.layout.TrackingRef(), + Visibility: string(sc.layout.Mode), MetadataWorktreePath: sc.layout.MetadataWorktree, ChangesDir: cfg.ChangesDir.Value, AdrsDir: cfg.ADRsDir.Value, diff --git a/internal/app/repository_prepare_test.go b/internal/app/repository_prepare_test.go index 97516b337..b906677c8 100644 --- a/internal/app/repository_prepare_test.go +++ b/internal/app/repository_prepare_test.go @@ -623,6 +623,33 @@ func TestRepositoryPrepareContextFieldsTyped(t *testing.T) { } } +// TestBuildPrepareContextVisibility: the prepare context names the repository's +// visibility mode, so a workflow knows whether it writes for a private +// repository. +func TestBuildPrepareContextVisibility(t *testing.T) { + sc := setupContext{ + repo: gitcli.Repository{PrimaryWorktree: "/repo", CommonDir: "/repo/.git"}, + defaultBranch: "main", + integrationBranch: "main", + layout: layout.SharedLayout("/repo/.git", "/repo"), + } + if got := buildPrepareContext(sc.cfg, sc, preparableFacts(), "").Visibility; got != "shared" { + t.Errorf("shared layout visibility = %q, want shared", got) + } + sc.layout = layout.PrivateLayout("/repo/.git", "/repo", "/d", "o-r") + pc := buildPrepareContext(sc.cfg, sc, preparableFacts(), "") + if pc.Visibility != "private" { + t.Errorf("private layout visibility = %q, want private", pc.Visibility) + } + raw, err := json.Marshal(pc) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(raw), `"visibility":"private"`) { + t.Errorf("context JSON lacks \"visibility\":\"private\": %s", raw) + } +} + // TestRepositoryPrepareResolverCapAgreesWithConfigDiagnostics reads the resolver // cap (change 0349) from both surfaces off ONE resolved fixture and asserts they // agree (spec test 1): the prepare `context.finalize.resolver_max_attempts` and diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index 60107572f..4432b0033 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -6,6 +6,7 @@ import ( "context" "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/repository" "github.com/danielhanold/docket/internal/workspace" @@ -1838,7 +1839,7 @@ func expectedSpecCopy(t *testing.T, metadataSpec string) string { Kind: repository.KindChange, Location: repository.LocationActive, Path: groomPath(31, "copyme"), Revision: "blobcopyme", Data: []byte(commitSpecRecord(commitSpecPath)), }}) - want, err := specCopyBytes([]byte(metadataSpec), mustChange(t, snap, 31)) + want, err := specCopyBytes([]byte(metadataSpec), mustChange(t, snap, 31), layout.Shared) if err != nil { t.Fatalf("specCopyBytes: %v", err) } diff --git a/internal/app/workflow_private_integration_test.go b/internal/app/workflow_private_integration_test.go index f64d3daf3..30af667df 100644 --- a/internal/app/workflow_private_integration_test.go +++ b/internal/app/workflow_private_integration_test.go @@ -99,6 +99,61 @@ func resolvedPrivateLayout(t *testing.T, dir string) layout.Layout { return lay } +// privateLifecycleChecks drives the whole claim-to-implemented run, then proves +// what a private repository publishes on origin: the one open PR carries the +// authored title and prose byte-for-byte (no backlink, no artifacts block), and +// the spec copy at the feature head carries no change line. +func privateLifecycleChecks(t *testing.T, created ChangeCreateResult) workflowEntry { + return func(node realNode, wdeps WorkspaceDeps, complete func(string) GitHubDeps) { + t.Helper() + ctx := context.Background() + gdeps := complete("") + + insp := WorkspaceInspect(ctx, node.deps, wdeps, node.dir, WorkspaceIDRequest{ID: created.ID}) + if insp.Result != ResultApplied || insp.FeatureRef == "" || insp.Head == "" { + t.Fatalf("workspace inspect = %q (reason %q), want a feature ref and head", insp.Result, insp.Reason) + } + headBranch := strings.TrimPrefix(insp.FeatureRef, "refs/heads/") + repo, err := gdeps.Service.DiscoverRepository(ctx, node.dir) + if err != nil { + t.Fatalf("discover repository: %v", err) + } + prs, err := gdeps.Service.FindOpenPullRequestsByHead(ctx, repo, headBranch) + if err != nil { + t.Fatalf("find open PRs for %s: %v", headBranch, err) + } + if len(prs) != 1 { + t.Fatalf("open PRs for %s = %d, want exactly one", headBranch, len(prs)) + } + if want := "Authored PR prose for the widget.\n"; prs[0].Body != want { + t.Errorf("private PR body = %q, want the authored prose verbatim %q", prs[0].Body, want) + } + if prs[0].Title != "Add the widget" { + t.Errorf("private PR title = %q, want %q", prs[0].Title, "Add the widget") + } + + // The spec copy at the feature head: present, and no change line. + var specs []string + for _, p := range strings.Split(runGit(t, node.dir, "ls-tree", "-r", "--name-only", insp.Head), "\n") { + if strings.HasSuffix(p, "-design.md") { + specs = append(specs, p) + } + } + if len(specs) != 1 { + t.Fatalf("spec copies at the feature head = %v, want exactly one", specs) + } + spec := runGit(t, node.dir, "show", insp.Head+":"+specs[0]) + if !strings.Contains(spec, "Build the widget.") { + t.Fatalf("spec copy %s lacks the authored spec:\n%s", specs[0], spec) + } + for _, line := range strings.Split(spec, "\n") { + if strings.HasPrefix(line, "Change 0") { + t.Errorf("private spec copy %s carries a change line %q:\n%s", specs[0], line, spec) + } + } + } +} + // 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 @@ -162,7 +217,8 @@ func TestIntegrationWorkflowLifecyclePrivateInitToImplemented(t *testing.T) { } // Drive the whole run against the private store. - driveClaimToImplemented(t, repo, privateFixtureBranch, ghBin, created.ID, created.Slug, created.Path, groom.SpecPath) + driveClaimToImplemented(t, repo, privateFixtureBranch, ghBin, created.ID, created.Slug, created.Path, groom.SpecPath, + privateLifecycleChecks(t, created)) // Origin gained exactly one branch, and it is the feature branch. originAfter := localHeads(t, r.origin) diff --git a/internal/app/workspace_spec.go b/internal/app/workspace_spec.go index 562133e0d..e451d50f9 100644 --- a/internal/app/workspace_spec.go +++ b/internal/app/workspace_spec.go @@ -9,6 +9,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/workspace" ) @@ -16,8 +17,9 @@ import ( // change's metadata spec onto its feature branch, at the spec's own path, so a // PR carries exactly the spec copy plus code. The copy is a pure function of the // metadata spec and the change (specCopyBytes): the spec's docket:backlink block -// is dropped and one plain change line (specCopyChangeLine) is inserted after its -// title. The operation is idempotent — a feature head that already carries the +// is dropped and, in a shared repository, one plain change line +// (specCopyChangeLine) is inserted after its title; a private repository's copy +// carries no change line. The operation is idempotent — a feature head that already carries the // exact copy is a no-op — and a revised metadata spec refreshes the copy with a // second commit. // @@ -66,8 +68,8 @@ const ( const specCopyFileMode = gitcli.FileMode("100644") // specCopyChangeLine renders the one plain line the feature-branch spec copy -// carries after its title. It has no URL, so it never needs updating. It is the -// single seam a visibility mode would change (a private repository omits it). +// carries after its title. It has no URL, so it never needs updating. A private +// repository omits it (specCopyBytes under layout.Private). func specCopyChangeLine(c domain.Change) string { return fmt.Sprintf("Change %04d — %s", int(c.ID()), c.Title()) } @@ -75,9 +77,11 @@ func specCopyChangeLine(c domain.Change) string { // specCopyBytes renders the copy of a change's metadata spec that rides the PR: // the spec without its docket:backlink block, with specCopyChangeLine inserted // after the first top-level "# " title outside fenced code (or at the top when -// the spec has no title). Line endings follow the source. Malformed managed -// markers refuse — the copy is never built from a spec the parser rejects. -func specCopyBytes(spec []byte, c domain.Change) ([]byte, error) { +// the spec has no title). In a private repository (mode layout.Private) the copy +// is the spec without its backlink and leading blank lines, with no change line. +// Line endings follow the source. Malformed managed markers refuse — the copy is +// never built from a spec the parser rejects. +func specCopyBytes(spec []byte, c domain.Change, mode layout.Mode) ([]byte, error) { doc, err := document.Parse(spec) if err != nil { return nil, fmt.Errorf("spec copy: %w", err) @@ -95,6 +99,11 @@ func specCopyBytes(spec []byte, c domain.Change) ([]byte, error) { } } text := strings.TrimLeft(string(body), "\r\n") + if mode == layout.Private { + // A private repository's copy carries no change line: nothing that + // ships names the change. + return []byte(text), nil + } line := specCopyChangeLine(c) end, ok := firstTitleLineEnd(text) if !ok { @@ -199,7 +208,7 @@ func WorkspaceCommitSpec(ctx context.Context, deps PlanningDeps, wdeps Workspace return refuse(ResultInvalidState, ReasonSpecCopyMissing, fmt.Sprintf("change %04d links spec %s, which does not exist on the metadata branch", req.ID, specPath)) } - copyBytes, err := specCopyBytes(art.Data, wc.change) + copyBytes, err := specCopyBytes(art.Data, wc.change, wc.pin.Layout.Mode) if err != nil { return refuse(ResultInvalidState, ReasonSpecCopyMalformed, fmt.Sprintf("the metadata spec %s cannot be copied: %v", specPath, err)) diff --git a/internal/app/workspace_spec_test.go b/internal/app/workspace_spec_test.go index ff77747b7..a4741b5bf 100644 --- a/internal/app/workspace_spec_test.go +++ b/internal/app/workspace_spec_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/repository" ) @@ -31,7 +32,7 @@ func specChange(t *testing.T) domain.Change { } func TestSpecCopyBytesStripsBacklinkAndAddsChangeLine(t *testing.T) { - got, err := specCopyBytes([]byte(specFixture()), specChange(t)) + got, err := specCopyBytes([]byte(specFixture()), specChange(t), layout.Shared) if err != nil { t.Fatal(err) } @@ -41,9 +42,33 @@ func TestSpecCopyBytesStripsBacklinkAndAddsChangeLine(t *testing.T) { } } +// TestSpecCopyPrivateOmitsChangeLine: a private repository's copy is the spec +// minus its backlink with leading newlines trimmed, and carries no change line; +// the shared copy is unchanged. +func TestSpecCopyPrivateOmitsChangeLine(t *testing.T) { + got, err := specCopyBytes([]byte(specFixture()), specChange(t), layout.Private) + if err != nil { + t.Fatal(err) + } + want := "# Widget: design\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n" + if string(got) != want { + t.Fatalf("private copy:\n%q\nwant\n%q", got, want) + } + if strings.Contains(string(got), "Change ") { + t.Fatalf("private copy carries a change line:\n%q", got) + } + shared, err := specCopyBytes([]byte(specFixture()), specChange(t), layout.Shared) + if err != nil { + t.Fatal(err) + } + if wantShared := "# Widget: design\n\nChange 0042 — Widget\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n"; string(shared) != wantShared { + t.Fatalf("shared copy:\n%q\nwant\n%q", shared, wantShared) + } +} + func TestSpecCopyBytesSkipsFencedH1(t *testing.T) { src := "```\n# not a title\n```\n\n# Real title\n\ntext\n" - got, err := specCopyBytes([]byte(src), specChange(t)) + got, err := specCopyBytes([]byte(src), specChange(t), layout.Shared) if err != nil { t.Fatal(err) } @@ -56,7 +81,7 @@ func TestSpecCopyBytesSkipsFencedH1(t *testing.T) { } func TestSpecCopyBytesNoTitle(t *testing.T) { - got, err := specCopyBytes([]byte("Just prose.\n"), specChange(t)) + got, err := specCopyBytes([]byte("Just prose.\n"), specChange(t), layout.Shared) if err != nil { t.Fatal(err) } @@ -69,7 +94,7 @@ func TestSpecCopyBytesNoTitle(t *testing.T) { // spec ends on it) still gets the change line after it, separated by one // blank line, and the copy ends with a terminator. func TestSpecCopyBytesTitleIsLastLine(t *testing.T) { - got, err := specCopyBytes([]byte("# Only a title"), specChange(t)) + got, err := specCopyBytes([]byte("# Only a title"), specChange(t), layout.Shared) if err != nil { t.Fatal(err) } @@ -80,7 +105,7 @@ func TestSpecCopyBytesTitleIsLastLine(t *testing.T) { func TestSpecCopyBytesKeepsCRLF(t *testing.T) { src := strings.ReplaceAll(specFixture(), "\n", "\r\n") - got, err := specCopyBytes([]byte(src), specChange(t)) + got, err := specCopyBytes([]byte(src), specChange(t), layout.Shared) if err != nil { t.Fatal(err) } @@ -98,7 +123,7 @@ func TestSpecCopyBytesKeepsCRLF(t *testing.T) { func TestSpecCopyBytesRefusesMalformedMarkers(t *testing.T) { src := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n# T\n" - if _, err := specCopyBytes([]byte(src), specChange(t)); err == nil { + if _, err := specCopyBytes([]byte(src), specChange(t), layout.Shared); err == nil { t.Fatal("a dangling backlink marker must refuse, never be copied") } } From dea6cc8fa144fbb82a820a3695c9db010e8d4ecb Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 11:55:12 -0400 Subject: [PATCH 06/13] feat(finalize): no PR comment or backlink repoint in a private repository --- internal/app/finalize_block.go | 56 +++++++++------ internal/app/finalize_cleanup.go | 3 + internal/app/finalize_closeout.go | 5 +- internal/app/maintenance.go | 57 ++++++++------- internal/app/maintenance_assess_test.go | 71 ++++++++++++++++++- internal/app/pr_backlink_repoint.go | 9 +++ .../repository_private_integration_test.go | 35 +++++++++ internal/app/repository_repair_prbacklinks.go | 14 ++++ .../app/workflow_private_integration_test.go | 39 +++++++++- internal/cli/finalize.go | 2 +- 10 files changed, 236 insertions(+), 55 deletions(-) diff --git a/internal/app/finalize_block.go b/internal/app/finalize_block.go index e9c97624c..b4258f379 100644 --- a/internal/app/finalize_block.go +++ b/internal/app/finalize_block.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository" "github.com/danielhanold/docket/internal/repository/transaction" @@ -39,6 +40,9 @@ import ( // rewrite). The inline board is rerendered atomically in the same // transaction. // +// A private repository posts no PR comment at all: step 1 is skipped, the marker +// carries no comment URL, and the marker transaction is the only effect. +// // A crash between the comment and the marker replays cleanly: the comment ensure // finds the owned marker and reuses it, and the marker transaction — keyed on // whether the section already records THIS attempt (the promised state, not a @@ -233,7 +237,8 @@ type blockReceipt struct { // FinalizeBlock ensures the owned PR comment first, then upserts the single // durable "## Finalize blocked" section in one exact-revision transaction. A -// comment that cannot be established is unknown and writes no marker. +// comment that cannot be established is unknown and writes no marker. A private +// repository posts no comment; the marker transaction is its only effect. func FinalizeBlock(ctx context.Context, deps FinalizeDeps, repoDir string, req BlockRequest) BlockResult { if findings := validateBlockShape(req); len(findings) > 0 { return newBlockResult(OperationFinalizeBlock, ResultInvalidInput, BlockResult{ID: req.ID, Findings: findings}) @@ -271,30 +276,35 @@ func FinalizeBlock(ctx context.Context, deps FinalizeDeps, repoDir string, req B } // Ensure the owned PR comment FIRST. It is idempotent by its attempt marker; - // an unresolved probe is unknown and writes no marker. - ghRepo, err := deps.GitHub.DiscoverRepository(ctx, repoDir) - if err != nil { - return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ - ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockRepoUnresolved, Message: err.Error(), - }) - } - marker := finalizeBlockedCommentMarker(req.Attempt) - commentBody := marker + "\n\n" + strings.TrimRight(req.Report, "\r\n") + "\n" - outcome, url, cerr := deps.GitHub.EnsureComment(ctx, ghRepo, req.PRNumber, marker, commentBody) - if cerr != nil { - return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ - ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockCommentUnknown, Message: cerr.Error(), - }) - } - if outcome == githubcli.CommentUnknown { - return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ - ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockCommentUnknown, - Message: "the owned pull-request comment could not be established; no finalize-blocked marker was written", - }) + // an unresolved probe is unknown and writes no marker. A private repository + // posts no comment: the marker transaction is its only effect. + url := "" + if pin.Layout.Mode != layout.Private { + ghRepo, err := deps.GitHub.DiscoverRepository(ctx, repoDir) + if err != nil { + return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ + ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockRepoUnresolved, Message: err.Error(), + }) + } + marker := finalizeBlockedCommentMarker(req.Attempt) + commentBody := marker + "\n\n" + strings.TrimRight(req.Report, "\r\n") + "\n" + outcome, curl, cerr := deps.GitHub.EnsureComment(ctx, ghRepo, req.PRNumber, marker, commentBody) + if cerr != nil { + return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ + ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockCommentUnknown, Message: cerr.Error(), + }) + } + if outcome == githubcli.CommentUnknown { + return newBlockResult(OperationFinalizeBlock, ResultExternalFailed, BlockResult{ + ID: req.ID, Disposition: BlockDispUnknown, Reason: ReasonBlockCommentUnknown, + Message: "the owned pull-request comment could not be established; no finalize-blocked marker was written", + }) + } + url = curl } - // The comment is present. Open the exact-revision transaction that upserts the - // single marker section. + // The comment is present (or, privately, not wanted). Open the exact-revision + // transaction that upserts the single marker section. repo, err := deps.Planning.Client.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: repoDir}) if err != nil { result, reason := classifyStatusError(ctx, classifyGitFailure(err)) diff --git a/internal/app/finalize_cleanup.go b/internal/app/finalize_cleanup.go index 93df101e9..b57c100f4 100644 --- a/internal/app/finalize_cleanup.go +++ b/internal/app/finalize_cleanup.go @@ -392,6 +392,9 @@ func finalizeCleanupResult(id int, disp string, removed []string, findings []Sta // reads the PR and issues no edit; when close-out left the leg pending it lands // the block-only edit. No editor wired runs nothing. func finalizeCleanupPRBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, number int) *StatusFinding { + if !prBacklinksApply(cc.pin.Layout) { + return nil + } ed := prBodyEditor(deps) if ed == nil { return nil diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index 07faa1e8b..8ff9cb381 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -838,8 +838,11 @@ func runCloseoutArchiveTransaction(ctx context.Context, deps FinalizeDeps, cc *c // archive path. It runs after the metadata transaction landed and never // changes the close-out disposition: each target that did not reach its promised // state contributes one pr-backlink-pending warning. No editor wired (a test -// GitHub fake) runs nothing. +// GitHub fake) runs nothing, and neither does a private repository. func runCloseoutPRBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, targets []closeoutTarget) []StatusFinding { + if !prBacklinksApply(cc.pin.Layout) { + return nil + } ed := prBodyEditor(deps) if ed == nil { return nil diff --git a/internal/app/maintenance.go b/internal/app/maintenance.go index 909e1c511..637458d91 100644 --- a/internal/app/maintenance.go +++ b/internal/app/maintenance.go @@ -9,6 +9,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/repository" "github.com/danielhanold/docket/internal/workspace" ) @@ -338,7 +339,7 @@ func MaintenanceSweep(ctx context.Context, deps FinalizeDeps, repoDir string, sc // advertisement, one worktree list, one batched PR-body read) — and only // when a done candidate needs them — then resolve every historical record locally, enqueuing only the // records that warrant a fresh cleanup attempt. - shared := gatherSweepSharedFacts(ctx, deps, repoDir, inv, historical) + shared := gatherSweepSharedFacts(ctx, deps, repoDir, pin.Layout, inv, historical) return sweepAssessHistorical(ctx, deps, wdeps, inv, pin, shared, historical) }, syncIntegration: func(ctx context.Context) *SyncOutcome { @@ -538,7 +539,7 @@ func sweepBuildSnapshot(ctx context.Context, reader StatusReader, pin StatusPin, // recorded as the leg's error (a failed shared inventory is unknown, never a clean // absence and never a fan-out into per-ref probes); a missing git client/repository // makes both inventories unknown rather than silently empty. -func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir string, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts { +func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir string, lay layout.Layout, inv sweepInventory, historical []sweepWorkItem) sweepSharedFacts { needsShared := false for _, it := range historical { if c, out := inv.snap.Change(domain.ChangeID(it.id)); out == domain.LookupFound && c.Status() == domain.StatusDone { @@ -550,31 +551,35 @@ func gatherSweepSharedFacts(ctx context.Context, deps FinalizeDeps, repoDir stri return sweepSharedFacts{} } var shared sweepSharedFacts - // One batched PR-body read over every done candidate's PR (≤25 per process) - // for the PR-backlink leg. A number the read could not resolve is unknown. - var numbers []int - for _, it := range historical { - c, out := inv.snap.Change(domain.ChangeID(it.id)) - if out != domain.LookupFound || c.Status() != domain.StatusDone { - continue - } - if n, ok := parsePRNumber(c.PR().Value); ok { - numbers = append(numbers, n) - } - } - shared.prBodiesGathered = true - shared.prBodies = map[int]string{} - shared.prBodiesUnknown = map[int]bool{} - if len(numbers) > 0 { - var bodies map[int]string - if deps.PRBatch != nil { - bodies = deps.PRBatch.ProbePRSet(ctx, repoDir, numbers).Bodies + // A private repository's PR descriptions carry no backlink: no body is read, + // so the PR-backlink leg is not assessed. + if prBacklinksApply(lay) { + // One batched PR-body read over every done candidate's PR (≤25 per process) + // for the PR-backlink leg. A number the read could not resolve is unknown. + var numbers []int + for _, it := range historical { + c, out := inv.snap.Change(domain.ChangeID(it.id)) + if out != domain.LookupFound || c.Status() != domain.StatusDone { + continue + } + if n, ok := parsePRNumber(c.PR().Value); ok { + numbers = append(numbers, n) + } } - for _, n := range numbers { - if b, ok := bodies[n]; ok { - shared.prBodies[n] = b - } else { - shared.prBodiesUnknown[n] = true + shared.prBodiesGathered = true + shared.prBodies = map[int]string{} + shared.prBodiesUnknown = map[int]bool{} + if len(numbers) > 0 { + var bodies map[int]string + if deps.PRBatch != nil { + bodies = deps.PRBatch.ProbePRSet(ctx, repoDir, numbers).Bodies + } + for _, n := range numbers { + if b, ok := bodies[n]; ok { + shared.prBodies[n] = b + } else { + shared.prBodiesUnknown[n] = true + } } } } diff --git a/internal/app/maintenance_assess_test.go b/internal/app/maintenance_assess_test.go index 3740598cd..e0a945e34 100644 --- a/internal/app/maintenance_assess_test.go +++ b/internal/app/maintenance_assess_test.go @@ -11,6 +11,7 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository" "github.com/danielhanold/docket/internal/workspace" @@ -457,7 +458,7 @@ func TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown(t *testing.T) { }} deps := FinalizeDeps{Planning: PlanningDeps{Reader: f.reader, Clock: testClock()}, PRBatch: batch} items := []sweepWorkItem{{id: 41, kind: sweepKindCleanup}, {id: 42, kind: sweepKindCleanup}} - shared := gatherSweepSharedFacts(context.Background(), deps, "repo", f.inv, items) + shared := gatherSweepSharedFacts(context.Background(), deps, "repo", f.pin.Layout, f.inv, items) if !shared.prBodiesGathered || batch.calls != 1 { t.Fatalf("gathered=%v calls=%d, want one batched read", shared.prBodiesGathered, batch.calls) } @@ -469,6 +470,74 @@ func TestGatherSweepSharedFactsMarksUnreadPRBodiesUnknown(t *testing.T) { } } +// TestGatherSweepSharedFactsSkipsPRBodiesInPrivateRepository: a private +// repository's PR descriptions carry no backlink, so the sweep reads no PR body +// and leaves the PR-backlink leg unassessed. +func TestGatherSweepSharedFactsSkipsPRBodiesInPrivateRepository(t *testing.T) { + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "a", ""), assessDoneBlob(42, "b", "")}, nil) + n41 := prNumberOf(t, f, 41) + batch := &fakeSweepBatchReader{result: SweepPRSetResult{Bodies: map[int]string{n41: "body"}}} + deps := FinalizeDeps{Planning: PlanningDeps{Reader: f.reader, Clock: testClock()}, PRBatch: batch} + items := []sweepWorkItem{{id: 41, kind: sweepKindCleanup}, {id: 42, kind: sweepKindCleanup}} + lay := layout.PrivateLayout("/c", "/r", "/d", "o-r") + shared := gatherSweepSharedFacts(context.Background(), deps, "repo", lay, f.inv, items) + if shared.prBodiesGathered || batch.calls != 0 { + t.Fatalf("private: gathered=%v calls=%d, want no PR-body read", shared.prBodiesGathered, batch.calls) + } +} + +// privateBacklinkFixture builds a legacy archived done record whose merged PR +// still carries an active-path backlink: shared, both repoint legs would read +// and edit it. +func privateBacklinkFixture(t *testing.T) (assessFixture, StatusBlob, domain.Change, int, string) { + t.Helper() + blob := assessArchivedDoneBlob(41, "legacy") + f := newAssessFixture(t, []StatusBlob{blob}, nil) + c, _ := f.inv.snap.Change(domain.ChangeID(41)) + return f, blob, c, prNumberOf(t, f, 41), "docs/changes/active/0041-legacy.md" +} + +// TestFinalizeCleanupPRBacklinkRepairSkipsPrivateRepository: cleanup's +// PR-backlink leg neither reads nor edits a private repository's PR. +func TestFinalizeCleanupPRBacklinkRepairSkipsPrivateRepository(t *testing.T) { + f, blob, c, n, active := privateBacklinkFixture(t) + pin := f.pin + pin.Layout = layout.PrivateLayout("/c", "/r", "/d", "o-r") + pr := newFakePRBody(map[int]string{n: prBodyWithActiveBacklink(active, "prose")}) + cc := &closeoutContext{pin: pin, eff: f.pin.Config.Effective, change: c, body: blob.Data, link: linkContextOf(f.pin)} + if fnd := finalizeCleanupPRBacklinkRepair(context.Background(), FinalizeDeps{PRBody: pr}, cc, githubcli.Repository{}, n); fnd != nil { + t.Fatalf("private cleanup leg returned a finding: %+v", *fnd) + } + if pr.views != 0 || pr.edits != 0 { + t.Fatalf("private cleanup leg touched the PR: views=%d edits=%d", pr.views, pr.edits) + } +} + +// TestCloseoutPRBacklinkLegSkipsPrivateRepository: close-out's PR-backlink leg +// neither reads nor edits a private repository's PR. The shared control proves +// the same context does reach the PR. +func TestCloseoutPRBacklinkLegSkipsPrivateRepository(t *testing.T) { + f, blob, c, n, active := privateBacklinkFixture(t) + targets := []closeoutTarget{{id: 41, activePath: active, slug: "legacy", archivePath: c.Path()}} + run := func(lay layout.Layout) (*fakePRBody, []StatusFinding) { + pin := f.pin + pin.Layout = lay + pr := newFakePRBody(map[int]string{n: prBodyWithActiveBacklink(active, "prose")}) + cc := &closeoutContext{ + pin: pin, eff: f.pin.Config.Effective, snap: f.inv.snap, change: c, + sources: map[string][]byte{active: blob.Data}, link: linkContextOf(f.pin), + } + return pr, runCloseoutPRBacklinkLeg(context.Background(), FinalizeDeps{PRBody: pr}, cc, githubcli.Repository{}, targets) + } + if pr, _ := run(f.pin.Layout); pr.views == 0 { + t.Fatalf("shared control: the close-out leg never read the PR; the fixture does not reach it") + } + pr, out := run(layout.PrivateLayout("/c", "/r", "/d", "o-r")) + if out != nil || pr.views != 0 || pr.edits != 0 { + t.Fatalf("private close-out leg: findings=%+v views=%d edits=%d, want none", out, pr.views, pr.edits) + } +} + // TestLegacyArchivedPRBacklinkIsFullSweepWorkOnly pins the full sweep's bulk // reach: a legacy done record — archived long ago, with a PR whose description // backlink still names the old active/ path and no close-out ever attempted the diff --git a/internal/app/pr_backlink_repoint.go b/internal/app/pr_backlink_repoint.go index ea3c74f6d..46be61bd8 100644 --- a/internal/app/pr_backlink_repoint.go +++ b/internal/app/pr_backlink_repoint.go @@ -8,6 +8,7 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/layout" ) // This file is the one place that decides whether a merged pull request's @@ -29,6 +30,14 @@ import ( // change stays truthfully done and cleanup / the sweep retry it. const ReasonPRBacklinkPending = "pr-backlink-pending" +// prBacklinksApply reports whether a repository's merged pull requests carry a +// docket:backlink block to repoint. A private repository's PR descriptions carry +// no docket blocks, so nothing is repointed and no PR body is read for it. It is +// the one predicate every repoint site consults. +func prBacklinksApply(lay layout.Layout) bool { + return lay.Mode != layout.Private +} + // The closed outcomes of one repoint attempt. const ( prBacklinkRepointed = "repointed" // the block now names the archive path diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index 89e67633f..ed67f07e9 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -9,6 +9,7 @@ import ( "strings" "testing" + "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/reposetup" "github.com/danielhanold/docket/internal/testsupport" @@ -806,3 +807,37 @@ func TestIntegrationRepoSetupPrivateMovedCloneOrphanPruned(t *testing.T) { t.Fatalf("prepare after prune = %q (%s), want the new checkout %s attached", prep.Disposition, prep.HumanText(), newCheckout) } } + +// fatalRepairGitHub is a RepairGitHub whose every method fails the test: a +// private repository's PR-backlink repair must never reach GitHub. +type fatalRepairGitHub struct{ t *testing.T } + +func (g fatalRepairGitHub) DiscoverRepository(context.Context, string) (githubcli.Repository, error) { + g.t.Fatalf("a private PR-backlink repair resolved the GitHub repository") + return githubcli.Repository{}, nil +} + +func (g fatalRepairGitHub) ViewPullRequestsBatch(context.Context, githubcli.Repository, []int) (map[int]githubcli.BatchPRResult, error) { + g.t.Fatalf("a private PR-backlink repair read pull-request bodies") + return nil, nil +} + +func (g fatalRepairGitHub) EditPullRequestBody(context.Context, githubcli.Repository, int, string, string) (githubcli.BodyEditOutcome, githubcli.PullRequest, error) { + g.t.Fatalf("a private PR-backlink repair edited a pull-request body") + return githubcli.BodyUnknown, githubcli.PullRequest{}, nil +} + +// TestIntegrationRepoSetupPrivatePRBacklinkRepairIsNoOp: a private repository's +// pull requests carry no backlink, so `repository repair --pr-backlinks` is a +// no-op that says so and never touches GitHub. +func TestIntegrationRepoSetupPrivatePRBacklinkRepairIsNoOp(t *testing.T) { + r, _ := initPrivateHealthy(t) + d := SetupDeps{Git: newGitClient(t), RepoDir: r.invocation, GitHub: fatalRepairGitHub{t: t}} + res := RunRepositoryRepair(context.Background(), d, RepairOptions{PRBacklinks: true}) + if res.Result != ResultNoOp { + t.Fatalf("private PR-backlink repair = %q (%s), want no-op", res.Result, res.HumanText()) + } + if !strings.Contains(res.HumanText(), "a private repository's pull requests carry no backlink") { + t.Errorf("private PR-backlink repair text = %q; want it to say a private repository's pull requests carry no backlink", res.HumanText()) + } +} diff --git a/internal/app/repository_repair_prbacklinks.go b/internal/app/repository_repair_prbacklinks.go index 78ea71a5f..57f933a79 100644 --- a/internal/app/repository_repair_prbacklinks.go +++ b/internal/app/repository_repair_prbacklinks.go @@ -82,6 +82,9 @@ func runPRBacklinkRepair(ctx context.Context, d SetupDeps, o RepairOptions) Repo if refusal != nil { return *refusal } + if !prBacklinksApply(sc.layout) { + return prRepairPrivateNoOp(metadataTip) + } if d.GitHub == nil { return repairInternalFailure(reposetup.StateHealthy, "wiring the GitHub client", errors.New("no GitHub client is wired for --pr-backlinks")) @@ -283,6 +286,17 @@ func prRepairNoOp(metadataTip string, skipped []PRBacklinkRepair) RepositoryRepa return out } +// prRepairPrivateNoOp is the no-op a private repository answers with: its pull +// requests carry no docket:backlink block, so nothing is read or repointed. +func prRepairPrivateNoOp(metadataTip string) RepositoryRepairResult { + out := newRepairResult(ResultNoOp, RepositoryRepairResult{ + RepositoryState: string(reposetup.StateHealthy), + SourceRevision: metadataTip, + }) + out.human = "repository repair --pr-backlinks: a private repository's pull requests carry no backlink; nothing to repoint" + return out +} + func prRepairApplied(metadataTip string, rows []PRBacklinkRepair, repointed, failed int) RepositoryRepairResult { result := ResultApplied if repointed == 0 && failed > 0 { diff --git a/internal/app/workflow_private_integration_test.go b/internal/app/workflow_private_integration_test.go index 30af667df..c5bac5d67 100644 --- a/internal/app/workflow_private_integration_test.go +++ b/internal/app/workflow_private_integration_test.go @@ -12,7 +12,9 @@ import ( "testing" "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/workspace" ) // This file is the private-visibility acceptance slice of the workflow @@ -102,8 +104,9 @@ func resolvedPrivateLayout(t *testing.T, dir string) layout.Layout { // privateLifecycleChecks drives the whole claim-to-implemented run, then proves // what a private repository publishes on origin: the one open PR carries the // authored title and prose byte-for-byte (no backlink, no artifacts block), and -// the spec copy at the feature head carries no change line. -func privateLifecycleChecks(t *testing.T, created ChangeCreateResult) workflowEntry { +// the spec copy at the feature head carries no change line. A finalize block +// then records its marker on the store's record and posts no PR comment. +func privateLifecycleChecks(t *testing.T, created ChangeCreateResult, store string) workflowEntry { return func(node realNode, wdeps WorkspaceDeps, complete func(string) GitHubDeps) { t.Helper() ctx := context.Background() @@ -151,6 +154,36 @@ func privateLifecycleChecks(t *testing.T, created ChangeCreateResult) workflowEn t.Errorf("private spec copy %s carries a change line %q:\n%s", specs[0], line, spec) } } + + // A private finalize block posts no PR comment: the marker on the record + // is its only effect (acceptance 6). + svc, err := workspace.NewService(node.deps.Client) + if err != nil { + t.Fatalf("workspace service: %v", err) + } + gh := &fakeBlockGitHub{repo: prRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.invalid/c/1"} + blocked := FinalizeBlock(ctx, FinalizeDeps{Planning: node.deps, GitHub: gh, Workspace: svc}, node.dir, BlockRequest{ + ID: created.ID, + Revision: blobRevisionAt(t, store, privateFixtureBranch, created.Path), + PRNumber: prs[0].Number, + Attempt: "a1", + Reason: "gate-failed", + Head: insp.Head, + Report: "The gate failed.\n", + }) + if blocked.Result != ResultApplied || blocked.Disposition != BlockDispRecorded { + t.Fatalf("private finalize block = %q/%q (%s), want applied/recorded", blocked.Result, blocked.Disposition, blocked.HumanText()) + } + if gh.ensureCalls != 0 || blocked.CommentURL != "" { + t.Errorf("private finalize block posted a PR comment: ensureCalls=%d url=%q", gh.ensureCalls, blocked.CommentURL) + } + record := runGit(t, store, "show", privateFixtureBranch+":"+created.Path) + if !strings.Contains(record, "## Finalize blocked") { + t.Errorf("the record at the store tip lacks the finalize-blocked marker:\n%s", record) + } + if strings.Contains(record, "- Comment:") { + t.Errorf("the private finalize-blocked marker names a comment:\n%s", record) + } } } @@ -218,7 +251,7 @@ func TestIntegrationWorkflowLifecyclePrivateInitToImplemented(t *testing.T) { // Drive the whole run against the private store. driveClaimToImplemented(t, repo, privateFixtureBranch, ghBin, created.ID, created.Slug, created.Path, groom.SpecPath, - privateLifecycleChecks(t, created)) + privateLifecycleChecks(t, created, repo.meta)) // Origin gained exactly one branch, and it is the feature branch. originAfter := localHeads(t, r.origin) diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index 40c0f55e5..cc2eb5da5 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -146,7 +146,7 @@ func newFinalizeCloseoutSubcommand(setResult func(app.OperationResult)) *cobra.C func newFinalizeBlockSubcommand(setResult func(app.OperationResult)) *cobra.Command { cmd := &cobra.Command{ Use: "block", - Short: "Record a blocked finalize attempt: an owned PR comment then a durable marker", + Short: "Record a blocked finalize attempt: an owned PR comment (none in a private repository), then a durable marker", Args: cobra.NoArgs, // external-write (the owned PR comment) + metadata-write (the durable // "## Finalize blocked" marker upserted in an exact-revision metadata- From 0a68a802056818d5cd6b3dd3ba8b00aa78925275 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:07:35 -0400 Subject: [PATCH 07/13] feat(app): block a private repository's feature push on a docket fingerprint --- internal/app/leak_check.go | 131 ++++++ .../leak_check_private_integration_test.go | 387 ++++++++++++++++++ internal/app/leak_check_test.go | 185 +++++++++ internal/app/workspace_ops.go | 32 +- tests/runtime-budgets.tsv | 2 +- 5 files changed, 735 insertions(+), 2 deletions(-) create mode 100644 internal/app/leak_check.go create mode 100644 internal/app/leak_check_private_integration_test.go create mode 100644 internal/app/leak_check_test.go diff --git a/internal/app/leak_check.go b/internal/app/leak_check.go new file mode 100644 index 000000000..9f2cb088a --- /dev/null +++ b/internal/app/leak_check.go @@ -0,0 +1,131 @@ +package app + +import ( + "context" + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/leakscan" + "github.com/danielhanold/docket/internal/workspace" +) + +// This file is the private-repository leak check. Before a push or PR write in a +// private repository, everything that write would expose — the outgoing commit +// messages, the added paths and lines of the merge-base diff, and any PR title +// and body — is scanned for docket fingerprints (internal/leakscan), and any hit +// refuses the write with nothing published. A shared repository never runs it. +// A failure to fetch the base or read the outgoing set is leak-check-unverified, +// never a clean scan. Each caller scans the exact head it publishes. + +// The stable machine reasons a leak-checked publish reports. +const ( + // ReasonLeakDetected is a blocked publish: the scan found at least one + // fingerprint, and nothing was published. + ReasonLeakDetected = "leak-detected" + // ReasonLeakCheckUnverified is an external-failed publish: the check could + // not run (the base fetch or the outgoing read failed), so nothing was + // published. + ReasonLeakCheckUnverified = "leak-check-unverified" +) + +// LeakHit is one fingerprint a publish would expose. Commit is set for a +// commit-message hit and File for an added line or path; Line is 1-based (0 for +// a path). Text is the matched text only, never the surrounding line, so a hit +// never carries the rest of a PR body or a file. +type LeakHit struct { + Source string `json:"source"` + Commit string `json:"commit,omitempty"` + File string `json:"file,omitempty"` + Line int `json:"line,omitempty"` + Text string `json:"text"` + Rule string `json:"rule"` +} + +// leakGit is the Git seam runLeakCheck reads through. +type leakGit interface { + FetchBranch(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName, branch gitcli.RefName) (gitcli.Revision, error) + ReadOutgoing(ctx context.Context, repo gitcli.Repository, base, head gitcli.ObjectID) (gitcli.Outgoing, error) +} + +var _ leakGit = (*gitcli.Client)(nil) + +// leakCheckShown bounds how many hits leakMessage names; the rest ride in the +// result's leaks list. +const leakCheckShown = 5 + +// leakCheckApplies reports whether the leak check runs: in a private +// repository only. +func leakCheckApplies(lay layout.Layout) bool { return lay.Mode == layout.Private } + +// leakOptions builds the scan options from the pinned configuration and the +// snapshot's change ids. +func leakOptions(pin StatusPin, snap domain.Snapshot) leakscan.Options { + ids := map[int]bool{} + for _, c := range snap.Changes() { + ids[int(c.ID())] = true + } + return leakscan.Options{MatchWord: pin.Config.Effective.LeakCheck.MatchWord.Value, ChangeIDs: ids} +} + +// runLeakCheck fetches target's base from origin and scans what publishing head +// over it would expose, plus pr when non-nil. Any fetch or read failure is an +// error — the caller refuses as unverified — and never an empty hit list. +func runLeakCheck(ctx context.Context, git leakGit, wc workspaceContext, target workspace.Target, head gitcli.ObjectID, pr *leakscan.PRText) ([]LeakHit, error) { + base, err := git.FetchBranch(ctx, wc.repo, originRemote, target.BaseRef) + if err != nil { + return nil, fmt.Errorf("fetching the base %s from origin: %w", target.BaseRef, err) + } + out, err := git.ReadOutgoing(ctx, wc.repo, base.Commit, head) + if err != nil { + return nil, fmt.Errorf("reading what %s over %s would publish: %w", shortCommit(string(head)), shortCommit(string(base.Commit)), err) + } + in := leakscan.Input{AddedPaths: out.AddedPaths, PR: pr} + for _, c := range out.Commits { + in.Commits = append(in.Commits, leakscan.Commit{ID: string(c.Commit), Message: c.Message}) + } + for _, l := range out.AddedLines { + in.AddedLines = append(in.AddedLines, leakscan.AddedLine{Path: l.Path, Line: l.Line, Text: l.Text}) + } + var hits []LeakHit + for _, h := range leakscan.Scan(in, leakOptions(wc.pin, wc.snap)) { + hits = append(hits, LeakHit{Source: string(h.Source), Commit: h.Commit, File: h.File, Line: h.Line, Text: h.Text, Rule: string(h.Rule)}) + } + return hits, nil +} + +// leakMessage renders a bounded refusal message naming the first hits by where +// they are, their matched text, and their rule. +func leakMessage(hits []LeakHit) string { + var b strings.Builder + fmt.Fprintf(&b, "%d docket fingerprint(s) would reach a shared surface; nothing was published: ", len(hits)) + for i, h := range hits { + if i == leakCheckShown { + break + } + if i > 0 { + b.WriteString("; ") + } + fmt.Fprintf(&b, "%s %q (%s)", leakWhere(h), h.Text, h.Rule) + } + if more := len(hits) - leakCheckShown; more > 0 { + fmt.Fprintf(&b, " … and %d more (see leaks)", more) + } + return b.String() +} + +// leakWhere names where a hit sits. +func leakWhere(h LeakHit) string { + switch leakscan.Source(h.Source) { + case leakscan.SourceCommitMessage: + return fmt.Sprintf("commit %s message line %d", shortCommit(h.Commit), h.Line) + case leakscan.SourceAddedLine: + return fmt.Sprintf("%s:%d", h.File, h.Line) + case leakscan.SourceAddedPath: + return "path " + h.File + default: + return fmt.Sprintf("%s line %d", h.Source, h.Line) + } +} diff --git a/internal/app/leak_check_private_integration_test.go b/internal/app/leak_check_private_integration_test.go new file mode 100644 index 000000000..cd8f2645d --- /dev/null +++ b/internal/app/leak_check_private_integration_test.go @@ -0,0 +1,387 @@ +//go:build integration + +package app + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/workspace" +) + +// This file is the leak-check slice of the workflow lifecycle shard (prefix +// TestIntegrationWorkflowLifecycle). Over real repositories it proves that a +// private repository's workspace.publish scans exactly what the push would +// expose and refuses — with nothing reaching origin — on every seeded docket +// fingerprint; that leak_check.match_word: false lets only the bare word +// through; that a scan which cannot run refuses as unverified rather than +// reading clean; and that a shared repository never runs the scan at all. +// +// None of these tests is parallel: planningDepsFor uses t.Setenv. + +// leakWorkspace is a claimed, reconciled change with a prepared feature +// workspace whose first commit is the spec copy, ready to seed and publish. +type leakWorkspace struct { + node realNode + wdeps WorkspaceDeps + id int + wp, specCommit string // feature worktree; spec-copy commit + featureRef, specPath string // the feature ref publish pushes; the spec copy's repo path + origin, writer, invocation string // bare origin; a clone with origin set; the invocation clone +} + +// prepareLeakWorkspace runs the opening steps of driveClaimToImplemented in +// order — context, claim, reconcile, workspace prepare, and the spec-copy commit +// — and fails unless each applies. +func prepareLeakWorkspace(t *testing.T, repo *gitRepo, branch string, id int, recPath string) *leakWorkspace { + t.Helper() + ctx := context.Background() + node := planningDepsFor(t, repo.invocation) + svc, err := workspace.NewService(node.deps.Client) + if err != nil { + t.Fatalf("workspace.NewService: %v", err) + } + wdeps := WorkspaceDeps{Service: svc} + ver := func() string { return blobRevisionAt(t, repo.meta, branch, recPath) } + + ctxRes := ContextImplementation(ctx, node.deps, node.dir, ImplementationContextRequest{ID: id}) + if ctxRes.Result != ResultApplied || ctxRes.Context == nil { + t.Fatalf("context implementation = %q (reason %q); want a bundle", ctxRes.Result, ctxRes.Reason) + } + claim := ChangeClaim(ctx, node.deps, node.dir, ChangeClaimRequest{ID: id, Revision: ctxRes.Context.Change.Revision, RunContext: ""}) + if claim.Result != ResultApplied || claim.Disposition != ClaimDispositionApplied { + t.Fatalf("claim = (%q, %q), want applied/applied (findings %v)", claim.Result, claim.Disposition, claim.Findings) + } + rec := ChangeReconcile(ctx, node.deps, node.dir, ChangeReconcileRequest{ + ID: id, Revision: ver(), ReconcileLogEntry: "Reconciled against current reality.\n", + }) + if rec.Result != ResultApplied { + t.Fatalf("reconcile = %q (findings %v)", rec.Result, rec.Findings) + } + prep := WorkspacePrepare(ctx, node.deps, wdeps, node.dir, WorkspaceIDRequest{ID: id, Revision: ver()}) + if prep.Result != ResultApplied { + t.Fatalf("workspace prepare = %q (reason %q msg %q)", prep.Result, prep.Reason, prep.Message) + } + cs := WorkspaceCommitSpec(ctx, node.deps, wdeps, node.dir, WorkspaceIDRequest{ID: id}) + if cs.Result != ResultApplied || cs.Disposition != SpecCopyCommitted { + t.Fatalf("workspace commit-spec = (%q, %q) (reason %q msg %q), want applied/committed", cs.Result, cs.Disposition, cs.Reason, cs.Message) + } + var specs []string + for _, p := range strings.Split(runGit(t, prep.Path, "show", "--name-only", "--format=", cs.Head), "\n") { + if strings.HasSuffix(p, "-design.md") { + specs = append(specs, p) + } + } + if len(specs) != 1 { + t.Fatalf("the spec-copy commit changed %v, want exactly one spec copy", specs) + } + return &leakWorkspace{ + node: node, wdeps: wdeps, id: id, + wp: prep.Path, specCommit: cs.Head, + featureRef: prep.FeatureRef, specPath: specs[0], + origin: repo.origin, writer: repo.writer, invocation: repo.invocation, + } +} + +// newPrivateLeakWorkspace repeats the private lifecycle test's setup through +// groom — init --private, a configured gate, change 1 created and groomed with +// a spec — and prepares its workspace against the dckt store. +func newPrivateLeakWorkspace(t *testing.T) (*leakWorkspace, layout.Layout) { + t.Helper() + ctx := context.Background() + r, _ := newPrivateInitRepo(t, nil) + 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()) + } + lay := resolvedPrivateLayout(t, r.invocation) + repo := &gitRepo{root: r.root, origin: r.origin, meta: lay.DefaultBareRemote, writer: r.writer, invocation: r.invocation} + + node := planningDepsFor(t, r.invocation) + 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) + } + if created.ID != 1 { + t.Fatalf("created change id = %d, want 1 (the seeded change references name 0001)", created.ID) + } + 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) + } + return prepareLeakWorkspace(t, repo, privateFixtureBranch, created.ID, created.Path), lay +} + +// newSharedLeakWorkspace builds the shared (docket-branch) fixture +// runClaimToImplemented uses — change 3 with a linked spec — and prepares it. +func newSharedLeakWorkspace(t *testing.T) *leakWorkspace { + t.Helper() + const ( + id = 3 + slug = "widget" + ) + recPath := groomPath(id, slug) + specPath := "docs/superpowers/specs/2026-08-17-" + slug + "-design.md" + m := planRepoModes()[0] + repo := buildConfiguredRepoWith(t, m, map[string]string{ + recPath: specLinkedBuildReadyChange(id, slug, specPath), + specPath: workflowMetadataSpec(id, slug), + }) + return prepareLeakWorkspace(t, repo, m.branch, id, recPath) +} + +// commitInWorkspace writes files into the worktree, commits everything with +// msg, and returns the new head. +func commitInWorkspace(t *testing.T, wp, msg string, files map[string]string) string { + t.Helper() + for rel, content := range files { + writeRepoFile(t, wp, rel, content) + } + runGit(t, wp, "add", "-A") + runGit(t, wp, "commit", "-q", "--allow-empty", "-m", msg) + return runGit(t, wp, "rev-parse", "HEAD") +} + +// resetWorkspace moves the worktree's branch and tree to commit. +func resetWorkspace(t *testing.T, wp, commit string) { + t.Helper() + runGit(t, wp, "reset", "-q", "--hard", commit) +} + +// originHasBranch reports whether the bare origin carries ref. A probe that +// fails for any reason other than an absent ref fails the test, so an +// unreadable origin never reads as "absent". +func originHasBranch(t *testing.T, origin, ref string) bool { + t.Helper() + out := runGit(t, origin, "for-each-ref", "--format=%(refname)", ref) + return out == ref +} + +// originRefTip returns ref's commit on the bare origin, failing when absent. +func originRefTip(t *testing.T, origin, ref string) string { + t.Helper() + if !originHasBranch(t, origin, ref) { + t.Fatalf("origin lacks %s", ref) + } + return runGit(t, origin, "rev-parse", ref) +} + +// publish runs workspace.publish for head on lw. +func (lw *leakWorkspace) publish(t *testing.T, head string) WorkspaceOpResult { + t.Helper() + return WorkspacePublish(context.Background(), lw.node.deps, lw.wdeps, lw.node.dir, WorkspacePublishRequest{ID: lw.id, Head: head}) +} + +// requireLeakRefusal requires a blocked/leak-detected publish whose leaks hold +// exactly one hit matching want (Commit is compared only when want sets it, +// Line only when non-zero), whose message names the hit's text and rule, and +// whose rule-shown hit carries only the matched text. +func requireLeakRefusal(t *testing.T, pub WorkspaceOpResult, want LeakHit) { + t.Helper() + if pub.Result != ResultBlocked || pub.Reason != ReasonLeakDetected { + t.Fatalf("publish = %q/%q (msg %q), want blocked/%s", pub.Result, pub.Reason, pub.Message, ReasonLeakDetected) + } + if len(pub.Leaks) != 1 { + t.Fatalf("leaks = %+v, want exactly one hit %+v", pub.Leaks, want) + } + got := pub.Leaks[0] + if got.Source != want.Source || got.Rule != want.Rule || got.Text != want.Text || got.File != want.File || + (want.Commit != "" && got.Commit != want.Commit) || (want.Line != 0 && got.Line != want.Line) { + t.Errorf("leak hit = %+v, want %+v", got, want) + } + if !strings.Contains(pub.Message, "("+want.Rule+")") || !strings.Contains(pub.Message, want.Text) { + t.Errorf("refusal message does not show the hit %q (%s): %q", want.Text, want.Rule, pub.Message) + } +} + +// TestIntegrationWorkflowLifecyclePrivateLeakCheckBlocksSeededLeaks seeds each +// fingerprint kind onto the feature branch and proves publish refuses with the +// hit shown and pushes nothing; a fingerprint-free year and ADR reference +// publish, and upstream main merged into the branch is not scanned as outgoing. +func TestIntegrationWorkflowLifecyclePrivateLeakCheckBlocksSeededLeaks(t *testing.T) { + lw, _ := newPrivateLeakWorkspace(t) + + specBytes, err := os.ReadFile(filepath.Join(lw.wp, lw.specPath)) + if err != nil { + t.Fatalf("reading the spec copy: %v", err) + } + spec := string(specBytes) + if !strings.HasSuffix(spec, "\n") { + spec += "\n" + } + appendedLine := strings.Count(spec, "\n") + 1 + + for _, tc := range []struct { + name string + msg string + files map[string]string + want func(head string) LeakHit + }{ + {"subject-change-id", "Add the widget (0001)", map[string]string{"widget.go": "package widget\n"}, + func(head string) LeakHit { + return LeakHit{Source: "commit-message", Commit: head, Line: 1, Text: "(0001)", Rule: "change-ref"} + }}, + {"added-docket-path", "Add notes", map[string]string{"notes.txt": "see .docket/x\n"}, + func(string) LeakHit { + return LeakHit{Source: "added-line", File: "notes.txt", Line: 1, Text: ".docket", Rule: "path"} + }}, + {"spec-copy-word", "Note the tracking", map[string]string{lw.specPath: spec + "Tracked in docket.\n"}, + func(string) LeakHit { + return LeakHit{Source: "added-line", File: lw.specPath, Line: appendedLine, Text: "docket", Rule: "word"} + }}, + {"dckt-in-message", "wire the dckt alias", map[string]string{"alias.txt": "alias\n"}, + func(head string) LeakHit { + return LeakHit{Source: "commit-message", Commit: head, Line: 1, Text: "dckt", Rule: "alias"} + }}, + } { + t.Run(tc.name, func(t *testing.T) { + resetWorkspace(t, lw.wp, lw.specCommit) + head := commitInWorkspace(t, lw.wp, tc.msg, tc.files) + requireLeakRefusal(t, lw.publish(t, head), tc.want(head)) + if originHasBranch(t, lw.origin, lw.featureRef) { + t.Errorf("a refused publish left %s on origin", lw.featureRef) + } + }) + } + + var cleanHead string + t.Run("clean-year-and-adr-publishes", func(t *testing.T) { + resetWorkspace(t, lw.wp, lw.specCommit) + cleanHead = commitInWorkspace(t, lw.wp, "Lay out the widget per ADR-0001 (2026)", + map[string]string{"layout.md": "See ADR-0001 (2026).\n"}) + pub := lw.publish(t, cleanHead) + if pub.Result != ResultApplied { + t.Fatalf("publish = %q/%q (msg %q, leaks %+v), want applied", pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + if got := originRefTip(t, lw.origin, lw.featureRef); got != cleanHead { + t.Errorf("origin %s = %s, want the published head %s", lw.featureRef, got, cleanHead) + } + }) + + t.Run("merged-main-is-not-outgoing", func(t *testing.T) { + if cleanHead == "" { + t.Fatal("the clean publish did not run") + } + runGit(t, lw.writer, "fetch", "-q", "origin", "main") + runGit(t, lw.writer, "reset", "-q", "--hard", "origin/main") + writeRepoFile(t, lw.writer, "upstream.txt", "docket\n") + runGit(t, lw.writer, "add", "upstream.txt") + runGit(t, lw.writer, "commit", "-q", "-m", "Upstream docket note (0001)") + runGit(t, lw.writer, "push", "-q", "origin", "main") + + runGit(t, lw.wp, "fetch", "-q", "origin", "main") + runGit(t, lw.wp, "merge", "-q", "--no-edit", "origin/main") + head := runGit(t, lw.wp, "rev-parse", "HEAD") + pub := lw.publish(t, head) + if pub.Result != ResultApplied { + t.Fatalf("publish of a branch with upstream main merged in = %q/%q (msg %q, leaks %+v), want applied", + pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + if got := originRefTip(t, lw.origin, lw.featureRef); got != head { + t.Errorf("origin %s = %s, want the merged head %s", lw.featureRef, got, head) + } + }) +} + +// TestIntegrationWorkflowLifecyclePrivateLeakCheckMatchWordOff proves +// leak_check.match_word: false lets the bare word docket through while every +// other rule still blocks. +func TestIntegrationWorkflowLifecyclePrivateLeakCheckMatchWordOff(t *testing.T) { + lw, lay := newPrivateLeakWorkspace(t) + f, err := os.OpenFile(lay.ConfigPath, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatalf("opening the private config %s: %v", lay.ConfigPath, err) + } + if _, err := f.WriteString("leak_check:\n match_word: false\n"); err != nil { + t.Fatalf("appending to the private config: %v", err) + } + if err := f.Close(); err != nil { + t.Fatalf("closing the private config: %v", err) + } + + resetWorkspace(t, lw.wp, lw.specCommit) + published := commitInWorkspace(t, lw.wp, "File the docket number", map[string]string{"entry.txt": "the docket entry\n"}) + if pub := lw.publish(t, published); pub.Result != ResultApplied { + t.Fatalf("publish under match_word false = %q/%q (msg %q, leaks %+v), want applied", pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + if got := originRefTip(t, lw.origin, lw.featureRef); got != published { + t.Fatalf("origin %s = %s, want %s", lw.featureRef, got, published) + } + + for _, tc := range []struct { + name string + msg string + files map[string]string + want LeakHit + }{ + {"marker", "Add a marker file", map[string]string{"marker.md": "<!-- docket:backlink:start -->\n"}, + LeakHit{Source: "added-line", File: "marker.md", Line: 1, Text: "docket:", Rule: "marker"}}, + {"trailer", "Tidy the widget\n\nDocket-Operation: x", map[string]string{"tidy.txt": "tidy\n"}, + LeakHit{Source: "commit-message", Text: "Docket-Operation:", Rule: "trailer"}}, + {"path", "Add a path note", map[string]string{"path.txt": ".docket/x\n"}, + LeakHit{Source: "added-line", File: "path.txt", Line: 1, Text: ".docket", Rule: "path"}}, + {"alias", "dckt", map[string]string{"alias.txt": "alias\n"}, + LeakHit{Source: "commit-message", Line: 1, Text: "dckt", Rule: "alias"}}, + {"change-ref", "(0001)", map[string]string{"ref.txt": "ref\n"}, + LeakHit{Source: "commit-message", Line: 1, Text: "(0001)", Rule: "change-ref"}}, + } { + t.Run(tc.name, func(t *testing.T) { + resetWorkspace(t, lw.wp, published) + head := commitInWorkspace(t, lw.wp, tc.msg, tc.files) + requireLeakRefusal(t, lw.publish(t, head), tc.want) + if got := originRefTip(t, lw.origin, lw.featureRef); got != published { + t.Errorf("a refused publish moved origin %s to %s, want %s", lw.featureRef, got, published) + } + }) + } +} + +// TestIntegrationWorkflowLifecyclePrivateLeakCheckUnverifiedPushesNothing +// proves a scan that cannot run — a feature branch sharing no history with +// origin's base, so the merge-base diff fails — refuses as unverified and +// pushes nothing, never reading as a clean scan. +func TestIntegrationWorkflowLifecyclePrivateLeakCheckUnverifiedPushesNothing(t *testing.T) { + lw, _ := newPrivateLeakWorkspace(t) + orphan := runGit(t, lw.wp, "commit-tree", lw.specCommit+"^{tree}", "-m", "Unrelated root") + resetWorkspace(t, lw.wp, orphan) + + pub := lw.publish(t, orphan) + if pub.Result != ResultExternalFailed || pub.Reason != ReasonLeakCheckUnverified { + t.Fatalf("publish of an unscannable head = %q/%q (msg %q), want %s/%s", + pub.Result, pub.Reason, pub.Message, ResultExternalFailed, ReasonLeakCheckUnverified) + } + if originHasBranch(t, lw.origin, lw.featureRef) { + t.Errorf("an unverified scan still pushed %s to origin", lw.featureRef) + } +} + +// TestIntegrationWorkflowLifecycleSharedPublishRunsNoLeakCheck pins that a +// shared repository never runs the scan: a change reference and a .docket path +// publish untouched. +func TestIntegrationWorkflowLifecycleSharedPublishRunsNoLeakCheck(t *testing.T) { + lw := newSharedLeakWorkspace(t) + head := commitInWorkspace(t, lw.wp, "Add the widget (0003)", map[string]string{"x.txt": ".docket/x\n"}) + pub := lw.publish(t, head) + if pub.Result != ResultApplied { + t.Fatalf("shared publish = %q/%q (msg %q, leaks %+v), want applied with no scan", pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + if len(pub.Leaks) != 0 { + t.Errorf("a shared publish reported leaks %+v", pub.Leaks) + } + if got := originRefTip(t, lw.origin, lw.featureRef); got != head { + t.Errorf("origin %s = %s, want %s", lw.featureRef, got, head) + } +} diff --git a/internal/app/leak_check_test.go b/internal/app/leak_check_test.go new file mode 100644 index 000000000..37402a100 --- /dev/null +++ b/internal/app/leak_check_test.go @@ -0,0 +1,185 @@ +package app + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/config" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/leakscan" + "github.com/danielhanold/docket/internal/repository" + "github.com/danielhanold/docket/internal/workspace" +) + +// fakeLeakGit records the base fetch and the outgoing read runLeakCheck makes, +// and returns scripted values or errors. +type fakeLeakGit struct { + fetchRemote gitcli.RemoteName + fetchBranch gitcli.RefName + fetchCalls int + fetchRev gitcli.Revision + fetchErr error + + readCalls int + readBase gitcli.ObjectID + readHead gitcli.ObjectID + out gitcli.Outgoing + readErr error +} + +func (f *fakeLeakGit) FetchBranch(_ context.Context, _ gitcli.Repository, remote gitcli.RemoteName, branch gitcli.RefName) (gitcli.Revision, error) { + f.fetchCalls++ + f.fetchRemote, f.fetchBranch = remote, branch + return f.fetchRev, f.fetchErr +} + +func (f *fakeLeakGit) ReadOutgoing(_ context.Context, _ gitcli.Repository, base, head gitcli.ObjectID) (gitcli.Outgoing, error) { + f.readCalls++ + f.readBase, f.readHead = base, head + return f.out, f.readErr +} + +const ( + leakBase = gitcli.ObjectID("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb") + leakHead = gitcli.ObjectID("cccccccccccccccccccccccccccccccccccccccc") +) + +// leakContext builds the workspace context and target runLeakCheck reads: the +// prReader pin with leak_check.match_word set explicitly, the snapshot over its +// corpus (change 7 exists), a fixed repository, and a main base ref. +func leakContext(t *testing.T, matchWord bool) (workspaceContext, workspace.Target) { + t.Helper() + reader := prReader(t) + pin := reader.pin + pin.Config.Effective.LeakCheck.MatchWord = config.Value[bool]{Value: matchWord, Explicit: true} + inputs, _ := parseCorpus(reader.corpus) + build, err := repository.BuildSnapshot(repository.BuildInput{Config: pin.Config.Effective, Documents: inputs}) + if err != nil { + t.Fatalf("BuildSnapshot: %v", err) + } + wc := workspaceContext{ + pin: pin, + snap: build.Snapshot, + repo: gitcli.Repository{PrimaryWorktree: "/r", CommonDir: "/r/.git"}, + } + return wc, workspace.Target{BaseRef: "refs/heads/main"} +} + +func TestRunLeakCheckScansFetchedBaseToHead(t *testing.T) { + wc, target := leakContext(t, true) + git := &fakeLeakGit{ + fetchRev: gitcli.Revision{Commit: leakBase}, + out: gitcli.Outgoing{ + Commits: []gitcli.OutgoingCommit{{Commit: "c1", Message: "Fix it (0007)\n"}}, + AddedPaths: []string{"a.go"}, + AddedLines: []gitcli.OutgoingLine{{Path: "a.go", Line: 3, Text: "x := \".docket/y\""}}, + }, + } + hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, &leakscan.PRText{Title: "change 0007", Body: "ok\n"}) + if err != nil { + t.Fatalf("runLeakCheck: %v", err) + } + if git.fetchCalls != 1 || git.fetchRemote != "origin" || git.fetchBranch != "refs/heads/main" { + t.Errorf("fetch = %d call(s) of (%q, %q), want one of (origin, refs/heads/main)", git.fetchCalls, git.fetchRemote, git.fetchBranch) + } + if git.readCalls != 1 || git.readBase != leakBase || git.readHead != leakHead { + t.Errorf("read = %d call(s) of (%q, %q), want one of the fetched base and the head", git.readCalls, git.readBase, git.readHead) + } + want := []LeakHit{ + {Source: "commit-message", Commit: "c1", Line: 1, Text: "(0007)", Rule: "change-ref"}, + {Source: "added-line", File: "a.go", Line: 3, Text: ".docket", Rule: "path"}, + {Source: "pr-title", Line: 1, Text: "change 0007", Rule: "change-ref"}, + } + if !reflect.DeepEqual(hits, want) { + t.Errorf("hits =\n %+v\nwant\n %+v", hits, want) + } +} + +func TestRunLeakCheckFetchFailureIsUnverified(t *testing.T) { + wc, target := leakContext(t, true) + git := &fakeLeakGit{fetchErr: errors.New("network down")} + hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, nil) + if err == nil { + t.Fatalf("a failed base fetch returned no error (hits %v); an unverifiable scan must never read as clean", hits) + } + if git.readCalls != 0 { + t.Errorf("a failed base fetch still read %d outgoing set(s)", git.readCalls) + } +} + +func TestRunLeakCheckReadFailureIsUnverified(t *testing.T) { + wc, target := leakContext(t, true) + git := &fakeLeakGit{fetchRev: gitcli.Revision{Commit: leakBase}, readErr: errors.New("no merge base")} + hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, nil) + if err == nil { + t.Fatalf("a failed outgoing read returned no error (hits %v); an unverifiable scan must never read as clean", hits) + } +} + +func TestRunLeakCheckMatchWordOff(t *testing.T) { + wc, target := leakContext(t, false) + git := &fakeLeakGit{ + fetchRev: gitcli.Revision{Commit: leakBase}, + out: gitcli.Outgoing{Commits: []gitcli.OutgoingCommit{ + {Commit: "c1", Message: "the docket number"}, + {Commit: "c2", Message: "dckt"}, + }}, + } + hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, nil) + if err != nil { + t.Fatalf("runLeakCheck: %v", err) + } + want := []LeakHit{{Source: "commit-message", Commit: "c2", Line: 1, Text: "dckt", Rule: "alias"}} + if !reflect.DeepEqual(hits, want) { + t.Errorf("hits under match_word false = %+v, want %+v", hits, want) + } +} + +func TestLeakCheckAppliesOnlyPrivate(t *testing.T) { + for _, tc := range []struct { + mode layout.Mode + want bool + }{{layout.Shared, false}, {"", false}, {layout.Private, true}} { + if got := leakCheckApplies(layout.Layout{Mode: tc.mode}); got != tc.want { + t.Errorf("leakCheckApplies(mode %q) = %v, want %v", tc.mode, got, tc.want) + } + } +} + +func TestLeakMessageBoundsAndNamesHits(t *testing.T) { + hits := []LeakHit{ + {Source: "commit-message", Commit: "0123456789abcdef0123", Line: 2, Text: "(0007)", Rule: "change-ref"}, + {Source: "added-line", File: "a.go", Line: 3, Text: ".docket", Rule: "path"}, + {Source: "added-path", File: "x/.docket/y", Text: ".docket", Rule: "path"}, + {Source: "pr-body", Line: 4, Text: "dckt", Rule: "alias"}, + {Source: "pr-title", Line: 1, Text: "docket", Rule: "word"}, + {Source: "added-line", File: "sixth.go", Line: 6, Text: "docket", Rule: "word"}, + {Source: "added-line", File: "seventh.go", Line: 7, Text: "docket", Rule: "word"}, + } + msg := leakMessage(hits) + for _, want := range []string{ + "7 docket fingerprint(s) would reach a shared surface; nothing was published: ", + `commit 0123456789ab message line 2 "(0007)" (change-ref)`, + `a.go:3 ".docket" (path)`, + `path x/.docket/y ".docket" (path)`, + `pr-body line 4 "dckt" (alias)`, + `pr-title line 1 "docket" (word)`, + " … and 2 more (see leaks)", + } { + if !strings.Contains(msg, want) { + t.Errorf("leak message lacks %q:\n%s", want, msg) + } + } + for _, absent := range []string{"sixth.go", "seventh.go"} { + if strings.Contains(msg, absent) { + t.Errorf("leak message names %q past the first five hits:\n%s", absent, msg) + } + } + if short := leakMessage(hits[:2]); strings.Contains(short, "more") { + t.Errorf("a two-hit message claims more hits:\n%s", short) + } +} diff --git a/internal/app/workspace_ops.go b/internal/app/workspace_ops.go index 5935050cd..3a6880156 100644 --- a/internal/app/workspace_ops.go +++ b/internal/app/workspace_ops.go @@ -137,6 +137,9 @@ type WorkspaceOpResult struct { State string `json:"state,omitempty"` Reason string `json:"reason,omitempty"` Message string `json:"message,omitempty"` + // Leaks lists every fingerprint a leak-detected publish refused on (each + // hit's matched text only); empty otherwise. + Leaks []LeakHit `json:"leaks,omitempty"` } // HumanText renders the one-line human summary. It names identity, disposition, @@ -370,7 +373,9 @@ func WorkspaceInspect(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep } // WorkspacePublish reinspects the workspace's current head and refuses when it -// differs from the caller's expected head, then delegates to the service's +// differs from the caller's expected head. In a private repository it then runs +// the leak check (runLeakCheck) over that head and refuses on any hit or on a +// check that could not run. Only then does it delegate to the service's // idempotent PublishHead. The service's disposition passes through verbatim — // contended and unknown are never forced or retried-with-force. func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req WorkspacePublishRequest) WorkspaceOpResult { @@ -402,6 +407,31 @@ func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep }) } + // Private-repository leak check: before the fence admission and the push, + // scan exactly req.Head — the head PublishHead re-proves under its lock as + // ExpectedHead — so the commits and lines checked are the ones pushed. A hit + // or a check that cannot run publishes nothing. + if leakCheckApplies(wc.pin.Layout) { + hits, lerr := runLeakCheck(ctx, deps.Client, wc, target, gitcli.ObjectID(req.Head), nil) + if lerr != nil { + return newWorkspaceResult(OperationWorkspacePublish, ResultExternalFailed, WorkspaceOpResult{ + ID: req.ID, + Head: req.Head, + Reason: ReasonLeakCheckUnverified, + Message: "the private-repository leak check could not run; nothing was pushed: " + lerr.Error(), + }) + } + if len(hits) > 0 { + return newWorkspaceResult(OperationWorkspacePublish, ResultBlocked, WorkspaceOpResult{ + ID: req.ID, + Head: req.Head, + Reason: ReasonLeakDetected, + Message: leakMessage(hits), + Leaks: hits, + }) + } + } + // Run mutation fence (change 0375 Task 11): before the feature-head push — // an external effect on origin — admit through the owning run. A cancelled // or superseded run refuses, and NOTHING is published; an active run journals diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 3f8b63183..96c2661c2 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -46,7 +46,7 @@ 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 55 parallel +tests/test_go_integration_app_workflowlifecycle.sh 70 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 076e7a51ad7d737f355fb73450da3bf6e6979e28 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:15:55 -0400 Subject: [PATCH 08/13] feat(app): leak-check private PR writes and finalize pushes --- internal/app/finalize_publish.go | 26 ++++++- .../leak_check_private_integration_test.go | 77 +++++++++++++++++++ internal/app/pr_publish.go | 36 ++++++++- .../app/workflow_private_integration_test.go | 39 +++++++++- internal/cli/finalize.go | 2 +- internal/cli/pr.go | 2 +- internal/cli/workspace.go | 2 +- tests/runtime-budgets.tsv | 2 +- 8 files changed, 177 insertions(+), 9 deletions(-) diff --git a/internal/app/finalize_publish.go b/internal/app/finalize_publish.go index 1cc276330..abbd00a55 100644 --- a/internal/app/finalize_publish.go +++ b/internal/app/finalize_publish.go @@ -29,8 +29,10 @@ import ( // // 1. gate the request shape, capability preflight, and canonical evidence bytes // (bounded, and green for the EXACT requested head); -// 2. read the owned rebase receipt and refuse a foreign attempt token BEFORE any -// push — the attempt is the authorization, and a mismatch never pushes; +// 2. in a private repository, leak-check exactly the requested head (runLeakCheck) +// and refuse on any hit or an unverified scan; then read the owned rebase +// receipt and refuse a foreign attempt token BEFORE any push — the attempt +// is the authorization, and a mismatch never pushes; // 3. PublishRewrite probes the remote feature ref, is a no-op when it already // equals the intended head, and otherwise pushes exactly that head under the // receipt's exact old-remote-head lease and reprobes to equality; a divergent @@ -162,6 +164,9 @@ type FinalizePublishResult struct { Reason string `json:"reason,omitempty"` Message string `json:"message,omitempty"` Findings []StatusFinding `json:"findings"` + // Leaks names the docket fingerprints behind a private repository's + // leak-detected refusal; absent on every other outcome. + Leaks []LeakHit `json:"leaks,omitempty"` } // HumanText renders a one-line summary naming identity, disposition, and the PR @@ -253,6 +258,23 @@ func FinalizePublish(ctx context.Context, deps FinalizeDeps, repoDir string, req } metaDir := workspace.MetaDir(wc.repo.CommonDir, target.FeatureRef) + // The private-repository leak check scans exactly the head this would push, + // before the receipt read and so before any push: a hit refuses with nothing + // pushed, and a scan that could not run is unverified, never clean. A shared + // repository never runs it. + if leakCheckApplies(wc.pin.Layout) { + hits, lerr := runLeakCheck(ctx, deps.Planning.Client, wc, target, gitcli.ObjectID(req.Head), nil) + if lerr != nil { + return publishRefusal(ResultExternalFailed, PublishDispBlocked, ReasonLeakCheckUnverified, + "the private-repository leak check could not run; nothing was pushed: "+lerr.Error(), id) + } + if len(hits) > 0 { + r := publishRefusal(ResultBlocked, PublishDispBlocked, ReasonLeakDetected, leakMessage(hits), id) + r.Leaks = hits + return r + } + } + // The owned receipt authorizes the rewrite. A foreign attempt token is refused // BEFORE any push — the attempt, not the request, is the authorization. receipt, present, rerr := deps.Workspace.ReadRebaseReceipt(ctx, metaDir) diff --git a/internal/app/leak_check_private_integration_test.go b/internal/app/leak_check_private_integration_test.go index cd8f2645d..ae7fef260 100644 --- a/internal/app/leak_check_private_integration_test.go +++ b/internal/app/leak_check_private_integration_test.go @@ -4,11 +4,13 @@ package app import ( "context" + "encoding/json" "os" "path/filepath" "strings" "testing" + "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" "github.com/danielhanold/docket/internal/workspace" ) @@ -368,6 +370,81 @@ func TestIntegrationWorkflowLifecyclePrivateLeakCheckUnverifiedPushesNothing(t * } } +// TestIntegrationWorkflowLifecyclePrivateLeakCheckGatesPR proves a private +// repository's pr.publish scans the authored title and body (as well as the +// outgoing commits) before any GitHub write: a change reference in the title or +// a .docket path in the body refuses with nothing ensured and the body never +// echoed back, while clean prose is ensured byte-for-byte. +func TestIntegrationWorkflowLifecyclePrivateLeakCheckGatesPR(t *testing.T) { + lw, _ := newPrivateLeakWorkspace(t) + head := commitInWorkspace(t, lw.wp, "Add the widget", map[string]string{"widget.go": "package widget\n"}) + if pub := lw.publish(t, head); pub.Result != ResultApplied { + t.Fatalf("publish of the clean head = %q/%q (msg %q, leaks %+v), want applied", pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + evidenceBytes := prEvidenceBytes(t, head) + + prPublish := func(gh *fakeGitHub, title, body string) PRPublishResult { + return PRPublish(context.Background(), lw.node.deps, lw.wdeps, GitHubDeps{Service: gh}, lw.node.dir, PRPublishRequest{ + ID: lw.id, Head: head, Title: title, Body: body, EvidenceRecord: evidenceBytes, + }) + } + newGH := func() *fakeGitHub { + return &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("x")}} + } + requirePRLeak := func(t *testing.T, gh *fakeGitHub, res PRPublishResult, want LeakHit) { + t.Helper() + if res.Result != ResultBlocked || res.Reason != ReasonLeakDetected { + t.Fatalf("pr publish = %q/%q (msg %q), want blocked/%s", res.Result, res.Reason, res.Message, ReasonLeakDetected) + } + if len(gh.ensureCalls) != 0 { + t.Errorf("a leak-refused pr publish still ensured a PR: %+v", gh.ensureCalls) + } + if len(res.Leaks) != 1 { + t.Fatalf("leaks = %+v, want exactly one hit %+v", res.Leaks, want) + } + got := res.Leaks[0] + if got.Source != want.Source || got.Line != want.Line || got.Rule != want.Rule || (want.Text != "" && got.Text != want.Text) { + t.Errorf("leak hit = %+v, want %+v", got, want) + } + if !strings.Contains(res.Message, "("+want.Rule+")") { + t.Errorf("refusal message does not show the rule %s: %q", want.Rule, res.Message) + } + } + + t.Run("change-id-in-title", func(t *testing.T) { + gh := newGH() + requirePRLeak(t, gh, prPublish(gh, "Add the widget for change 0001", "Plain prose.\n"), + LeakHit{Source: "pr-title", Line: 1, Rule: "change-ref"}) + }) + + t.Run("docket-path-in-body", func(t *testing.T) { + gh := newGH() + res := prPublish(gh, "Add the widget", "Plain.\nSee .docket/notes for more.\n") + requirePRLeak(t, gh, res, LeakHit{Source: "pr-body", Line: 2, Rule: "path", Text: ".docket"}) + doc, err := json.Marshal(res) + if err != nil { + t.Fatalf("marshal the refusal: %v", err) + } + if strings.Contains(string(doc), "See .docket/notes for more") { + t.Errorf("the refusal carries the PR body line; a hit holds only the matched token:\n%s", doc) + } + }) + + t.Run("clean-prose-is-ensured-verbatim", func(t *testing.T) { + gh := newGH() + res := prPublish(gh, "Add the widget", "Plain prose.\n") + if res.Result != ResultApplied { + t.Fatalf("pr publish of clean prose = %q/%q (msg %q, leaks %+v), want applied", res.Result, res.Reason, res.Message, res.Leaks) + } + if len(gh.ensureCalls) != 1 { + t.Fatalf("ensure calls = %d, want 1", len(gh.ensureCalls)) + } + if got := gh.ensureCalls[0]; got.Body != "Plain prose.\n" || got.Title != "Add the widget" { + t.Errorf("ensured title/body = %q/%q, want the authored prose verbatim", got.Title, got.Body) + } + }) +} + // TestIntegrationWorkflowLifecycleSharedPublishRunsNoLeakCheck pins that a // shared repository never runs the scan: a change reference and a .docket path // publish untouched. diff --git a/internal/app/pr_publish.go b/internal/app/pr_publish.go index 35687a401..77d651438 100644 --- a/internal/app/pr_publish.go +++ b/internal/app/pr_publish.go @@ -8,8 +8,10 @@ import ( "github.com/danielhanold/docket/internal/document" "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/leakscan" "github.com/danielhanold/docket/internal/render" "github.com/danielhanold/docket/internal/repository" ) @@ -42,7 +44,9 @@ import ( // block. The evidence is a head gate only: it is never woven into // the PR body — its durable home is the change record's "## Build evidence" // section, written by `change mark-implemented`. The result never carries the -// PR body bytes — no Body/Title field exists on it. +// PR body bytes — no Body/Title field exists on it — and a private +// repository's leak-detected refusal names each hit by its matched token +// and line number only, never the title or body line around it. // OperationPRPublish is the operation key `pr publish` records in its envelope. const OperationPRPublish = "pr.publish" @@ -151,6 +155,10 @@ type PRPublishResult struct { // Findings names the relevant validation errors behind a record-invalid // refusal; absent on every other outcome. Findings []StatusFinding `json:"findings,omitempty"` + // Leaks names the docket fingerprints behind a private repository's + // leak-detected refusal — each the matched token and its line only, never + // the surrounding title or body line; absent on every other outcome. + Leaks []LeakHit `json:"leaks,omitempty"` } // HumanText renders the one-line human summary. It names identity, disposition, @@ -249,6 +257,32 @@ func PRPublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } } + // (7b) The private-repository leak check: the authored title and body, the + // outgoing commit messages, and the merge-base diff's added paths and lines of + // exactly the requested head are scanned before any run admission or GitHub + // write. A hit refuses with no PR created or edited; a scan that could not + // run is unverified, never clean. A shared repository never runs it. + if leakCheckApplies(pin.Layout) { + wc, wref := loadWorkspaceContext(ctx, deps, repoDir, req.ID, OperationPRPublish) + if wref != nil { + return prRefusal(wref.Result, wref.Reason, wref.Message, req.ID) + } + target, tref := resolveWorkspaceTarget(OperationPRPublish, wc) + if tref != nil { + return prRefusal(tref.Result, tref.Reason, tref.Message, req.ID) + } + hits, lerr := runLeakCheck(ctx, deps.Client, wc, target, gitcli.ObjectID(req.Head), &leakscan.PRText{Title: req.Title, Body: string(body)}) + if lerr != nil { + return prRefusal(ResultExternalFailed, ReasonLeakCheckUnverified, + "the private-repository leak check could not run; no pull request was created or edited: "+lerr.Error(), req.ID) + } + if len(hits) > 0 { + r := prRefusal(ResultBlocked, ReasonLeakDetected, leakMessage(hits), req.ID) + r.Leaks = hits + return r + } + } + // (8a) Run mutation fence (change 0375 Task 11): PR creation is an external // effect on GitHub, so admit through the owning run before the ensure. A // cancelled or superseded run refuses, and gh is never invoked; an active run diff --git a/internal/app/workflow_private_integration_test.go b/internal/app/workflow_private_integration_test.go index c5bac5d67..583aa41e0 100644 --- a/internal/app/workflow_private_integration_test.go +++ b/internal/app/workflow_private_integration_test.go @@ -10,7 +10,9 @@ import ( "sort" "strings" "testing" + "time" + "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/layout" @@ -155,13 +157,46 @@ func privateLifecycleChecks(t *testing.T, created ChangeCreateResult, store stri } } - // A private finalize block posts no PR comment: the marker on the record - // is its only effect (acceptance 6). svc, err := workspace.NewService(node.deps.Client) if err != nil { t.Fatalf("workspace service: %v", err) } gh := &fakeBlockGitHub{repo: prRepo(), commentOutcome: githubcli.CommentCreated, commentURL: "https://example.invalid/c/1"} + + // finalize.publish scans the exact head it would push before it reads the + // rebase receipt: a head whose own message carries a change reference is + // leak-detected, and the clean head passes the scan and reaches the + // receipt read (no-rebase-receipt). Neither moves the feature branch. + finalizePublish := func(head string) FinalizePublishResult { + rec, rerr := evidence.NewRecord("gate", head, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + if rerr != nil { + t.Fatalf("evidence.NewRecord: %v", rerr) + } + return FinalizePublish(ctx, FinalizeDeps{Planning: node.deps, GitHub: gh, Workspace: svc}, node.dir, FinalizePublishRequest{ + ID: created.ID, Attempt: "a1", Head: head, EvidenceRecord: []byte(evidence.Render(rec)), + }) + } + leaky := runGit(t, node.dir, "commit-tree", insp.Head+"^{tree}", "-p", insp.Head, "-m", "Finalize (0001)") + fp := finalizePublish(leaky) + if fp.Result != ResultBlocked || fp.Reason != ReasonLeakDetected { + t.Fatalf("private finalize publish of a leaky head = %q/%q (msg %q), want blocked/%s", fp.Result, fp.Reason, fp.Message, ReasonLeakDetected) + } + if len(fp.Leaks) != 1 || fp.Leaks[0].Commit != leaky || fp.Leaks[0].Rule != "change-ref" || fp.Leaks[0].Source != "commit-message" { + t.Errorf("finalize publish leaks = %+v, want one change-ref hit in commit %s", fp.Leaks, leaky) + } + if !strings.Contains(fp.Message, shortCommit(leaky)) { + t.Errorf("finalize publish refusal does not name the leaky commit %s: %q", shortCommit(leaky), fp.Message) + } + if clean := finalizePublish(insp.Head); clean.Result != ResultBlocked || clean.Reason != ReasonPublishNoReceipt { + t.Errorf("private finalize publish of the clean head = %q/%q (msg %q, leaks %+v), want blocked/%s", + clean.Result, clean.Reason, clean.Message, clean.Leaks, ReasonPublishNoReceipt) + } + if got := runGit(t, node.dir, "rev-parse", insp.FeatureRef); got != insp.Head { + t.Errorf("finalize publish moved %s to %s, want %s", insp.FeatureRef, got, insp.Head) + } + + // A private finalize block posts no PR comment: the marker on the record + // is its only effect (acceptance 6). blocked := FinalizeBlock(ctx, FinalizeDeps{Planning: node.deps, GitHub: gh, Workspace: svc}, node.dir, BlockRequest{ ID: created.ID, Revision: blobRevisionAt(t, store, privateFixtureBranch, created.Path), diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index cc2eb5da5..a18e1d1dd 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -513,7 +513,7 @@ func newFinalizeResolverReserveSubcommand(setResult func(app.OperationResult)) * func newFinalizePublishSubcommand(setResult func(app.OperationResult)) *cobra.Command { cmd := &cobra.Command{ Use: "publish", - Short: "Publish a rebased feature head under its receipt lease and record its build evidence", + Short: "Publish a rebased feature head under its receipt lease and record its build evidence (leak-checked in a private repository)", Args: cobra.NoArgs, // external-write: force-with-lease pushes the rewritten feature head // (remote ref); the pull request is only probed, never edited; diff --git a/internal/cli/pr.go b/internal/cli/pr.go index 9aba9a264..6d413fcb9 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -41,7 +41,7 @@ func newPRCommand(setResult func(app.OperationResult)) *cobra.Command { publish := &cobra.Command{ Use: "publish", - Short: "Publish (create or adopt) the pull request for a published feature head certified by its build evidence", + Short: "Publish (create or adopt) the pull request for a published feature head certified by its build evidence (leak-checked in a private repository)", Args: cobra.NoArgs, // external-write only: PRPublish weaves the backlink block into the PR // body on GitHub (the evidence only gates the head); the change record's diff --git a/internal/cli/workspace.go b/internal/cli/workspace.go index 5c68d064d..d6c69766d 100644 --- a/internal/cli/workspace.go +++ b/internal/cli/workspace.go @@ -109,7 +109,7 @@ func newWorkspaceCommand(setResult func(app.OperationResult)) *cobra.Command { publish := &cobra.Command{ Use: "publish", - Short: "Publish a change's ready workspace head to the remote feature ref", + Short: "Publish a change's ready workspace head to the remote feature ref (leak-checked in a private repository)", Args: cobra.NoArgs, Annotations: capability("workspace.publish", EffectExternalWrite), RunE: func(c *cobra.Command, _ []string) error { diff --git a/tests/runtime-budgets.tsv b/tests/runtime-budgets.tsv index 96c2661c2..3ad56746b 100644 --- a/tests/runtime-budgets.tsv +++ b/tests/runtime-budgets.tsv @@ -46,7 +46,7 @@ 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 70 parallel +tests/test_go_integration_app_workflowlifecycle.sh 80 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 befe27a51fb4559a6f3f177096101058e647b923 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:21:13 -0400 Subject: [PATCH 09/13] feat(repository): report docket-named refs on a private repository's origin --- .../leak_check_private_integration_test.go | 23 ++++ internal/app/repository_check.go | 1 + internal/app/repository_prepare.go | 4 + .../repository_private_integration_test.go | 43 +++++++ internal/app/repository_shared_remote.go | 105 +++++++++++++++++ internal/app/repository_shared_remote_test.go | 106 ++++++++++++++++++ internal/app/workspace_ops.go | 10 +- 7 files changed, 291 insertions(+), 1 deletion(-) create mode 100644 internal/app/repository_shared_remote.go create mode 100644 internal/app/repository_shared_remote_test.go diff --git a/internal/app/leak_check_private_integration_test.go b/internal/app/leak_check_private_integration_test.go index ae7fef260..4e07f5116 100644 --- a/internal/app/leak_check_private_integration_test.go +++ b/internal/app/leak_check_private_integration_test.go @@ -295,6 +295,29 @@ func TestIntegrationWorkflowLifecyclePrivateLeakCheckBlocksSeededLeaks(t *testin if got := originRefTip(t, lw.origin, lw.featureRef); got != head { t.Errorf("origin %s = %s, want the merged head %s", lw.featureRef, got, head) } + if len(pub.Findings) != 0 { + t.Errorf("publish findings = %+v, want none while origin holds no docket-named ref", pub.Findings) + } + }) + + t.Run("docket-named-ref-on-origin-is-reported", func(t *testing.T) { + if cleanHead == "" { + t.Fatal("the clean publish did not run") + } + runGit(t, lw.writer, "push", "-q", "origin", "main:refs/heads/dckt") + head := commitInWorkspace(t, lw.wp, "Polish the widget layout", map[string]string{"polish.md": "Polished.\n"}) + pub := lw.publish(t, head) + if pub.Result != ResultApplied { + t.Fatalf("publish = %q/%q (msg %q, leaks %+v), want applied; a docket-named origin ref never blocks", + pub.Result, pub.Reason, pub.Message, pub.Leaks) + } + if len(pub.Findings) != 1 || pub.Findings[0].Code != FindingMetadataOnSharedRemote || + pub.Findings[0].Path != "refs/heads/dckt" || pub.Findings[0].Severity != "warning" { + t.Errorf("publish findings = %+v, want one %s warning for refs/heads/dckt", pub.Findings, FindingMetadataOnSharedRemote) + } + if got := originRefTip(t, lw.origin, lw.featureRef); got != head { + t.Errorf("origin %s = %s, want the published head %s", lw.featureRef, got, head) + } }) } diff --git a/internal/app/repository_check.go b/internal/app/repository_check.go index f7baecf1e..a94512be0 100644 --- a/internal/app/repository_check.go +++ b/internal/app/repository_check.go @@ -115,6 +115,7 @@ func RunRepositoryCheck(ctx context.Context, d SetupDeps) RepositoryCheckResult // repository's setting is what init will use). if facts.RemoteMetadata.Presence == reposetup.PresencePresent { findings = append(findings, privateCheckFindings(sc)...) + findings = append(findings, sharedRemoteMetadataFindings(ctx, d.Git, sc.layout, sc.repo)...) } return newCheckResult(cls, facts, findings) } diff --git a/internal/app/repository_prepare.go b/internal/app/repository_prepare.go index b815b5f28..53c1cc22a 100644 --- a/internal/app/repository_prepare.go +++ b/internal/app/repository_prepare.go @@ -773,7 +773,11 @@ func prepareContextResult(ctx context.Context, git *gitcli.Client, result Result Context: pc, Notices: notices, } + out.Findings = sharedRemoteMetadataFindings(ctx, git, sc.layout, sc.repo) out.human = fmt.Sprintf("repository prepare: %s (%s); metadata worktree %s", disposition, reposetup.StateHealthy, pc.MetadataWorktreePath) + for _, f := range out.Findings { + out.human += fmt.Sprintf("\n- [%s] %s %s", f.Severity, f.Code, f.Ref) + } return out } diff --git a/internal/app/repository_private_integration_test.go b/internal/app/repository_private_integration_test.go index ed67f07e9..00bc8808d 100644 --- a/internal/app/repository_private_integration_test.go +++ b/internal/app/repository_private_integration_test.go @@ -841,3 +841,46 @@ func TestIntegrationRepoSetupPrivatePRBacklinkRepairIsNoOp(t *testing.T) { t.Errorf("private PR-backlink repair text = %q; want it to say a private repository's pull requests carry no backlink", res.HumanText()) } } + +// TestIntegrationRepoSetupPrivateMetadataOnSharedRemote proves a docket-named +// branch or refs/docket/ ref on a private repository's origin is reported by +// check and prepare as a warning, and never changes the classified state. +func TestIntegrationRepoSetupPrivateMetadataOnSharedRemote(t *testing.T) { + r, _ := initPrivateHealthy(t) + before := r.runCheck(t) + if got := findingsWithCode(before.Findings, FindingMetadataOnSharedRemote); len(got) != 0 { + t.Fatalf("a clean private origin already reports %+v", got) + } + + runGit(t, r.writer, "push", "-q", "origin", "main:refs/heads/docket", "main:refs/docket/x") + want := []string{"refs/docket/x", "refs/heads/docket"} + refsOf := func(fs []reposetup.Finding) []string { + var out []string + for _, f := range findingsWithCode(fs, FindingMetadataOnSharedRemote) { + if f.Severity != reposetup.SeverityWarning { + t.Errorf("finding %+v is not a warning", f) + } + out = append(out, f.Ref) + } + return out + } + + chk := r.runCheck(t) + if got := refsOf(chk.Findings); strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("check %s refs = %v, want %v:\n%s", FindingMetadataOnSharedRemote, got, want, chk.HumanText()) + } + if chk.RepositoryState != before.RepositoryState { + t.Errorf("state moved from %q to %q; the finding never changes the state", before.RepositoryState, chk.RepositoryState) + } + + prep := RunRepositoryPrepare(context.Background(), SetupDeps{Git: newGitClient(t), RepoDir: r.invocation}, PrepareOptions{}) + if prep.Disposition != PrepareDispositionApplied && prep.Disposition != PrepareDispositionNoOp { + t.Fatalf("prepare disposition = %q (%s), want applied or no-op", prep.Disposition, prep.HumanText()) + } + if got := refsOf(prep.Findings); strings.Join(got, ",") != strings.Join(want, ",") { + t.Errorf("prepare %s refs = %v, want %v:\n%s", FindingMetadataOnSharedRemote, got, want, prep.HumanText()) + } + if !strings.Contains(prep.HumanText(), "- [warning] "+FindingMetadataOnSharedRemote+" refs/heads/docket") { + t.Errorf("prepare human text does not list the finding:\n%s", prep.HumanText()) + } +} diff --git a/internal/app/repository_shared_remote.go b/internal/app/repository_shared_remote.go new file mode 100644 index 000000000..c9c0cf028 --- /dev/null +++ b/internal/app/repository_shared_remote.go @@ -0,0 +1,105 @@ +package app + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// This file reports docket-named refs on a private repository's origin: a +// `docket` or `dckt` branch, or any refs/docket/ ref. A private repository keeps +// its metadata off origin, so such a ref is visible to everyone with access to +// origin. It is reported as a warning only — it never blocks and never changes +// a classified state — and a listing failure is reported as unverified, never as +// a proven absence. A shared repository never lists origin for it. + +// The finding codes. +const ( + FindingMetadataOnSharedRemote = "metadata-on-shared-remote" + FindingMetadataOnSharedRemoteUnverified = "metadata-on-shared-remote-unverified" +) + +// remoteRefLister is the Git seam the report reads origin through. +type remoteRefLister interface { + ListRemoteRefs(ctx context.Context, repo gitcli.Repository, remote gitcli.RemoteName, patterns []string) (map[gitcli.RefName]gitcli.ObjectID, error) +} + +var _ remoteRefLister = (*gitcli.Client)(nil) + +// metadataRefsOnOrigin lists origin's docket-named refs, sorted: exactly +// refs/heads/docket, exactly refs/heads/dckt, and anything under refs/docket/. +// ls-remote patterns match by trailing path component, so the result is +// filtered to those exact names (refs/heads/docketeer and refs/x/heads/docket +// are not docket-named). A listing failure is returned, never an empty list. +func metadataRefsOnOrigin(ctx context.Context, g remoteRefLister, repo gitcli.Repository) ([]string, error) { + shared := "refs/heads/" + layout.SharedName + private := "refs/heads/" + layout.PrivateName + prefix := "refs/" + layout.SharedName + "/" + refs, err := g.ListRemoteRefs(ctx, repo, originRemote, []string{shared, private, prefix + "*"}) + if err != nil { + return nil, err + } + var out []string + for ref := range refs { + name := string(ref) + if name == shared || name == private || strings.HasPrefix(name, prefix) { + out = append(out, name) + } + } + sort.Strings(out) + return out, nil +} + +// sharedRemoteMetadataFindings reports each docket-named ref on a private +// repository's origin as a warning, or one unverified warning when origin +// cannot be listed. A shared repository returns nil without listing. +func sharedRemoteMetadataFindings(ctx context.Context, g remoteRefLister, lay layout.Layout, repo gitcli.Repository) []reposetup.Finding { + if lay.Mode != layout.Private { + return nil + } + refs, err := metadataRefsOnOrigin(ctx, g, repo) + if err != nil { + return []reposetup.Finding{{ + Code: FindingMetadataOnSharedRemoteUnverified, + Severity: reposetup.SeverityWarning, + Message: fmt.Sprintf("origin could not be listed for docket-named refs (unverified, not proven absent): %v", err), + Remedy: "Restore access to origin, then re-run docket repository check.", + }} + } + var out []reposetup.Finding + for _, ref := range refs { + out = append(out, reposetup.Finding{ + Code: FindingMetadataOnSharedRemote, + Severity: reposetup.SeverityWarning, + Ref: ref, + Message: fmt.Sprintf("origin holds %s, a docket-named ref, but this repository is private; everyone with access to origin can see it.", ref), + Remedy: fmt.Sprintf("If nothing still needs it, delete it from origin: `git push origin --delete %s`.", ref), + }) + } + return out +} + +// sharedRemoteMetadataStatusFindings is sharedRemoteMetadataFindings in the +// StatusFinding shape a workflow operation result carries (the ref as Path). +func sharedRemoteMetadataStatusFindings(ctx context.Context, g remoteRefLister, lay layout.Layout, repo gitcli.Repository) []StatusFinding { + fs := sharedRemoteMetadataFindings(ctx, g, lay, repo) + if fs == nil { + return nil + } + out := make([]StatusFinding, 0, len(fs)) + for _, f := range fs { + out = append(out, StatusFinding{ + Code: f.Code, + Severity: string(f.Severity), + Path: f.Ref, + Message: f.Message, + Remedy: f.Remedy, + }) + } + return out +} diff --git a/internal/app/repository_shared_remote_test.go b/internal/app/repository_shared_remote_test.go new file mode 100644 index 000000000..4f09ef6fa --- /dev/null +++ b/internal/app/repository_shared_remote_test.go @@ -0,0 +1,106 @@ +package app + +import ( + "context" + "errors" + "reflect" + "strings" + "testing" + + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/layout" + "github.com/danielhanold/docket/internal/reposetup" +) + +// fakeRefLister returns refs (or err) and records every call's patterns. +type fakeRefLister struct { + refs map[gitcli.RefName]gitcli.ObjectID + err error + calls [][]string +} + +func (f *fakeRefLister) ListRemoteRefs(_ context.Context, _ gitcli.Repository, remote gitcli.RemoteName, patterns []string) (map[gitcli.RefName]gitcli.ObjectID, error) { + if remote != originRemote { + return nil, errors.New("unexpected remote " + string(remote)) + } + f.calls = append(f.calls, append([]string(nil), patterns...)) + return f.refs, f.err +} + +func TestSharedRemoteMetadataFindings(t *testing.T) { + ctx := context.Background() + const oid = gitcli.ObjectID("0123456789012345678901234567890123456789") + + t.Run("shared-never-lists", func(t *testing.T) { + g := &fakeRefLister{refs: map[gitcli.RefName]gitcli.ObjectID{"refs/heads/docket": oid}} + lay := layout.Layout{Mode: layout.Shared} + if got := sharedRemoteMetadataFindings(ctx, g, lay, gitcli.Repository{}); got != nil { + t.Errorf("shared findings = %+v, want nil", got) + } + if got := sharedRemoteMetadataStatusFindings(ctx, g, lay, gitcli.Repository{}); got != nil { + t.Errorf("shared status findings = %+v, want nil", got) + } + if len(g.calls) != 0 { + t.Errorf("a shared repository listed origin %d times, want never", len(g.calls)) + } + }) + + t.Run("private-reports-docket-named-refs-sorted", func(t *testing.T) { + g := &fakeRefLister{refs: map[gitcli.RefName]gitcli.ObjectID{ + "refs/heads/dckt": oid, + "refs/heads/docketeer": oid, + "refs/docket/finalize/7/orig": oid, + "refs/heads/docket": oid, + }} + lay := layout.Layout{Mode: layout.Private} + got := sharedRemoteMetadataFindings(ctx, g, lay, gitcli.Repository{}) + wantRefs := []string{"refs/docket/finalize/7/orig", "refs/heads/dckt", "refs/heads/docket"} + var refs []string + for _, f := range got { + refs = append(refs, f.Ref) + if f.Code != FindingMetadataOnSharedRemote || f.Severity != reposetup.SeverityWarning || f.Repairable != nil { + t.Errorf("finding %+v, want a %s warning with no Repairable", f, FindingMetadataOnSharedRemote) + } + if !strings.Contains(f.Message, "origin holds "+f.Ref+", a docket-named ref") || + !strings.Contains(f.Remedy, "git push origin --delete "+f.Ref) { + t.Errorf("finding %+v does not name its ref in the message and remedy", f) + } + } + if !reflect.DeepEqual(refs, wantRefs) { + t.Errorf("refs = %v, want %v", refs, wantRefs) + } + if len(g.calls) != 1 || !reflect.DeepEqual(g.calls[0], []string{"refs/heads/docket", "refs/heads/dckt", "refs/docket/*"}) { + t.Errorf("lister calls = %v, want one call with the three docket-named patterns", g.calls) + } + + st := sharedRemoteMetadataStatusFindings(ctx, g, lay, gitcli.Repository{}) + if len(st) != len(got) { + t.Fatalf("status findings = %+v, want %d", st, len(got)) + } + for i, f := range st { + if f.Code != got[i].Code || f.Severity != "warning" || f.Path != got[i].Ref || + f.Message != got[i].Message || f.Remedy != got[i].Remedy { + t.Errorf("status finding %+v does not mirror %+v", f, got[i]) + } + } + }) + + t.Run("private-list-error-is-unverified", func(t *testing.T) { + g := &fakeRefLister{err: errors.New("origin unreachable")} + lay := layout.Layout{Mode: layout.Private} + got := sharedRemoteMetadataFindings(ctx, g, lay, gitcli.Repository{}) + if len(got) != 1 { + t.Fatalf("findings = %+v, want one unverified warning", got) + } + f := got[0] + if f.Code != FindingMetadataOnSharedRemoteUnverified || f.Severity != reposetup.SeverityWarning || + !strings.Contains(f.Message, "not proven absent") || !strings.Contains(f.Message, "origin unreachable") || + !strings.Contains(f.Remedy, "docket repository check") { + t.Errorf("unverified finding = %+v", f) + } + st := sharedRemoteMetadataStatusFindings(ctx, g, lay, gitcli.Repository{}) + if len(st) != 1 || st[0].Code != FindingMetadataOnSharedRemoteUnverified { + t.Errorf("unverified status findings = %+v", st) + } + }) +} diff --git a/internal/app/workspace_ops.go b/internal/app/workspace_ops.go index 3a6880156..d7d08c108 100644 --- a/internal/app/workspace_ops.go +++ b/internal/app/workspace_ops.go @@ -140,6 +140,9 @@ type WorkspaceOpResult struct { // Leaks lists every fingerprint a leak-detected publish refused on (each // hit's matched text only); empty otherwise. Leaks []LeakHit `json:"leaks,omitempty"` + // Findings carries report-only warnings a successful operation surfaces (a + // docket-named ref on a private repository's origin); they never block. + Findings []StatusFinding `json:"findings,omitempty"` } // HumanText renders the one-line human summary. It names identity, disposition, @@ -410,7 +413,9 @@ func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep // Private-repository leak check: before the fence admission and the push, // scan exactly req.Head — the head PublishHead re-proves under its lock as // ExpectedHead — so the commits and lines checked are the ones pushed. A hit - // or a check that cannot run publishes nothing. + // or a check that cannot run publishes nothing. Once it passes, a docket-named + // ref already on origin is reported (warn), never blocked. + var warn []StatusFinding if leakCheckApplies(wc.pin.Layout) { hits, lerr := runLeakCheck(ctx, deps.Client, wc, target, gitcli.ObjectID(req.Head), nil) if lerr != nil { @@ -430,6 +435,7 @@ func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep Leaks: hits, }) } + warn = sharedRemoteMetadataStatusFindings(ctx, deps.Client, wc.pin.Layout, wc.repo) } // Run mutation fence (change 0375 Task 11): before the feature-head push — @@ -475,10 +481,12 @@ func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep Message: "the workspace head moved past the expected head before the push; publish nothing", }) } + out.Findings = warn done(mutationJournalOutcome(out.Result)) return out } out := publishResult(OperationWorkspacePublish, req.ID, target, res) + out.Findings = warn // PublishHead re-proves req.Head under its own lock (ExpectedHead, change // 0451), so a head that moved after the Inspect above is refused before any // push. The verified flag still independently requires the acted-on head to From eef9f33964e71a411033073a78414baf28cebdc8 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:25:10 -0400 Subject: [PATCH 10/13] docs(skills): private-repository writing rule, visibility hand-off, and leak-detected halt --- agents/docket-plan-writer.md | 4 +- internal/assets/embedded/manifest.json | 50 ++++----- .../tree/agents/docket-plan-writer.md | 4 +- .../tree/skills/docket-auto-groom/SKILL.md | 2 +- .../tree/skills/docket-brainstorm/SKILL.md | 4 +- .../tree/skills/docket-build-task/SKILL.md | 5 + .../tree/skills/docket-build/SKILL.md | 4 +- .../tree/skills/docket-convention/SKILL.md | 4 +- .../skills/docket-finalize-change/SKILL.md | 4 +- .../tree/skills/docket-groom-next/SKILL.md | 2 +- .../skills/docket-implement-next/SKILL.md | 7 +- .../references/edge-paths.md | 6 +- .../references/fix-pass.md | 4 +- .../tree/skills/docket-new-change/SKILL.md | 2 +- .../testdata/golden/docket-plan-writer.md | 4 +- .../testdata/golden/docket-plan-writer.toml | 4 +- .../testdata/golden/docket-plan-writer.md | 4 +- .../testdata/golden/docket-plan-writer.md | 4 +- internal/repoguard/budgets_test.go | 22 ++-- .../repoguard/private_writing_rule_test.go | 104 ++++++++++++++++++ skills/docket-auto-groom/SKILL.md | 2 +- skills/docket-brainstorm/SKILL.md | 4 +- skills/docket-build-task/SKILL.md | 5 + skills/docket-build/SKILL.md | 4 +- skills/docket-convention/SKILL.md | 4 +- skills/docket-finalize-change/SKILL.md | 4 +- skills/docket-groom-next/SKILL.md | 2 +- skills/docket-implement-next/SKILL.md | 7 +- .../references/edge-paths.md | 6 +- .../references/fix-pass.md | 4 +- skills/docket-new-change/SKILL.md | 2 +- 31 files changed, 218 insertions(+), 70 deletions(-) create mode 100644 internal/repoguard/private_writing_rule_test.go diff --git a/agents/docket-plan-writer.md b/agents/docket-plan-writer.md index 159faac32..029a2d61d 100644 --- a/agents/docket-plan-writer.md +++ b/agents/docket-plan-writer.md @@ -3,7 +3,9 @@ name: docket-plan-writer description: Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, writes the plan artifact with its backlink on the metadata branch through change.attach-plan, and returns only the plan's repo-relative path. Not invoked directly by a human. worktree-scope: feature --- -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 9d09532cf..be63897ab 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:f32b5c725729f6d397517ab006f4e5416bd10e8bb8c569680a67e60d33b51db6", + "asset_set_id": "sha256:5d0136e7d91b740646f0a5837ba0d599d8b5eccc2267252b0d269edeccf0ecba", "entries": [ { "path": ".docket.example.yml", @@ -91,8 +91,8 @@ "path": "agents/docket-plan-writer.md", "role": "agent-source", "mode": 420, - "size": 3053, - "sha256": "2124b9eb0c33b1b17245b1b39d39be9f5a3de1e7a6aa2042fea0e30836404a9a" + "size": 3310, + "sha256": "47253954dc82e88d16fe8b9a34708263c302b59a915073ed3c505f1bf9a761af" }, { "path": "agents/docket-rebase-resolver.md", @@ -280,29 +280,29 @@ "path": "skills/docket-auto-groom/SKILL.md", "role": "skill", "mode": 420, - "size": 11146, - "sha256": "b643f3686d6563bc26c5fad11c24a901c9febb39f436cce606056acb1e3650e5" + "size": 11338, + "sha256": "d4f4404e9183aece4b3c9c7f6c827bbcf46c315b792315909537e404feea385c" }, { "path": "skills/docket-brainstorm/SKILL.md", "role": "skill", "mode": 420, - "size": 4573, - "sha256": "33808bc068634e6380e4e60d515fa23fce4128d97adcb2821ebee11cbcda860e" + "size": 4769, + "sha256": "b0eeaccd49e416bd950bc8ecfddd1d930dbe4364b7c7c83e23f9b16d9779058e" }, { "path": "skills/docket-build-task/SKILL.md", "role": "skill", "mode": 420, - "size": 10238, - "sha256": "d6e8c6ae9c6e95feace285ea02b9d60d4b29dac8ce08c40367e7561f9d859f43" + "size": 10603, + "sha256": "ef60c5f15963d8a0da8ba6fd327ccba8a6547ca6a2b27ad61891039cfccbd87a" }, { "path": "skills/docket-build/SKILL.md", "role": "skill", "mode": 420, - "size": 25724, - "sha256": "c21a98708e798f79eafc4d130a874004abf5c7395e68e4bb5fff28614caa8170" + "size": 25938, + "sha256": "bf930df5accead5f6ee63bb5eadbc6fdf2a793f24c1d19a0eb72c7bd5c423cf1" }, { "path": "skills/docket-build/references/gate-driver.md", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 52148, - "sha256": "99b19e49adb1bc60b729ad1595563f3e44ba3fe058849a03defe2526f181c6b1" + "size": 52933, + "sha256": "2947b14fb015f8e586a89012826c18c479755535de53d89b489305187c50ab8d" }, { "path": "skills/docket-convention/references/agent-layer.md", @@ -357,8 +357,8 @@ "path": "skills/docket-finalize-change/SKILL.md", "role": "skill", "mode": 420, - "size": 41498, - "sha256": "ef64a37b1002b7dd45061665a5ba3f8675bd29b1ba472164abf296bede016cea" + "size": 41759, + "sha256": "cf26a62323d1437a36e422491d13f3660b4be666feaf92649f8c7389a340d569" }, { "path": "skills/docket-finalize-change/references/gate-failure.md", @@ -371,29 +371,29 @@ "path": "skills/docket-groom-next/SKILL.md", "role": "skill", "mode": 420, - "size": 14508, - "sha256": "957197bd9fbf96918f3776d4465bb34524335ea9f27bfdda811c5be677f994f5" + "size": 14700, + "sha256": "3b1bb78819de80f2b7152fa79559668564255f7118510e0326dc77a7ded1ec9f" }, { "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56932, - "sha256": "49c4ba1d6b87f657d4cd2bad004c3a3d0f090880b1d9b784a883b635b4f94b37" + "size": 57866, + "sha256": "e17d481e068b67e20335978280743c3414d3e0819a55033523c5a460cd162bf6" }, { "path": "skills/docket-implement-next/references/edge-paths.md", "role": "skill", "mode": 420, - "size": 8374, - "sha256": "79c7f698415b7733f811e68815b9378c32b807dc3d4b05429da4f06e8a38c639" + "size": 8662, + "sha256": "08c96bffc603a732a674e361e70b645b98b5ae1752520372027215446eaf2667" }, { "path": "skills/docket-implement-next/references/fix-pass.md", "role": "skill", "mode": 420, - "size": 12335, - "sha256": "895f5c2a1e030e8591ab02a48a8f82b8d48b5a778a66699f170011ad3abd7c11" + "size": 12497, + "sha256": "e52c26a900915c8eac7cbdeff9224decd5f9c7fbbe4c14f98a2ade6523c3fd36" }, { "path": "skills/docket-implement-next/results-template.md", @@ -406,8 +406,8 @@ "path": "skills/docket-new-change/SKILL.md", "role": "skill", "mode": 420, - "size": 11229, - "sha256": "b86edf4e97716f9b66bc0c705f01f04cda4ba421738802a7fbbe22e22233b385" + "size": 11422, + "sha256": "552aa0ef2006e7432dda433c3bc22b043ea247b91b58370759a1dde4cb2ee7d1" }, { "path": "skills/docket-review/SKILL.md", diff --git a/internal/assets/embedded/tree/agents/docket-plan-writer.md b/internal/assets/embedded/tree/agents/docket-plan-writer.md index 159faac32..029a2d61d 100644 --- a/internal/assets/embedded/tree/agents/docket-plan-writer.md +++ b/internal/assets/embedded/tree/agents/docket-plan-writer.md @@ -3,7 +3,9 @@ name: docket-plan-writer description: Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, writes the plan artifact with its backlink on the metadata branch through change.attach-plan, and returns only the plan's repo-relative path. Not invoked directly by a human. worktree-scope: feature --- -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. 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 a334ef84a..8625279a3 100644 --- a/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-auto-groom/SKILL.md @@ -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 `<changes_dir>/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. +Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `<changes_dir>/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. In a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references. 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 diff --git a/internal/assets/embedded/tree/skills/docket-brainstorm/SKILL.md b/internal/assets/embedded/tree/skills/docket-brainstorm/SKILL.md index c17bb4791..b95e3eced 100644 --- a/internal/assets/embedded/tree/skills/docket-brainstorm/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-brainstorm/SKILL.md @@ -46,7 +46,9 @@ that). Hand it: - relevant learnings findings, drawn from the learnings index; - the compact brief: the spec path and expected format, the PM-altitude boundary (design detail belongs in the spec; intent and scope stay in the change), and the - requirement for an explicit `## Assumptions` section. + requirement for an explicit `## Assumptions` section. In a private repository the spec ships + with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` + line, no change or ADR numbers, no backlog references. The consultant performs zero docket operations (no git, no status writes, no board) and returns **in-context** exactly one of: 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 19b86040d..d679153b6 100644 --- a/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build-task/SKILL.md @@ -133,6 +133,11 @@ A task produces a commit **only on success** — `COMPLETE` means focused verifi If the **task text itself** prescribes more than one commit, the plan wins over this default: follow the task and report every SHA in your return. +In a private repository (the dispatch payload says `Visibility: private`), every commit message, +code line, comment, and test name follows the convention's private-repository writing rule; never +copy a change id or docket wording from the plan. When the task cannot be done without breaking it, +return `BLOCKED` naming the conflict — never commit the violation. + ## Outcomes Return exactly one of three outcomes. A missing or malformed outcome halts the build, so state it diff --git a/internal/assets/embedded/tree/skills/docket-build/SKILL.md b/internal/assets/embedded/tree/skills/docket-build/SKILL.md index 298772357..4eda2e388 100644 --- a/internal/assets/embedded/tree/skills/docket-build/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build/SKILL.md @@ -79,7 +79,9 @@ Dispatch the selected tier agent **by name** — one of `docket-build-economy`, time; later tasks build on earlier task commits and share the worktree, so workers are strictly sequential. Its dispatch payload contains: Feature worktree: <absolute canonical feature-worktree root> -It also gives the worker the branch name, the plan task text, applicable repository instructions, selected +Visibility: <shared|private> +The `Visibility` value is the `repository.prepare` context's `visibility`; in a private repository +the plan's suggested commit messages are held to the private-repository writing rule. It also gives the worker the branch name, the plan task text, applicable repository instructions, selected tier and routing reason, and the return schema. No run context or capability goes into a worker prompt: the worker runs its tests directly and calls no gate operation. Never dispatch a task reviewer, and never dispatch two workers concurrently — that binds a controller who *believes the first worker diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index a31d0b404..0d6976f55 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -74,7 +74,7 @@ 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 <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. +3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir <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 repository's `visibility` (`shared` | `private`), 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. @@ -352,6 +352,8 @@ Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch 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`. +**Private-repository writing rule.** In a private repository (`visibility: private` in the `repository.prepare` context), nothing that ships through the feature branch or the PR may contain the words "docket" or "dckt", docket marker comments, `Docket-` trailers, `.docket`/`dckt` paths, change ids or change references (`change 0612`, `#0612`, `(0612)`), docket ADR numbers, or references to the backlog or board. The metadata branch's own files (plan, results, records) are exempt and must not be copied from. `workspace.publish`, `pr.publish`, and `finalize.publish` enforce it with a blocking leak check (`leak-detected`); `leak_check.match_word: false` lets the bare word "docket" through. A shared repository is unaffected. + 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. diff --git a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md index 77eda9238..3aba56ade 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md @@ -133,7 +133,7 @@ A pass with **no** authored repair (an exact-head-evidence skip, or a clean firs ### 7. Publish the rebased head -The `finalize.publish` operation with `--id <id> --attempt <attempt> --head <head> --evidence <evidence file>`. It probes the remote first (a no-op when already at `head`), pushes exactly `head` under the receipt's exact old-value lease, then records that head's evidence in the change record's `## Build evidence` section. The PR body is not touched. It never creates a second PR. A reprobe `unknown` returns `rewrite-unknown`/`pr-probe-failed` and stops with no second mutation (`halted`); a moved remote returns `rewrite-contended` (re-read context, `contended`); an attempt token not matching the receipt is refused before any push. +The `finalize.publish` operation with `--id <id> --attempt <attempt> --head <head> --evidence <evidence file>`. It probes the remote first (a no-op when already at `head`), pushes exactly `head` under the receipt's exact old-value lease, then records that head's evidence in the change record's `## Build evidence` section. The PR body is not touched. It never creates a second PR. A reprobe `unknown` returns `rewrite-unknown`/`pr-probe-failed` and stops with no second mutation (`halted`); a moved remote returns `rewrite-contended` (re-read context, `contended`); an attempt token not matching the receipt is refused before any push. `leak-detected` (`blocked`): record it with `finalize.block` (reason `leak-detected`, the hit list as the report) and stop `halted`; never reword or force-push past it. ### 8. Merge exactly once @@ -232,7 +232,7 @@ After a successful relink, **reload and re-probe from scratch** — run the `con ## Abort and the blocked marker -The full abort-and-report set, the two-agent split, the green-repair rule, and the `## Finalize blocked` marker's write shape and lifecycle live in **`references/gate-failure.md`** — **read it at any abort** (a conflict, a red gate, an unavailable dispatch, a denied merge) before recording or reporting. Every abort-and-report point maps to `halted`, leaves the PR open and the change `implemented`, and records the `## Finalize blocked` marker via the `finalize.block` operation (comment first, then the single upserted section); the `finalize.clear-block` operation removes it after a successful reprobe. The marker is a visible note: it never stops selection or merge, so the next run retries the change. +The full abort-and-report set, the two-agent split, the green-repair rule, and the `## Finalize blocked` marker's write shape and lifecycle live in **`references/gate-failure.md`** — **read it at any abort** (a conflict, a red gate, an unavailable dispatch, a denied merge) before recording or reporting. Every abort-and-report point maps to `halted`, leaves the PR open and the change `implemented`, and records the `## Finalize blocked` marker via the `finalize.block` operation (comment first, then the single upserted section; in a private repository no PR comment is posted; the marker is recorded on the change only); the `finalize.clear-block` operation removes it after a successful reprobe. The marker is a visible note: it never stops selection or merge, so the next run retries the change. ## Dispatch unavailability — no fallback 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 e0693b513..0d768c970 100644 --- a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md @@ -58,7 +58,7 @@ 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 <metadata_worktree_path> --request <request-file>` — 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. +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 <metadata_worktree_path> --request <request-file>` — 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 a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references. 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 <metadata_worktree_path> --request <request-file>` 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 <metadata_worktree_path> --request <request-file>` 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. 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 1fc7507fc..d2f25ff57 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -81,6 +81,7 @@ the metadata branch only receives bookkeeping commits — it NEVER determines wh **Plan authoring (dispatched).** The parent stays the orchestrator. Dispatch `docket-plan-writer` foreground at the model/effort its wrapper resolves. Its payload supplies, so the child rediscovers nothing: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> 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) `<changes_dir>/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 <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <file>` — it writes the plan with its backlink on `docket` and sets `plan:` in one commit — and returns the single success line `PLAN_PATH=<repo-relative-path>`. <!-- docket:feature-dispatch:end --> @@ -90,7 +91,7 @@ It also supplies the change id, title, exact record revision, synchronized chang ### Step 5 — Build -**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns, at a revision re-read after a re-sync (a build checkpoint moves the record) — a refresh `contended` stops a new invocation but never cancels a build already running. The build role — `docket-build` — is invoked **DIRECTED to:** execute the plan task-by-task and stop at the executed plan. `docket-build` is a skill to invoke, never a same-name agent to dispatch (convention, *Skill layer*): the invoked skill directs the controller to dispatch one named build-tier worker per plan task and gates on one full-suite run. A rejected attempt to dispatch a same-name `docket-build` agent is the wrong operation, never capability evidence — resume at the skill invocation; no metadata or worktree state changed because that probe was refused. **Proceed through the build — the deliverable is the executed plan, never the decision about how to execute it.** Separately, and without ever relaxing the first: answer any choice it poses from resolved config, surface none, and log one line naming the role and skill if you suppressed a hand-off. Emitting that log line discharges the suppression obligation only; the step is not complete until its git-state postcondition holds — see *Step postconditions*. When `docket-build` cannot be invoked, apply the convention's missing-skill rule: execute the plan inline on the feature branch, warning prominently — the artifact is the executed plan. If the invoked skill's **required nested dispatch** — a build-tier worker — is unavailable (established only per the convention's *Dispatch-capability resolution* — never from a tool name), the build role is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. When the build role returns `halted`, the `## Run halted` report carries its halt report's gate evidence verbatim, a `tree-survives` finding included — information for the human, never a different disposition. +**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns, at a revision re-read after a re-sync (a build checkpoint moves the record) — a refresh `contended` stops a new invocation but never cancels a build already running. The build role — `docket-build` — is invoked **DIRECTED to:** execute the plan task-by-task and stop at the executed plan, carrying the prepare context's `visibility` so every worker payload names its `Visibility`. `docket-build` is a skill to invoke, never a same-name agent to dispatch (convention, *Skill layer*): the invoked skill directs the controller to dispatch one named build-tier worker per plan task and gates on one full-suite run. A rejected attempt to dispatch a same-name `docket-build` agent is the wrong operation, never capability evidence — resume at the skill invocation; no metadata or worktree state changed because that probe was refused. **Proceed through the build — the deliverable is the executed plan, never the decision about how to execute it.** Separately, and without ever relaxing the first: answer any choice it poses from resolved config, surface none, and log one line naming the role and skill if you suppressed a hand-off. Emitting that log line discharges the suppression obligation only; the step is not complete until its git-state postcondition holds — see *Step postconditions*. When `docket-build` cannot be invoked, apply the convention's missing-skill rule: execute the plan inline on the feature branch, warning prominently — the artifact is the executed plan. If the invoked skill's **required nested dispatch** — a build-tier worker — is unavailable (established only per the convention's *Dispatch-capability resolution* — never from a tool name), the build role is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. When the build role returns `halted`, the `## Run halted` report carries its halt report's gate evidence verbatim, a `tree-survives` finding included — information for the human, never a different disposition. ### Step 6 — Review + ADRs @@ -137,10 +138,12 @@ The results artifact is **required for every change, trivial included** — ther **Publish the feature head.** The `workspace.publish` operation with `--id <id> --head <feature head>` inspects the owned workspace and pushes the exact head through the landed non-forcing publication, returning the verified remote head. It refuses when the reinspected head differs from `--head`; a divergent remote ref is `contended`, unknown post-effect state is `unknown` (reprobe on retry) — neither forces, and no PR opens here. -**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), and the review disposition table; `pr.publish` adds the back-link line and the plan/results links block. The PR body carries no build-evidence block. Before assembling it, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics. +**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), and the review disposition table; `pr.publish` adds the back-link line and the plan/results links block. The PR body carries no build-evidence block. In a private repository the authored title and body follow the private-repository writing rule: no `#<issue>` reference, no change id in the title, and `pr.publish` publishes the body verbatim. Before assembling it, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics. **Publish the PR.** With the authored title+body and the verified evidence bytes each in a request file, the `pr.publish` operation with `--id <id> --head <feature head> --body <request-file> --evidence <evidence request-file>` reparses the evidence (never a prior result) and verifies local HEAD == published remote head == evidence head == requested head, and that the feature branch, effective-base branch, and repository identity agree, BEFORE calling the landed GitHub adapter; it then inserts or replaces ONLY the Docket-owned backlink and plan/results links blocks (authored prose byte-preserved) and returns the canonical PR reference, URL, number, head, base, and disposition. `unknown` stays `unknown` — reprobe, never a duplicate PR — and the result redacts the body bytes. It refuses `record-invalid` (`invalid-state`) before any GitHub call when the change, a `depends_on` target, or a stack ancestor carries a validation error (associative links are never checked): no PR was created or edited — repair exactly the records the result's `findings` name, then re-publish; the run keeps its existing halt posture for a typed refusal. +**Leak check (private repositories).** `workspace.publish` and `pr.publish` refuse `leak-detected` (`blocked`) when anything they would expose carries a docket fingerprint; the result's `leaks` list names each hit (source, commit, file, line, rule, matched text). Nothing was pushed or opened. End the run `halted`: write the hit list into the `change.halt` report — never reword, amend, rebase, or force-push to get past it, and never retry. `leak-check-unverified` (`external-failed`) means the check could not run; treat it as any external failure. `metadata-on-shared-remote` warnings go in the run report only. + **Mark implemented.** The `change.mark-implemented` operation with `--id <id> --revision <revision> --head <feature head> --pr <reference> --evidence <evidence request-file>` is the final mutation in this scope. Before its transaction it reprobes Git and GitHub and proves: the change is still the exact `in-progress` revision, `reconciled: true`, linked to the verified plan; local and remote feature heads equal the supplied head; valid evidence names that head and a passed gate; exactly one verified PR for the feature branch targets the resolved effective-base branch and names that head; and a results artifact is attached, read from the metadata branch, and satisfies the final content contract and its artifact identity (missing, invalid, or unattached results — trivial changes included — refuse the transition). It then applies the landed transition atomically — `status: implemented` + `pr:` + updated date + the verified evidence as the record's `## Build evidence` section + `## Artifacts` block + inline board + audit receipt (letting the sweep read `pr:`), rendering the board inside that transaction, so no separate Board pass runs. It does NOT clear the claim, delete the branch/workspace, merge, archive, or close descendants. A retry against an already-`implemented` change with matching PR/head returns the prior applied outcome, never a duplicate transition. **Verify the run.** The `run.verify` operation with `--id <id>` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet condition with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting <change-id> <handoff-id> <phase>` is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halted` stays terminal. A run **dispatched with an explicit change id, continuation id, and run key resumes, not restarts**: its FIRST act is the `run.continue` operation with `<key> <continuation-id>`, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `run-retry-once`** — the key stays active until a true terminal disposition. Each `run-retry-once` authorizes exactly one next dispatch; the run tracker may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **run context** token; pass it, always as `--run-context`, into every build-owned `gate.drive.start` (`--owner build` — Step 6's evidence re-mint and re-gates, the build role's final suite gate, and each post-repair attempt the build role starts) this run performs — each invoked with `--json` per the shared capture requirement and each always carrying `--change-id <id>` — and into the Step-2 claim, omitting it only when the prompt carried none (a `run-untracked` or ungated run). Build-task workers never receive it: they run their tests directly and call no gate operation. 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 13ee3756a..e39198644 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 @@ -100,10 +100,10 @@ to force a workspace, a commit, or a HEAD move. ## PR-body assembly (Step 7) -**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. +**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. None in a private repository (private-repository writing rule); the body is published verbatim. -**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. +**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. None in a private repository (private-repository writing rule); the body is published verbatim. **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 metadata 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. None in a private repository (private-repository writing rule); the body is published verbatim. diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/references/fix-pass.md b/internal/assets/embedded/tree/skills/docket-implement-next/references/fix-pass.md index 967c87bca..cdda517fc 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/references/fix-pass.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/references/fix-pass.md @@ -83,7 +83,9 @@ three** suite runs across Step 6; the bound below is scoped to the gate and is u Every fix runs the **`docket-build-task`** contract (focused test → implement → verify → self-review → one commit), dispatched by tier name, **foreground and sequential** — fixes share -one worktree, so two concurrent workers would collide. +one worktree, so two concurrent workers would collide. The fix-worker payload carries `Visibility` +(the prepare context's `visibility`); in a private repository fix commits follow the +private-repository writing rule. A fix worker that returns without a schema-valid outcome may still be **running**: never discard the worktree and dispatch a fresh worker for that finding, however dead the first one looks. Halt 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 f501f7adb..54998bca8 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 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. +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 (in a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references) — 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/harness/claude/testdata/golden/docket-plan-writer.md b/internal/harness/claude/testdata/golden/docket-plan-writer.md index 2a1390ffb..1bdf6ec2c 100644 --- a/internal/harness/claude/testdata/golden/docket-plan-writer.md +++ b/internal/harness/claude/testdata/golden/docket-plan-writer.md @@ -5,7 +5,9 @@ description: 'Internal plan-writing agent for docket-implement-next Step 4 — i You are already running as `docket-plan-writer`. Carry out this wrapper's assigned charter directly. Do not dispatch another `docket-plan-writer` merely to perform the current assignment. Dispatches to different agents explicitly required by the active charter remain required. -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. diff --git a/internal/harness/codex/testdata/golden/docket-plan-writer.toml b/internal/harness/codex/testdata/golden/docket-plan-writer.toml index 2daab9896..fb4fa77a4 100644 --- a/internal/harness/codex/testdata/golden/docket-plan-writer.toml +++ b/internal/harness/codex/testdata/golden/docket-plan-writer.toml @@ -7,7 +7,9 @@ Before any read or write, locate the `Feature worktree: <absolute-path>` input, When your active charter requires another agent, inspect the registered target's description markers. `[docket launch: root-coordinator]` takes precedence: enter it as a foreground root thread through catalog-resolved `agent.enter` at the caller cwd. Otherwise `[docket worktree: feature]` requires foreground catalog-resolved `agent.enter` with the owning workflow's exact `--worktree`; an unmarked metadata child uses direct native named-agent dispatch. Nested orchestration inventories — tool lists read from inside another tool — omit top-level collaboration controls, so absence from such a nested inventory cannot establish dispatch unavailability; only a failed direct dispatch attempt or an explicit policy denial does. Never substitute `codex exec`, a shell runner, another harness, a generic agent, or a relay. -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. diff --git a/internal/harness/cursor/testdata/golden/docket-plan-writer.md b/internal/harness/cursor/testdata/golden/docket-plan-writer.md index 45e8cc1df..2961752f9 100644 --- a/internal/harness/cursor/testdata/golden/docket-plan-writer.md +++ b/internal/harness/cursor/testdata/golden/docket-plan-writer.md @@ -5,7 +5,9 @@ description: 'Internal plan-writing agent for docket-implement-next Step 4 — i You are already running as `docket-plan-writer`. Carry out this wrapper's assigned charter directly. Do not dispatch another `docket-plan-writer` merely to perform the current assignment. Dispatches to different agents explicitly required by the active charter remain required. -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. diff --git a/internal/harness/opencode/testdata/golden/docket-plan-writer.md b/internal/harness/opencode/testdata/golden/docket-plan-writer.md index f752f1346..cb139a8ad 100644 --- a/internal/harness/opencode/testdata/golden/docket-plan-writer.md +++ b/internal/harness/opencode/testdata/golden/docket-plan-writer.md @@ -5,7 +5,9 @@ mode: subagent You are already running as `docket-plan-writer`. Carry out this wrapper's assigned charter directly. Do not dispatch another `docket-plan-writer` merely to perform the current assignment. Dispatches to different agents explicitly required by the active charter remain required. -You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; and whether learnings are enabled and, when enabled, the learnings index path. +You are docket's plan writer. You are dispatched by `docket-implement-next` (Step 4) with everything already resolved — you parse no configuration and perform no discovery. Your dispatch payload names: the change id, title, exact record revision, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; the repository's visibility (`Visibility: <shared|private>`); and whether learnings are enabled and, when enabled, the learnings index path. + +In a private repository, the commit messages, code, comments, and test names the plan prescribes follow the private-repository writing rule; the plan itself is metadata and may name the change. You own exactly one durable artifact: the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan`. You write nothing in either worktree; you perform no other Docket metadata mutation, board update, or status transition. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 7a93a5972..ebc2c75a1 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -187,31 +187,31 @@ type skillBudget struct { // exact new counts. var skillBudgets = []skillBudget{ {"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, 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) + {"docket-auto-groom/SKILL.md", 64, 1638}, // 0532: the private-repository writing rule and visibility hand-off (64/1606 -> 64/1638); 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", 83, 722}, // 0532: the private-repository writing rule and visibility hand-off (81/690 -> 83/722); 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", 386, 3878}, // 0532: the private-repository writing rule and visibility hand-off (384/3852 -> 386/3878); 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, 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, 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) + {"docket-build-task/SKILL.md", 173, 1695}, // 0532: the private-repository writing rule and visibility hand-off (168/1640 -> 173/1695); 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", 361, 7263}, // 0532: the private-repository writing rule and visibility hand-off (359/7160 -> 361/7263); 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) {"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/<name>.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/SKILL.md", 239, 6020}, // 0532: the private-repository writing rule and visibility hand-off (239/6000 -> 239/6020); 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, 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-groom-next/SKILL.md", 76, 2141}, // 0532: the private-repository writing rule and visibility hand-off (76/2109 -> 76/2141); 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", 215, 8411}, // 0532: the private-repository writing rule and visibility hand-off (212/8282 -> 215/8411); 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, 1265}, // 0532: the private-repository writing rule and visibility hand-off (109/1226 -> 109/1265); 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", 189, 1963}, // 0532: the private-repository writing rule and visibility hand-off (187/1943 -> 189/1963); 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, 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-new-change/SKILL.md", 57, 1635}, // 0532: the private-repository writing rule and visibility hand-off (57/1603 -> 57/1635); 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) } diff --git a/internal/repoguard/private_writing_rule_test.go b/internal/repoguard/private_writing_rule_test.go new file mode 100644 index 000000000..4401657f5 --- /dev/null +++ b/internal/repoguard/private_writing_rule_test.go @@ -0,0 +1,104 @@ +package repoguard + +import ( + "regexp" + "strings" + "testing" +) + +// TestPrivateWritingRuleReachesEveryWriter guards the private-repository writing +// rule's prose. The rule is stated once, in docket-convention's *Branch model*, +// and every skill or agent that writes something that ships through the feature +// branch or the PR (commit messages, code, specs, PR text) names it. The two +// producer paragraphs that act on a `leak-detected` refusal must also say how the +// run records it: implement-next through `change.halt`, finalize through +// `finalize.block`. +// +// Matching is case-insensitive with whitespace runs collapsed, so a hard wrap +// inside the phrase still matches. + +const ( + privateRuleAnchor = "**Private-repository writing rule.**" + privateRulePhrase = "private-repository writing rule" + privateRuleWindow = 600 +) + +// privateRuleWriters are the files that hand work to something that writes +// shipped text. Each must name the rule. +var privateRuleWriters = []string{ + "skills/docket-build-task/SKILL.md", + "skills/docket-build/SKILL.md", + "skills/docket-implement-next/SKILL.md", + "skills/docket-implement-next/references/fix-pass.md", + "skills/docket-implement-next/references/edge-paths.md", + "skills/docket-new-change/SKILL.md", + "skills/docket-groom-next/SKILL.md", + "skills/docket-auto-groom/SKILL.md", + "skills/docket-brainstorm/SKILL.md", + "agents/docket-plan-writer.md", +} + +// privateRuleProducers are the paragraphs that turn a leak-detected refusal into +// a recorded stop. Each needs one blank-line paragraph holding every token. +var privateRuleProducers = []struct { + file string + tokens []string +}{ + {"skills/docket-implement-next/SKILL.md", []string{"leak-detected", "change.halt"}}, + {"skills/docket-finalize-change/SKILL.md", []string{"leak-detected", "finalize.block"}}, +} + +var blankLineSplit = regexp.MustCompile(`\n[ \t]*\n`) + +func normalizeProse(s string) string { + return strings.ToLower(wsRun.ReplaceAllString(s, " ")) +} + +// paragraphWithAll reports whether some blank-line-separated paragraph of +// content contains every token verbatim. +func paragraphWithAll(content string, tokens []string) bool { + for _, para := range blankLineSplit.Split(content, -1) { + all := true + for _, tok := range tokens { + if !strings.Contains(para, tok) { + all = false + break + } + } + if all { + return true + } + } + return false +} + +func TestPrivateWritingRuleReachesEveryWriter(t *testing.T) { + conv := readRepoFile(t, "skills/docket-convention/SKILL.md") + if n := strings.Count(conv, privateRuleAnchor); n != 1 { + t.Errorf("docket-convention: %q appears %d times, want exactly 1", privateRuleAnchor, n) + } else { + i := strings.Index(conv, privateRuleAnchor) + end := min(i+len(privateRuleAnchor)+privateRuleWindow, len(conv)) + window := normalizeProse(conv[i:end]) + for _, claim := range []string{"change ids", "metadata branch"} { + if !strings.Contains(window, claim) { + t.Errorf("docket-convention: the writing rule's first %d bytes do not name %q", privateRuleWindow, claim) + } + } + } + + if len(privateRuleWriters) < 10 { + t.Fatalf("writer table shrank to %d rows, floor is 10", len(privateRuleWriters)) + } + for _, rel := range privateRuleWriters { + if !strings.Contains(normalizeProse(readRepoFile(t, rel)), privateRulePhrase) { + t.Errorf("%s does not name the %s", rel, privateRulePhrase) + } + } + + for _, p := range privateRuleProducers { + if !paragraphWithAll(readRepoFile(t, p.file), p.tokens) { + t.Errorf("%s: no paragraph holds all of %q", p.file, p.tokens) + } + } +} diff --git a/skills/docket-auto-groom/SKILL.md b/skills/docket-auto-groom/SKILL.md index a334ef84a..8625279a3 100644 --- a/skills/docket-auto-groom/SKILL.md +++ b/skills/docket-auto-groom/SKILL.md @@ -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 `<changes_dir>/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. +Read the stub body, its `related`/`depends_on` neighbours (active + recently archived), the ADR index, and the relevant code. Read the learnings index `<changes_dir>/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. In a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references. 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 diff --git a/skills/docket-brainstorm/SKILL.md b/skills/docket-brainstorm/SKILL.md index c17bb4791..b95e3eced 100644 --- a/skills/docket-brainstorm/SKILL.md +++ b/skills/docket-brainstorm/SKILL.md @@ -46,7 +46,9 @@ that). Hand it: - relevant learnings findings, drawn from the learnings index; - the compact brief: the spec path and expected format, the PM-altitude boundary (design detail belongs in the spec; intent and scope stay in the change), and the - requirement for an explicit `## Assumptions` section. + requirement for an explicit `## Assumptions` section. In a private repository the spec ships + with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` + line, no change or ADR numbers, no backlog references. The consultant performs zero docket operations (no git, no status writes, no board) and returns **in-context** exactly one of: diff --git a/skills/docket-build-task/SKILL.md b/skills/docket-build-task/SKILL.md index 19b86040d..d679153b6 100644 --- a/skills/docket-build-task/SKILL.md +++ b/skills/docket-build-task/SKILL.md @@ -133,6 +133,11 @@ A task produces a commit **only on success** — `COMPLETE` means focused verifi If the **task text itself** prescribes more than one commit, the plan wins over this default: follow the task and report every SHA in your return. +In a private repository (the dispatch payload says `Visibility: private`), every commit message, +code line, comment, and test name follows the convention's private-repository writing rule; never +copy a change id or docket wording from the plan. When the task cannot be done without breaking it, +return `BLOCKED` naming the conflict — never commit the violation. + ## Outcomes Return exactly one of three outcomes. A missing or malformed outcome halts the build, so state it diff --git a/skills/docket-build/SKILL.md b/skills/docket-build/SKILL.md index 298772357..4eda2e388 100644 --- a/skills/docket-build/SKILL.md +++ b/skills/docket-build/SKILL.md @@ -79,7 +79,9 @@ Dispatch the selected tier agent **by name** — one of `docket-build-economy`, time; later tasks build on earlier task commits and share the worktree, so workers are strictly sequential. Its dispatch payload contains: Feature worktree: <absolute canonical feature-worktree root> -It also gives the worker the branch name, the plan task text, applicable repository instructions, selected +Visibility: <shared|private> +The `Visibility` value is the `repository.prepare` context's `visibility`; in a private repository +the plan's suggested commit messages are held to the private-repository writing rule. It also gives the worker the branch name, the plan task text, applicable repository instructions, selected tier and routing reason, and the return schema. No run context or capability goes into a worker prompt: the worker runs its tests directly and calls no gate operation. Never dispatch a task reviewer, and never dispatch two workers concurrently — that binds a controller who *believes the first worker diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index a31d0b404..0d6976f55 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -74,7 +74,7 @@ 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 <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. +3. **Prepare.** Resolve the `repository.prepare` entry from the catalog and run its argv with `--repo-dir <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 repository's `visibility` (`shared` | `private`), 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. @@ -352,6 +352,8 @@ Metadata (change files, `BOARD.md`, ADRs, specs) commits to the metadata branch 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`. +**Private-repository writing rule.** In a private repository (`visibility: private` in the `repository.prepare` context), nothing that ships through the feature branch or the PR may contain the words "docket" or "dckt", docket marker comments, `Docket-` trailers, `.docket`/`dckt` paths, change ids or change references (`change 0612`, `#0612`, `(0612)`), docket ADR numbers, or references to the backlog or board. The metadata branch's own files (plan, results, records) are exempt and must not be copied from. `workspace.publish`, `pr.publish`, and `finalize.publish` enforce it with a blocking leak check (`leak-detected`); `leak_check.match_word: false` lets the bare word "docket" through. A shared repository is unaffected. + 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. diff --git a/skills/docket-finalize-change/SKILL.md b/skills/docket-finalize-change/SKILL.md index 77eda9238..3aba56ade 100644 --- a/skills/docket-finalize-change/SKILL.md +++ b/skills/docket-finalize-change/SKILL.md @@ -133,7 +133,7 @@ A pass with **no** authored repair (an exact-head-evidence skip, or a clean firs ### 7. Publish the rebased head -The `finalize.publish` operation with `--id <id> --attempt <attempt> --head <head> --evidence <evidence file>`. It probes the remote first (a no-op when already at `head`), pushes exactly `head` under the receipt's exact old-value lease, then records that head's evidence in the change record's `## Build evidence` section. The PR body is not touched. It never creates a second PR. A reprobe `unknown` returns `rewrite-unknown`/`pr-probe-failed` and stops with no second mutation (`halted`); a moved remote returns `rewrite-contended` (re-read context, `contended`); an attempt token not matching the receipt is refused before any push. +The `finalize.publish` operation with `--id <id> --attempt <attempt> --head <head> --evidence <evidence file>`. It probes the remote first (a no-op when already at `head`), pushes exactly `head` under the receipt's exact old-value lease, then records that head's evidence in the change record's `## Build evidence` section. The PR body is not touched. It never creates a second PR. A reprobe `unknown` returns `rewrite-unknown`/`pr-probe-failed` and stops with no second mutation (`halted`); a moved remote returns `rewrite-contended` (re-read context, `contended`); an attempt token not matching the receipt is refused before any push. `leak-detected` (`blocked`): record it with `finalize.block` (reason `leak-detected`, the hit list as the report) and stop `halted`; never reword or force-push past it. ### 8. Merge exactly once @@ -232,7 +232,7 @@ After a successful relink, **reload and re-probe from scratch** — run the `con ## Abort and the blocked marker -The full abort-and-report set, the two-agent split, the green-repair rule, and the `## Finalize blocked` marker's write shape and lifecycle live in **`references/gate-failure.md`** — **read it at any abort** (a conflict, a red gate, an unavailable dispatch, a denied merge) before recording or reporting. Every abort-and-report point maps to `halted`, leaves the PR open and the change `implemented`, and records the `## Finalize blocked` marker via the `finalize.block` operation (comment first, then the single upserted section); the `finalize.clear-block` operation removes it after a successful reprobe. The marker is a visible note: it never stops selection or merge, so the next run retries the change. +The full abort-and-report set, the two-agent split, the green-repair rule, and the `## Finalize blocked` marker's write shape and lifecycle live in **`references/gate-failure.md`** — **read it at any abort** (a conflict, a red gate, an unavailable dispatch, a denied merge) before recording or reporting. Every abort-and-report point maps to `halted`, leaves the PR open and the change `implemented`, and records the `## Finalize blocked` marker via the `finalize.block` operation (comment first, then the single upserted section; in a private repository no PR comment is posted; the marker is recorded on the change only); the `finalize.clear-block` operation removes it after a successful reprobe. The marker is a visible note: it never stops selection or merge, so the next run retries the change. ## Dispatch unavailability — no fallback diff --git a/skills/docket-groom-next/SKILL.md b/skills/docket-groom-next/SKILL.md index e0693b513..0d768c970 100644 --- a/skills/docket-groom-next/SKILL.md +++ b/skills/docket-groom-next/SKILL.md @@ -58,7 +58,7 @@ 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 <metadata_worktree_path> --request <request-file>` — 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. +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 <metadata_worktree_path> --request <request-file>` — 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 a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references. 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 <metadata_worktree_path> --request <request-file>` 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 <metadata_worktree_path> --request <request-file>` 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. diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index 1fc7507fc..d2f25ff57 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -81,6 +81,7 @@ the metadata branch only receives bookkeeping commits — it NEVER determines wh **Plan authoring (dispatched).** The parent stays the orchestrator. Dispatch `docket-plan-writer` foreground at the model/effort its wrapper resolves. Its payload supplies, so the child rediscovers nothing: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> 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) `<changes_dir>/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 <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <file>` — it writes the plan with its backlink on `docket` and sets `plan:` in one commit — and returns the single success line `PLAN_PATH=<repo-relative-path>`. <!-- docket:feature-dispatch:end --> @@ -90,7 +91,7 @@ It also supplies the change id, title, exact record revision, synchronized chang ### Step 5 — Build -**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns, at a revision re-read after a re-sync (a build checkpoint moves the record) — a refresh `contended` stops a new invocation but never cancels a build already running. The build role — `docket-build` — is invoked **DIRECTED to:** execute the plan task-by-task and stop at the executed plan. `docket-build` is a skill to invoke, never a same-name agent to dispatch (convention, *Skill layer*): the invoked skill directs the controller to dispatch one named build-tier worker per plan task and gates on one full-suite run. A rejected attempt to dispatch a same-name `docket-build` agent is the wrong operation, never capability evidence — resume at the skill invocation; no metadata or worktree state changed because that probe was refused. **Proceed through the build — the deliverable is the executed plan, never the decision about how to execute it.** Separately, and without ever relaxing the first: answer any choice it poses from resolved config, surface none, and log one line naming the role and skill if you suppressed a hand-off. Emitting that log line discharges the suppression obligation only; the step is not complete until its git-state postcondition holds — see *Step postconditions*. When `docket-build` cannot be invoked, apply the convention's missing-skill rule: execute the plan inline on the feature branch, warning prominently — the artifact is the executed plan. If the invoked skill's **required nested dispatch** — a build-tier worker — is unavailable (established only per the convention's *Dispatch-capability resolution* — never from a tool name), the build role is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. When the build role returns `halted`, the `## Run halted` report carries its halt report's gate evidence verbatim, a `tree-survives` finding included — information for the human, never a different disposition. +**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns, at a revision re-read after a re-sync (a build checkpoint moves the record) — a refresh `contended` stops a new invocation but never cancels a build already running. The build role — `docket-build` — is invoked **DIRECTED to:** execute the plan task-by-task and stop at the executed plan, carrying the prepare context's `visibility` so every worker payload names its `Visibility`. `docket-build` is a skill to invoke, never a same-name agent to dispatch (convention, *Skill layer*): the invoked skill directs the controller to dispatch one named build-tier worker per plan task and gates on one full-suite run. A rejected attempt to dispatch a same-name `docket-build` agent is the wrong operation, never capability evidence — resume at the skill invocation; no metadata or worktree state changed because that probe was refused. **Proceed through the build — the deliverable is the executed plan, never the decision about how to execute it.** Separately, and without ever relaxing the first: answer any choice it poses from resolved config, surface none, and log one line naming the role and skill if you suppressed a hand-off. Emitting that log line discharges the suppression obligation only; the step is not complete until its git-state postcondition holds — see *Step postconditions*. When `docket-build` cannot be invoked, apply the convention's missing-skill rule: execute the plan inline on the feature branch, warning prominently — the artifact is the executed plan. If the invoked skill's **required nested dispatch** — a build-tier worker — is unavailable (established only per the convention's *Dispatch-capability resolution* — never from a tool name), the build role is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. When the build role returns `halted`, the `## Run halted` report carries its halt report's gate evidence verbatim, a `tree-survives` finding included — information for the human, never a different disposition. ### Step 6 — Review + ADRs @@ -137,10 +138,12 @@ The results artifact is **required for every change, trivial included** — ther **Publish the feature head.** The `workspace.publish` operation with `--id <id> --head <feature head>` inspects the owned workspace and pushes the exact head through the landed non-forcing publication, returning the verified remote head. It refuses when the reinspected head differs from `--head`; a divergent remote ref is `contended`, unknown post-effect state is `unknown` (reprobe on retry) — neither forces, and no PR opens here. -**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), and the review disposition table; `pr.publish` adds the back-link line and the plan/results links block. The PR body carries no build-evidence block. Before assembling it, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics. +**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), and the review disposition table; `pr.publish` adds the back-link line and the plan/results links block. The PR body carries no build-evidence block. In a private repository the authored title and body follow the private-repository writing rule: no `#<issue>` reference, no change id in the title, and `pr.publish` publishes the body verbatim. Before assembling it, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics. **Publish the PR.** With the authored title+body and the verified evidence bytes each in a request file, the `pr.publish` operation with `--id <id> --head <feature head> --body <request-file> --evidence <evidence request-file>` reparses the evidence (never a prior result) and verifies local HEAD == published remote head == evidence head == requested head, and that the feature branch, effective-base branch, and repository identity agree, BEFORE calling the landed GitHub adapter; it then inserts or replaces ONLY the Docket-owned backlink and plan/results links blocks (authored prose byte-preserved) and returns the canonical PR reference, URL, number, head, base, and disposition. `unknown` stays `unknown` — reprobe, never a duplicate PR — and the result redacts the body bytes. It refuses `record-invalid` (`invalid-state`) before any GitHub call when the change, a `depends_on` target, or a stack ancestor carries a validation error (associative links are never checked): no PR was created or edited — repair exactly the records the result's `findings` name, then re-publish; the run keeps its existing halt posture for a typed refusal. +**Leak check (private repositories).** `workspace.publish` and `pr.publish` refuse `leak-detected` (`blocked`) when anything they would expose carries a docket fingerprint; the result's `leaks` list names each hit (source, commit, file, line, rule, matched text). Nothing was pushed or opened. End the run `halted`: write the hit list into the `change.halt` report — never reword, amend, rebase, or force-push to get past it, and never retry. `leak-check-unverified` (`external-failed`) means the check could not run; treat it as any external failure. `metadata-on-shared-remote` warnings go in the run report only. + **Mark implemented.** The `change.mark-implemented` operation with `--id <id> --revision <revision> --head <feature head> --pr <reference> --evidence <evidence request-file>` is the final mutation in this scope. Before its transaction it reprobes Git and GitHub and proves: the change is still the exact `in-progress` revision, `reconciled: true`, linked to the verified plan; local and remote feature heads equal the supplied head; valid evidence names that head and a passed gate; exactly one verified PR for the feature branch targets the resolved effective-base branch and names that head; and a results artifact is attached, read from the metadata branch, and satisfies the final content contract and its artifact identity (missing, invalid, or unattached results — trivial changes included — refuse the transition). It then applies the landed transition atomically — `status: implemented` + `pr:` + updated date + the verified evidence as the record's `## Build evidence` section + `## Artifacts` block + inline board + audit receipt (letting the sweep read `pr:`), rendering the board inside that transaction, so no separate Board pass runs. It does NOT clear the claim, delete the branch/workspace, merge, archive, or close descendants. A retry against an already-`implemented` change with matching PR/head returns the prior applied outcome, never a duplicate transition. **Verify the run.** The `run.verify` operation with `--id <id>` is read-only: it reports one closed verdict — `run-complete` / `run-unclaimed` / `run-incomplete` / `run-halted` / `run-waiting` — and enumerates every unmet condition with its stable reason and observed identity. Automation keys on the typed verdict, **never** the exit code (all verdicts exit 0). `run-complete` is the receipt that Step 7's postcondition holds; `run-halted` is the closed verdict for a change carrying a `## Run halted` marker — a human is needed, and an attributed caller re-dispatches it through the resume path (the `change.resume-halted` operation with `--acknowledge-quiescent`, Step 2), never a fresh claim. `run-waiting <change-id> <handoff-id> <phase>` is the closed verdict for a change whose gate stopped at a safe, resumable continuation — neither complete nor failed: an attributed caller resumes that exact handoff rather than drawing another change or re-dispatching a fresh run, and precedence is completed-run postconditions first, then a valid `run-waiting`, then ordinary `run-incomplete`; a persisted `run-halted` stays terminal. A run **dispatched with an explicit change id, continuation id, and run key resumes, not restarts**: its FIRST act is the `run.continue` operation with `<key> <continuation-id>`, claiming the recovered drive **before any other work**; it never launches a replacement test, and this is a **continuation, not `run-retry-once`** — the key stays active until a true terminal disposition. Each `run-retry-once` authorizes exactly one next dispatch; the run tracker may grant another on a later eligible attempt while the configured `run.max_attempts` budget allows, and a continuation consumes none of that budget. A gated parent's prompt may also carry a **run context** token; pass it, always as `--run-context`, into every build-owned `gate.drive.start` (`--owner build` — Step 6's evidence re-mint and re-gates, the build role's final suite gate, and each post-repair attempt the build role starts) this run performs — each invoked with `--json` per the shared capture requirement and each always carrying `--change-id <id>` — and into the Step-2 claim, omitting it only when the prompt carried none (a `run-untracked` or ungated run). Build-task workers never receive it: they run their tests directly and call no gate operation. diff --git a/skills/docket-implement-next/references/edge-paths.md b/skills/docket-implement-next/references/edge-paths.md index 13ee3756a..e39198644 100644 --- a/skills/docket-implement-next/references/edge-paths.md +++ b/skills/docket-implement-next/references/edge-paths.md @@ -100,10 +100,10 @@ to force a workspace, a commit, or a HEAD move. ## PR-body assembly (Step 7) -**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. +**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. None in a private repository (private-repository writing rule); the body is published verbatim. -**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. +**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. None in a private repository (private-repository writing rule); the body is published verbatim. **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 metadata 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. None in a private repository (private-repository writing rule); the body is published verbatim. diff --git a/skills/docket-implement-next/references/fix-pass.md b/skills/docket-implement-next/references/fix-pass.md index 967c87bca..cdda517fc 100644 --- a/skills/docket-implement-next/references/fix-pass.md +++ b/skills/docket-implement-next/references/fix-pass.md @@ -83,7 +83,9 @@ three** suite runs across Step 6; the bound below is scoped to the gate and is u Every fix runs the **`docket-build-task`** contract (focused test → implement → verify → self-review → one commit), dispatched by tier name, **foreground and sequential** — fixes share -one worktree, so two concurrent workers would collide. +one worktree, so two concurrent workers would collide. The fix-worker payload carries `Visibility` +(the prepare context's `visibility`); in a private repository fix commits follow the +private-repository writing rule. A fix worker that returns without a schema-valid outcome may still be **running**: never discard the worktree and dispatch a fresh worker for that finding, however dead the first one looks. Halt diff --git a/skills/docket-new-change/SKILL.md b/skills/docket-new-change/SKILL.md index f501f7adb..54998bca8 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 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. +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 (in a private repository the spec ships with the PR, so its body follows the private-repository writing rule: no `Change #N, groomed …` line, no change or ADR numbers, no backlog references) — 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. From 1ea8023cf26f5b7dd3254b152d7b708d5ce16f88 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:45:58 -0400 Subject: [PATCH 11/13] fix(gitcli): leak-scan every outgoing commit's added lines, not only the net diff Review finding: ReadOutgoing took added lines and paths only from the three-dot diff base...head, so a fingerprint one outgoing commit adds and a later commit removes was pushed (and visible in the PR's commits, carried by a rebase-merge) yet never scanned. ReadOutgoing now also reads the added paths and lines of every non-merge commit in base..head (diff-tree against its parent, with the same pinned diff options), attributing each to its commit. The three-dot diff stays, so merge-introduced content is still scanned and merged-in upstream content is still excluded; a three-dot entry a commit already adds is reported once. The commit rides through leakscan into each leak hit and the refusal message. --- internal/app/leak_check.go | 26 ++- .../leak_check_private_integration_test.go | 19 ++ internal/app/leak_check_test.go | 15 +- internal/app/pr_publish.go | 4 +- internal/gitcli/outgoing.go | 174 +++++++++++++++--- internal/gitcli/outgoing_integration_test.go | 119 +++++++++++- internal/gitcli/outgoing_test.go | 68 +++++++ internal/leakscan/leakscan.go | 28 +-- internal/leakscan/leakscan_test.go | 7 +- 9 files changed, 394 insertions(+), 66 deletions(-) diff --git a/internal/app/leak_check.go b/internal/app/leak_check.go index 9f2cb088a..0e5f22421 100644 --- a/internal/app/leak_check.go +++ b/internal/app/leak_check.go @@ -14,8 +14,8 @@ import ( // This file is the private-repository leak check. Before a push or PR write in a // private repository, everything that write would expose — the outgoing commit -// messages, the added paths and lines of the merge-base diff, and any PR title -// and body — is scanned for docket fingerprints (internal/leakscan), and any hit +// messages, the paths and lines each outgoing commit adds and the merge-base +// diff adds, and any PR title and body — is scanned for docket fingerprints (internal/leakscan), and any hit // refuses the write with nothing published. A shared repository never runs it. // A failure to fetch the base or read the outgoing set is leak-check-unverified, // never a clean scan. Each caller scans the exact head it publishes. @@ -32,8 +32,8 @@ const ( ) // LeakHit is one fingerprint a publish would expose. Commit is set for a -// commit-message hit and File for an added line or path; Line is 1-based (0 for -// a path). Text is the matched text only, never the surrounding line, so a hit +// commit-message hit, and for an added line or path that a known outgoing commit +// added; File is set for an added line or path; Line is 1-based (0 for a path). Text is the matched text only, never the surrounding line, so a hit // never carries the rest of a PR body or a file. type LeakHit struct { Source string `json:"source"` @@ -82,12 +82,15 @@ func runLeakCheck(ctx context.Context, git leakGit, wc workspaceContext, target if err != nil { return nil, fmt.Errorf("reading what %s over %s would publish: %w", shortCommit(string(head)), shortCommit(string(base.Commit)), err) } - in := leakscan.Input{AddedPaths: out.AddedPaths, PR: pr} + in := leakscan.Input{PR: pr} for _, c := range out.Commits { in.Commits = append(in.Commits, leakscan.Commit{ID: string(c.Commit), Message: c.Message}) } + for _, p := range out.AddedPaths { + in.AddedPaths = append(in.AddedPaths, leakscan.AddedPath{Path: p.Path, Commit: string(p.Commit)}) + } for _, l := range out.AddedLines { - in.AddedLines = append(in.AddedLines, leakscan.AddedLine{Path: l.Path, Line: l.Line, Text: l.Text}) + in.AddedLines = append(in.AddedLines, leakscan.AddedLine{Path: l.Path, Line: l.Line, Text: l.Text, Commit: string(l.Commit)}) } var hits []LeakHit for _, h := range leakscan.Scan(in, leakOptions(wc.pin, wc.snap)) { @@ -116,15 +119,20 @@ func leakMessage(hits []LeakHit) string { return b.String() } -// leakWhere names where a hit sits. +// leakWhere names where a hit sits, with the adding commit for an added line or +// path when it is known. func leakWhere(h LeakHit) string { + in := "" + if h.Commit != "" { + in = " in commit " + shortCommit(h.Commit) + } switch leakscan.Source(h.Source) { case leakscan.SourceCommitMessage: return fmt.Sprintf("commit %s message line %d", shortCommit(h.Commit), h.Line) case leakscan.SourceAddedLine: - return fmt.Sprintf("%s:%d", h.File, h.Line) + return fmt.Sprintf("%s:%d%s", h.File, h.Line, in) case leakscan.SourceAddedPath: - return "path " + h.File + return "path " + h.File + in default: return fmt.Sprintf("%s line %d", h.Source, h.Line) } diff --git a/internal/app/leak_check_private_integration_test.go b/internal/app/leak_check_private_integration_test.go index 4e07f5116..951d9b479 100644 --- a/internal/app/leak_check_private_integration_test.go +++ b/internal/app/leak_check_private_integration_test.go @@ -259,6 +259,25 @@ func TestIntegrationWorkflowLifecyclePrivateLeakCheckBlocksSeededLeaks(t *testin }) } + // A fingerprint one outgoing commit adds and a later one removes is absent + // from the net diff, yet the push still carries the adding commit: the + // refusal names that commit. + t.Run("added-then-removed-docket-path", func(t *testing.T) { + resetWorkspace(t, lw.wp, lw.specCommit) + added := commitInWorkspace(t, lw.wp, "Add notes", map[string]string{"notes.txt": "see .docket/x\n"}) + runGit(t, lw.wp, "rm", "-q", "notes.txt") + head := commitInWorkspace(t, lw.wp, "Drop notes", nil) + pub := lw.publish(t, head) + requireLeakRefusal(t, pub, + LeakHit{Source: "added-line", Commit: added, File: "notes.txt", Line: 1, Text: ".docket", Rule: "path"}) + if !strings.Contains(pub.Message, shortCommit(added)) { + t.Errorf("refusal message does not name the adding commit %s: %q", shortCommit(added), pub.Message) + } + if originHasBranch(t, lw.origin, lw.featureRef) { + t.Errorf("a refused publish left %s on origin", lw.featureRef) + } + }) + var cleanHead string t.Run("clean-year-and-adr-publishes", func(t *testing.T) { resetWorkspace(t, lw.wp, lw.specCommit) diff --git a/internal/app/leak_check_test.go b/internal/app/leak_check_test.go index 37402a100..843282cc7 100644 --- a/internal/app/leak_check_test.go +++ b/internal/app/leak_check_test.go @@ -75,8 +75,11 @@ func TestRunLeakCheckScansFetchedBaseToHead(t *testing.T) { fetchRev: gitcli.Revision{Commit: leakBase}, out: gitcli.Outgoing{ Commits: []gitcli.OutgoingCommit{{Commit: "c1", Message: "Fix it (0007)\n"}}, - AddedPaths: []string{"a.go"}, - AddedLines: []gitcli.OutgoingLine{{Path: "a.go", Line: 3, Text: "x := \".docket/y\""}}, + AddedPaths: []gitcli.OutgoingPath{{Path: "a.go", Commit: "c1"}, {Path: "dckt.txt"}}, + AddedLines: []gitcli.OutgoingLine{ + {Path: "a.go", Line: 3, Text: "x := \".docket/y\""}, + {Path: "b.go", Line: 1, Text: "gone .docket/z", Commit: "c1"}, + }, }, } hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, &leakscan.PRText{Title: "change 0007", Body: "ok\n"}) @@ -91,7 +94,9 @@ func TestRunLeakCheckScansFetchedBaseToHead(t *testing.T) { } want := []LeakHit{ {Source: "commit-message", Commit: "c1", Line: 1, Text: "(0007)", Rule: "change-ref"}, + {Source: "added-path", File: "dckt.txt", Text: "dckt", Rule: "alias"}, {Source: "added-line", File: "a.go", Line: 3, Text: ".docket", Rule: "path"}, + {Source: "added-line", Commit: "c1", File: "b.go", Line: 1, Text: ".docket", Rule: "path"}, {Source: "pr-title", Line: 1, Text: "change 0007", Rule: "change-ref"}, } if !reflect.DeepEqual(hits, want) { @@ -154,7 +159,7 @@ func TestLeakMessageBoundsAndNamesHits(t *testing.T) { hits := []LeakHit{ {Source: "commit-message", Commit: "0123456789abcdef0123", Line: 2, Text: "(0007)", Rule: "change-ref"}, {Source: "added-line", File: "a.go", Line: 3, Text: ".docket", Rule: "path"}, - {Source: "added-path", File: "x/.docket/y", Text: ".docket", Rule: "path"}, + {Source: "added-path", Commit: "fedcba9876543210fedc", File: "x/.docket/y", Text: ".docket", Rule: "path"}, {Source: "pr-body", Line: 4, Text: "dckt", Rule: "alias"}, {Source: "pr-title", Line: 1, Text: "docket", Rule: "word"}, {Source: "added-line", File: "sixth.go", Line: 6, Text: "docket", Rule: "word"}, @@ -164,8 +169,8 @@ func TestLeakMessageBoundsAndNamesHits(t *testing.T) { for _, want := range []string{ "7 docket fingerprint(s) would reach a shared surface; nothing was published: ", `commit 0123456789ab message line 2 "(0007)" (change-ref)`, - `a.go:3 ".docket" (path)`, - `path x/.docket/y ".docket" (path)`, + `a.go:3 ".docket" (path)`, // an unattributed (merge-base) line names no commit + `path x/.docket/y in commit fedcba987654 ".docket" (path)`, `pr-body line 4 "dckt" (alias)`, `pr-title line 1 "docket" (word)`, " … and 2 more (see leaks)", diff --git a/internal/app/pr_publish.go b/internal/app/pr_publish.go index 77d651438..5def51f53 100644 --- a/internal/app/pr_publish.go +++ b/internal/app/pr_publish.go @@ -258,8 +258,8 @@ func PRPublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } // (7b) The private-repository leak check: the authored title and body, the - // outgoing commit messages, and the merge-base diff's added paths and lines of - // exactly the requested head are scanned before any run admission or GitHub + // outgoing commit messages, and the paths and lines each outgoing commit and + // the merge-base diff add, of exactly the requested head, are scanned before any run admission or GitHub // write. A hit refuses with no PR created or edited; a scan that could not // run is unverified, never clean. A shared repository never runs it. if leakCheckApplies(pin.Layout) { diff --git a/internal/gitcli/outgoing.go b/internal/gitcli/outgoing.go index 6fd314129..b1d14a508 100644 --- a/internal/gitcli/outgoing.go +++ b/internal/gitcli/outgoing.go @@ -19,39 +19,61 @@ type OutgoingCommit struct { Message string } -// OutgoingLine is one added line: its path, its 1-based line number in head's -// version of the file, and its text without the leading '+'. +// OutgoingLine is one added line: its path, its 1-based line number in the +// version of the file that added it, and its text without the leading '+'. +// Commit is the outgoing commit that added it, or empty for a line read only +// from the merge-base diff (such as one a merge commit introduced). type OutgoingLine struct { - Path string - Line int - Text string + Path string + Line int + Text string + Commit ObjectID +} + +// OutgoingPath is one added path. Commit is the outgoing commit that added it, +// or empty for a path read only from the merge-base diff. +type OutgoingPath struct { + Path string + Commit ObjectID } // Outgoing is everything a push of head over base exposes that base does not -// already carry: the commits in base..head, plus the paths added and the lines -// added in the merge-base diff base...head. +// already carry: the commits in base..head, plus the paths and lines added — +// by each non-merge commit in base..head, and in the merge-base diff +// base...head. type Outgoing struct { Commits []OutgoingCommit - AddedPaths []string + AddedPaths []OutgoingPath AddedLines []OutgoingLine } -// ReadOutgoing reads what pushing head exposes beyond base, from three local -// reads in the primary worktree: +// ReadOutgoing reads what pushing head exposes beyond base, from local reads in +// the primary worktree: // // - `log -z --format=%H%x01%B <base>..<head>` — every commit head carries that // base does not, a merge commit included; -// - `diff --name-status -z --diff-filter=A <base>...<head>` — the added paths; -// - `diff -U0 <base>...<head>` — the added lines, attributed to their file. +// - `rev-list --no-merges --reverse <base>..<head>` — the non-merge commits +// among them, and for each one `diff-tree --root <commit>` (against its +// parent) read twice: `--name-status -z --diff-filter=A` for the paths it +// adds and `-p -U0` for the lines it adds, each attributed to that commit; +// - the same two reads as a three-dot `diff <base>...<head>`. +// +// The per-commit reads are what a push carries: a line one outgoing commit adds +// and a later one removes is absent from any net diff, yet the adding commit is +// pushed (and shown in a PR's commits) all the same. The three-dot (merge-base) +// diff adds what no non-merge commit's own diff shows — content a merge commit +// introduced — while comparing head with the merge base of base and head, so +// upstream content merged into the branch, or rewritten upstream after that +// merge, is never reported as the branch's own; a two-tree diff against base +// would report it. A merge-base path or line some outgoing commit already adds +// (same path, and same text for a line) is reported once, attributed to that +// commit. // -// The diffs are three-dot (merge-base) diffs on purpose: they compare head with -// the merge base of base and head, so upstream content merged into the branch — -// or rewritten upstream after that merge — is never reported as the branch's own -// added lines, while a two-tree diff against base would report it. Every diff -// option that user or repository configuration could otherwise change (quoting, -// prefixes, renames, external or textconv drivers, inter-hunk context, binary -// detection, submodule rendering) is pinned on the command line, and replace refs -// and grafts are ignored, so the lines read are the objects a push sends. +// Every diff option that user or repository configuration could otherwise +// change (quoting, prefixes, renames, external or textconv drivers, inter-hunk +// context, binary detection, submodule rendering) is pinned on the command line, +// identically for every diff, and replace refs and grafts are ignored, so the +// lines read are the objects a push sends. // // Every failure is an error and never an empty or partial result: an invalid id // is invalid-request, a non-zero exit — including base and head sharing no merge @@ -77,21 +99,60 @@ func (c *Client) ReadOutgoing(ctx context.Context, repo Repository, base, head O return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed outgoing log output", perr) } - namesOut, err := c.readOutgoingRun(ctx, repo, "git diff --name-status", - "diff", "--name-status", "-z", "--no-renames", "--no-relative", "--diff-filter=A", threeDot) + revOut, err := c.readOutgoingRun(ctx, repo, "git rev-list", + "rev-list", "--no-merges", "--reverse", twoDot) + if err != nil { + return Outgoing{}, err + } + nonMerge, perr := parseObjectIDLines(revOut) + if perr != nil { + return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed outgoing rev-list output", perr) + } + + var perCommit []Outgoing + for _, id := range nonMerge { + added, err := c.readAdded(ctx, repo, []string{"diff-tree", "-r", "--root", "--no-commit-id"}, string(id)) + if err != nil { + return Outgoing{}, err + } + for i := range added.AddedPaths { + added.AddedPaths[i].Commit = id + } + for i := range added.AddedLines { + added.AddedLines[i].Commit = id + } + perCommit = append(perCommit, added) + } + + net, err := c.readAdded(ctx, repo, []string{"diff"}, threeDot) + if err != nil { + return Outgoing{}, err + } + + paths, lines := mergeAdded(perCommit, net) + return Outgoing{Commits: commits, AddedPaths: paths, AddedLines: lines}, nil +} + +// readAdded reads the added paths and added lines of one diff — cmd (a diff +// subcommand and its own flags) over rev — with every configurable diff option +// pinned. The returned paths and lines carry no commit. +func (c *Client) readAdded(ctx context.Context, repo Repository, cmd []string, rev string) (Outgoing, error) { + nameArgs := append(append([]string{}, cmd...), + "--name-status", "-z", "--no-renames", "--no-relative", "--diff-filter=A", rev) + namesOut, err := c.readOutgoingRun(ctx, repo, "git "+cmd[0]+" --name-status", nameArgs...) if err != nil { return Outgoing{}, err } - paths, perr := parseAddedPaths(namesOut) + names, perr := parseAddedPaths(namesOut) if perr != nil { return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed added-paths output", perr) } - patchOut, err := c.readOutgoingRun(ctx, repo, "git diff", - "-c", "core.quotePath=false", "-c", "diff.suppressBlankEmpty=false", - "diff", "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", + patchArgs := append(append([]string{"-c", "core.quotePath=false", "-c", "diff.suppressBlankEmpty=false"}, cmd...), + "-p", "--no-renames", "--no-color", "--no-ext-diff", "--no-textconv", "--no-relative", "--text", "--submodule=short", "--inter-hunk-context=0", - "--src-prefix=a/", "--dst-prefix=b/", "-U0", threeDot) + "--src-prefix=a/", "--dst-prefix=b/", "-U0", rev) + patchOut, err := c.readOutgoingRun(ctx, repo, "git "+cmd[0], patchArgs...) if err != nil { return Outgoing{}, err } @@ -100,7 +161,64 @@ func (c *Client) ReadOutgoing(ctx context.Context, repo Repository, base, head O return Outgoing{}, newFailure(readOutgoingOp, KindInvalidOutput, "malformed patch output", perr) } - return Outgoing{Commits: commits, AddedPaths: paths, AddedLines: lines}, nil + paths := make([]OutgoingPath, 0, len(names)) + for _, p := range names { + paths = append(paths, OutgoingPath{Path: p}) + } + return Outgoing{AddedPaths: paths, AddedLines: lines}, nil +} + +// mergeAdded joins the per-commit reads (in commit order) with the merge-base +// read: every per-commit path and line is kept, and a merge-base path or line +// is kept only when no commit already adds the same path (and, for a line, the +// same text) — the scan of a dropped entry would repeat a kept one's exactly. +func mergeAdded(perCommit []Outgoing, net Outgoing) ([]OutgoingPath, []OutgoingLine) { + type lineKey struct{ path, text string } + var ( + paths []OutgoingPath + lines []OutgoingLine + seenPaths = map[string]bool{} + seenLines = map[lineKey]bool{} + ) + for _, o := range perCommit { + for _, p := range o.AddedPaths { + paths = append(paths, p) + seenPaths[p.Path] = true + } + for _, l := range o.AddedLines { + lines = append(lines, l) + seenLines[lineKey{l.Path, l.Text}] = true + } + } + for _, p := range net.AddedPaths { + if !seenPaths[p.Path] { + paths = append(paths, p) + } + } + for _, l := range net.AddedLines { + if !seenLines[lineKey{l.Path, l.Text}] { + lines = append(lines, l) + } + } + return paths, lines +} + +// parseObjectIDLines parses newline-terminated object ids (rev-list output); +// empty output is no ids. An empty line or a malformed id is an error, never a +// skipped commit. +func parseObjectIDLines(out []byte) ([]ObjectID, error) { + if len(out) == 0 { + return nil, nil + } + var ids []ObjectID + for _, l := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { + id := ObjectID(l) + if err := validateObjectID(id); err != nil { + return nil, err + } + ids = append(ids, id) + } + return ids, nil } // readOutgoingRun runs one local read for ReadOutgoing with replace refs and diff --git a/internal/gitcli/outgoing_integration_test.go b/internal/gitcli/outgoing_integration_test.go index a9c37cd0a..e41ba98c7 100644 --- a/internal/gitcli/outgoing_integration_test.go +++ b/internal/gitcli/outgoing_integration_test.go @@ -12,9 +12,11 @@ import ( // TestIntegrationRepoReadOutgoing proves ReadOutgoing returns exactly what a // push of a feature branch exposes against real git: the branch's own commits // (the merge included, the merged-in upstream commit excluded), the paths it -// adds (a space, non-ASCII, and a '"' kept verbatim), and its added lines from -// the merge-base diff — so upstream content merged into the branch is never -// attributed to it, even once the upstream base has moved on past the merge. +// adds (a space, non-ASCII, and a '"' kept verbatim), and the lines each of its +// commits adds, attributed to that commit — a line one commit adds and the next +// replaces included — with the merge-base diff adding nothing those already +// carry, so upstream content merged into the branch is never attributed to it, +// even once the upstream base has moved on past the merge. // A file Git deems binary still has its lines read, and diff configuration in // the clone (no prefixes, forced color, inter-hunk context, copy detection) // changes nothing. An unrelated base (no merge base) is an error, never an empty result. @@ -92,7 +94,13 @@ func TestIntegrationRepoReadOutgoing(t *testing.T) { t.Errorf("commit (a) message = %q, want the raw body", msg) } - paths := append([]string(nil), got.AddedPaths...) + var paths []string + for _, p := range got.AddedPaths { + if p.Commit != commitA { + t.Errorf("added path %+v not attributed to the adding commit %s", p, commitA) + } + paths = append(paths, p.Path) + } sort.Strings(paths) wantPaths := []string{"blob.bin", "café.txt", "has space.txt", "notes.txt", `we"ird.txt`} sort.Strings(wantPaths) @@ -108,18 +116,24 @@ func TestIntegrationRepoReadOutgoing(t *testing.T) { } } want := []OutgoingLine{ - {Path: "notes.txt", Line: 2, Text: "B"}, - {Path: "notes.txt", Line: 3, Text: "c"}, - {Path: "blob.bin", Line: 2, Text: "hidden line"}, + {Path: "notes.txt", Line: 1, Text: "a", Commit: commitA}, + {Path: "notes.txt", Line: 2, Text: "b", Commit: commitA}, // replaced by (b), still pushed + {Path: "notes.txt", Line: 2, Text: "B", Commit: commitB}, + {Path: "notes.txt", Line: 3, Text: "c", Commit: commitB}, + {Path: "blob.bin", Line: 1, Text: "\x00bin", Commit: commitA}, + {Path: "blob.bin", Line: 2, Text: "hidden line", Commit: commitA}, } for p, line := range odd { - want = append(want, OutgoingLine{Path: p, Line: 1, Text: line}) + want = append(want, OutgoingLine{Path: p, Line: 1, Text: line, Commit: commitA}) } for _, w := range want { if !have[w] { t.Errorf("added lines missing %+v; got %+v", w, got.AddedLines) } } + if len(got.AddedLines) != len(want) { + t.Errorf("added lines = %+v, want exactly %+v", got.AddedLines, want) + } } t.Run("base is the merged upstream tip", func(t *testing.T) { check(t, base) }) @@ -151,3 +165,92 @@ func TestIntegrationRepoReadOutgoing(t *testing.T) { assertKind(t, err, KindInvalidRequest) }) } + +// TestIntegrationRepoReadOutgoingTransientAdd proves a path and line one +// outgoing commit adds and a later one deletes — absent from the net diff, yet +// pushed with the adding commit — are read and attributed to that commit. +func TestIntegrationRepoReadOutgoingTransientAdd(t *testing.T) { + ctx := context.Background() + r := newMainModeRepos(t) + c := newRealClient(t) + repo := mustDiscover(t, c, r.Invocation) + inv := r.Invocation + + base := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + gitOut(t, inv, "checkout", "-q", "-b", "feat") + writeWorktreeFile(t, inv, "notes.txt", "see .docket/x\n") + gitOut(t, inv, "add", "--", "notes.txt") + gitOut(t, inv, "commit", "-q", "-m", "Add notes") + added := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + gitOut(t, inv, "rm", "-q", "--", "notes.txt") + gitOut(t, inv, "commit", "-q", "-m", "Drop notes") + head := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + + got, err := c.ReadOutgoing(ctx, repo, base, head) + if err != nil { + t.Fatalf("ReadOutgoing: %v", err) + } + if want := []OutgoingPath{{Path: "notes.txt", Commit: added}}; !reflect.DeepEqual(got.AddedPaths, want) { + t.Errorf("added paths = %+v, want %+v", got.AddedPaths, want) + } + if want := []OutgoingLine{{Path: "notes.txt", Line: 1, Text: "see .docket/x", Commit: added}}; !reflect.DeepEqual(got.AddedLines, want) { + t.Errorf("added lines = %+v, want %+v", got.AddedLines, want) + } +} + +// TestIntegrationRepoReadOutgoingMergeOnlyContent proves content only a merge +// commit introduces — no non-merge commit's own diff shows it — is still read, +// from the merge-base diff and with no commit, while a merged-in side commit's +// lines stay attributed to that commit. +func TestIntegrationRepoReadOutgoingMergeOnlyContent(t *testing.T) { + ctx := context.Background() + r := newMainModeRepos(t) + c := newRealClient(t) + repo := mustDiscover(t, c, r.Invocation) + inv := r.Invocation + + base := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + gitOut(t, inv, "checkout", "-q", "-b", "side") + writeWorktreeFile(t, inv, "side.txt", "side\n") + gitOut(t, inv, "add", "--", "side.txt") + gitOut(t, inv, "commit", "-q", "-m", "Side work") + side := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + + gitOut(t, inv, "checkout", "-q", "-b", "feat", string(base)) + writeWorktreeFile(t, inv, "feat.txt", "feat\n") + gitOut(t, inv, "add", "--", "feat.txt") + gitOut(t, inv, "commit", "-q", "-m", "Feature work") + gitOut(t, inv, "merge", "-q", "--no-ff", "--no-commit", "side") + writeWorktreeFile(t, inv, "evil.txt", "merge .docket/y\n") + gitOut(t, inv, "add", "--", "evil.txt") + gitOut(t, inv, "commit", "-q", "-m", "Merge side") + head := ObjectID(gitOut(t, inv, "rev-parse", "HEAD")) + feat := ObjectID(gitOut(t, inv, "rev-parse", "HEAD^1")) + + got, err := c.ReadOutgoing(ctx, repo, base, head) + if err != nil { + t.Fatalf("ReadOutgoing: %v", err) + } + wantPaths := map[OutgoingPath]bool{{Path: "side.txt", Commit: side}: true, {Path: "feat.txt", Commit: feat}: true, {Path: "evil.txt"}: true} + if len(got.AddedPaths) != len(wantPaths) { + t.Errorf("added paths = %+v, want exactly %+v", got.AddedPaths, wantPaths) + } + for _, p := range got.AddedPaths { + if !wantPaths[p] { + t.Errorf("unexpected added path %+v; want %+v", p, wantPaths) + } + } + wantLines := map[OutgoingLine]bool{ + {Path: "side.txt", Line: 1, Text: "side", Commit: side}: true, + {Path: "feat.txt", Line: 1, Text: "feat", Commit: feat}: true, + {Path: "evil.txt", Line: 1, Text: "merge .docket/y"}: true, + } + if len(got.AddedLines) != len(wantLines) { + t.Errorf("added lines = %+v, want exactly %+v", got.AddedLines, wantLines) + } + for _, l := range got.AddedLines { + if !wantLines[l] { + t.Errorf("unexpected added line %+v; want %+v", l, wantLines) + } + } +} diff --git a/internal/gitcli/outgoing_test.go b/internal/gitcli/outgoing_test.go index 375ae178c..0533cd090 100644 --- a/internal/gitcli/outgoing_test.go +++ b/internal/gitcli/outgoing_test.go @@ -207,3 +207,71 @@ func TestParseAddedPaths(t *testing.T) { }) } } + +// TestParseObjectIDLines proves the rev-list reader keeps every id in order, +// reads empty output as no ids, and refuses an empty line or a malformed id +// rather than skipping a commit. +func TestParseObjectIDLines(t *testing.T) { + const a = "1111111111111111111111111111111111111111" + const b = "2222222222222222222222222222222222222222" + got, err := parseObjectIDLines([]byte(a + "\n" + b + "\n")) + if err != nil { + t.Fatalf("parseObjectIDLines: %v", err) + } + if want := []ObjectID{a, b}; !reflect.DeepEqual(got, want) { + t.Fatalf("ids = %q, want %q", got, want) + } + if none, err := parseObjectIDLines(nil); err != nil || len(none) != 0 { + t.Fatalf("empty output = %q, %v; want no ids, no error", none, err) + } + for name, bad := range map[string]string{ + "blank line": a + "\n\n" + b + "\n", + "lone newline": "\n", + "malformed id": a + "\nabc\n", + } { + t.Run(name, func(t *testing.T) { + if ids, err := parseObjectIDLines([]byte(bad)); err == nil { + t.Fatalf("parseObjectIDLines accepted %q: %q", bad, ids) + } + }) + } +} + +// TestMergeAdded proves every per-commit path and line is kept with its commit, +// in commit order, and a merge-base entry is kept only when no commit already +// adds the same path (and, for a line, the same text) — so a merge's own +// content is still scanned while the branch's net content is not reported twice. +func TestMergeAdded(t *testing.T) { + const a, b = ObjectID("1111111111111111111111111111111111111111"), ObjectID("2222222222222222222222222222222222222222") + perCommit := []Outgoing{ + { + AddedPaths: []OutgoingPath{{Path: "notes.txt", Commit: a}}, + AddedLines: []OutgoingLine{{Path: "notes.txt", Line: 1, Text: "see .docket/x", Commit: a}, {Path: "notes.txt", Line: 2, Text: "keep", Commit: a}}, + }, + {AddedLines: []OutgoingLine{{Path: "notes.txt", Line: 1, Text: "later", Commit: b}}}, + } + net := Outgoing{ + AddedPaths: []OutgoingPath{{Path: "notes.txt"}, {Path: "merged.txt"}}, + AddedLines: []OutgoingLine{ + {Path: "notes.txt", Line: 1, Text: "later"}, + {Path: "notes.txt", Line: 2, Text: "keep"}, + {Path: "merged.txt", Line: 1, Text: "from the merge"}, + {Path: "other.txt", Line: 4, Text: "keep"}, + }, + } + paths, lines := mergeAdded(perCommit, net) + wantPaths := []OutgoingPath{{Path: "notes.txt", Commit: a}, {Path: "merged.txt"}} + if !reflect.DeepEqual(paths, wantPaths) { + t.Errorf("paths = %+v, want %+v", paths, wantPaths) + } + wantLines := []OutgoingLine{ + {Path: "notes.txt", Line: 1, Text: "see .docket/x", Commit: a}, + {Path: "notes.txt", Line: 2, Text: "keep", Commit: a}, + {Path: "notes.txt", Line: 1, Text: "later", Commit: b}, + {Path: "merged.txt", Line: 1, Text: "from the merge"}, + {Path: "other.txt", Line: 4, Text: "keep"}, + } + if !reflect.DeepEqual(lines, wantLines) { + t.Errorf("lines =\n %+v\nwant\n %+v", lines, wantLines) + } +} diff --git a/internal/leakscan/leakscan.go b/internal/leakscan/leakscan.go index 2b2e24409..a9c64364f 100644 --- a/internal/leakscan/leakscan.go +++ b/internal/leakscan/leakscan.go @@ -46,9 +46,10 @@ type Match struct { Rule Rule } -// Hit is an attributed match. Commit is set for a commit-message hit, File for -// an added line or path; Line is 1-based (0 for a path); Text is the matched -// text only, never the whole line. +// Hit is an attributed match. Commit is set for a commit-message hit, and for an +// added line or path that a known commit added; File is set for an added line or +// path; Line is 1-based (0 for a path); Text is the matched text only, never the +// whole line. type Hit struct { Source Source Commit string @@ -61,20 +62,25 @@ type Hit struct { // Commit is one outgoing commit's id and full message. type Commit struct{ ID, Message string } -// AddedLine is one line a push adds, with its file and 1-based line number. +// AddedLine is one line a push adds, with its file and 1-based line number, and +// the commit that added it when known. type AddedLine struct { - Path string - Line int - Text string + Path string + Line int + Text string + Commit string } +// AddedPath is one path a push adds, and the commit that added it when known. +type AddedPath struct{ Path, Commit string } + // PRText is a pull request's title and body. type PRText struct{ Title, Body string } // Input is everything one publish exposes. PR is nil when no PR text is written. type Input struct { Commits []Commit - AddedPaths []string + AddedPaths []AddedPath AddedLines []AddedLine PR *PRText } @@ -90,13 +96,13 @@ func Scan(in Input, opts Options) []Hit { } } for _, p := range in.AddedPaths { - if m, ok := Line(p, opts); ok { - hits = append(hits, Hit{Source: SourceAddedPath, File: p, Text: m.Text, Rule: m.Rule}) + if m, ok := Line(p.Path, opts); ok { + hits = append(hits, Hit{Source: SourceAddedPath, Commit: p.Commit, File: p.Path, Text: m.Text, Rule: m.Rule}) } } for _, l := range in.AddedLines { if m, ok := Line(l.Text, opts); ok { - hits = append(hits, Hit{Source: SourceAddedLine, File: l.Path, Line: l.Line, Text: m.Text, Rule: m.Rule}) + hits = append(hits, Hit{Source: SourceAddedLine, Commit: l.Commit, File: l.Path, Line: l.Line, Text: m.Text, Rule: m.Rule}) } } if in.PR != nil { diff --git a/internal/leakscan/leakscan_test.go b/internal/leakscan/leakscan_test.go index 7a44c3578..bf7d325c1 100644 --- a/internal/leakscan/leakscan_test.go +++ b/internal/leakscan/leakscan_test.go @@ -69,15 +69,16 @@ func TestTextLineNumbersAndCRLF(t *testing.T) { func TestScanAttributesEverySource(t *testing.T) { in := Input{ Commits: []Commit{{ID: "c1", Message: "Add widget\n\nRefs (0612)\n"}}, - AddedPaths: []string{"notes/.docket-old.md"}, - AddedLines: []AddedLine{{Path: "a.go", Line: 7, Text: "// dckt"}}, + AddedPaths: []AddedPath{{Path: "notes/.docket-old.md", Commit: "c1"}}, + AddedLines: []AddedLine{{Path: "a.go", Line: 7, Text: "// dckt"}, {Path: "b.go", Line: 2, Text: "see .docket/x", Commit: "c1"}}, PR: &PRText{Title: "change 0012", Body: "plain\n"}, } got := Scan(in, Options{MatchWord: true, ChangeIDs: map[int]bool{12: true, 612: true}}) want := []Hit{ {Source: SourceCommitMessage, Commit: "c1", Line: 3, Text: "(0612)", Rule: RuleChangeRef}, - {Source: SourceAddedPath, File: "notes/.docket-old.md", Text: ".docket", Rule: RulePath}, + {Source: SourceAddedPath, Commit: "c1", File: "notes/.docket-old.md", Text: ".docket", Rule: RulePath}, {Source: SourceAddedLine, File: "a.go", Line: 7, Text: "dckt", Rule: RuleAlias}, + {Source: SourceAddedLine, Commit: "c1", File: "b.go", Line: 2, Text: ".docket", Rule: RulePath}, {Source: SourcePRTitle, Line: 1, Text: "change 0012", Rule: RuleChangeRef}, } if !reflect.DeepEqual(got, want) { From 9fd3b69c053bc52fbd238676aae3f9dec600c752 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:51:48 -0400 Subject: [PATCH 12/13] fix(app): leak-scan the pushed feature branch name Review finding: the feature branch name (<type>/<slug>, slug from the change title) is shown on origin and the PR but was never scanned, so a trivial private change titled "Fix the dckt alias" pushed fix/fix-the-dckt-alias unrefused. runLeakCheck now feeds the target's short feature branch to leakscan as a branch-name input, covering workspace.publish, pr.publish, and finalize.publish, which all scan through it. --- internal/app/leak_check.go | 16 +++-- .../leak_check_private_integration_test.go | 61 ++++++++++++++++--- internal/app/leak_check_test.go | 27 ++++++++ internal/app/pr_publish.go | 2 +- internal/leakscan/leakscan.go | 20 ++++-- internal/leakscan/leakscan_test.go | 2 + 6 files changed, 106 insertions(+), 22 deletions(-) diff --git a/internal/app/leak_check.go b/internal/app/leak_check.go index 0e5f22421..2d9c88a6e 100644 --- a/internal/app/leak_check.go +++ b/internal/app/leak_check.go @@ -13,10 +13,11 @@ import ( ) // This file is the private-repository leak check. Before a push or PR write in a -// private repository, everything that write would expose — the outgoing commit -// messages, the paths and lines each outgoing commit adds and the merge-base -// diff adds, and any PR title and body — is scanned for docket fingerprints (internal/leakscan), and any hit -// refuses the write with nothing published. A shared repository never runs it. +// private repository, everything that write would expose — the feature branch +// name, the outgoing commit messages, the paths and lines each outgoing commit +// adds and the merge-base diff adds, and any PR title and body — is scanned for +// docket fingerprints (internal/leakscan), and any hit refuses the write with +// nothing published. A shared repository never runs it. // A failure to fetch the base or read the outgoing set is leak-check-unverified, // never a clean scan. Each caller scans the exact head it publishes. @@ -71,7 +72,8 @@ func leakOptions(pin StatusPin, snap domain.Snapshot) leakscan.Options { } // runLeakCheck fetches target's base from origin and scans what publishing head -// over it would expose, plus pr when non-nil. Any fetch or read failure is an +// over it would expose — target's feature branch name included, since origin and +// the PR show it — plus pr when non-nil. Any fetch or read failure is an // error — the caller refuses as unverified — and never an empty hit list. func runLeakCheck(ctx context.Context, git leakGit, wc workspaceContext, target workspace.Target, head gitcli.ObjectID, pr *leakscan.PRText) ([]LeakHit, error) { base, err := git.FetchBranch(ctx, wc.repo, originRemote, target.BaseRef) @@ -82,7 +84,7 @@ func runLeakCheck(ctx context.Context, git leakGit, wc workspaceContext, target if err != nil { return nil, fmt.Errorf("reading what %s over %s would publish: %w", shortCommit(string(head)), shortCommit(string(base.Commit)), err) } - in := leakscan.Input{PR: pr} + in := leakscan.Input{Branch: target.FeatureBranch(), PR: pr} for _, c := range out.Commits { in.Commits = append(in.Commits, leakscan.Commit{ID: string(c.Commit), Message: c.Message}) } @@ -127,6 +129,8 @@ func leakWhere(h LeakHit) string { in = " in commit " + shortCommit(h.Commit) } switch leakscan.Source(h.Source) { + case leakscan.SourceBranchName: + return "the feature branch name" case leakscan.SourceCommitMessage: return fmt.Sprintf("commit %s message line %d", shortCommit(h.Commit), h.Line) case leakscan.SourceAddedLine: diff --git a/internal/app/leak_check_private_integration_test.go b/internal/app/leak_check_private_integration_test.go index 951d9b479..abd627a71 100644 --- a/internal/app/leak_check_private_integration_test.go +++ b/internal/app/leak_check_private_integration_test.go @@ -38,8 +38,9 @@ type leakWorkspace struct { // prepareLeakWorkspace runs the opening steps of driveClaimToImplemented in // order — context, claim, reconcile, workspace prepare, and the spec-copy commit -// — and fails unless each applies. -func prepareLeakWorkspace(t *testing.T, repo *gitRepo, branch string, id int, recPath string) *leakWorkspace { +// — and fails unless each applies. A change without a spec (spec false) must +// report no-spec instead, and its prepared head stands in for the spec commit. +func prepareLeakWorkspace(t *testing.T, repo *gitRepo, branch string, id int, recPath string, spec bool) *leakWorkspace { t.Helper() ctx := context.Background() node := planningDepsFor(t, repo.invocation) @@ -69,6 +70,17 @@ func prepareLeakWorkspace(t *testing.T, repo *gitRepo, branch string, id int, re t.Fatalf("workspace prepare = %q (reason %q msg %q)", prep.Result, prep.Reason, prep.Message) } cs := WorkspaceCommitSpec(ctx, node.deps, wdeps, node.dir, WorkspaceIDRequest{ID: id}) + if !spec { + if cs.Result != ResultNoOp || cs.Disposition != SpecCopyNoSpec { + t.Fatalf("workspace commit-spec = (%q, %q) (reason %q msg %q), want no-op/no-spec", cs.Result, cs.Disposition, cs.Reason, cs.Message) + } + return &leakWorkspace{ + node: node, wdeps: wdeps, id: id, + wp: prep.Path, specCommit: runGit(t, prep.Path, "rev-parse", "HEAD"), + featureRef: prep.FeatureRef, + origin: repo.origin, writer: repo.writer, invocation: repo.invocation, + } + } if cs.Result != ResultApplied || cs.Disposition != SpecCopyCommitted { t.Fatalf("workspace commit-spec = (%q, %q) (reason %q msg %q), want applied/committed", cs.Result, cs.Disposition, cs.Reason, cs.Message) } @@ -93,6 +105,13 @@ func prepareLeakWorkspace(t *testing.T, repo *gitRepo, branch string, id int, re // groom — init --private, a configured gate, change 1 created and groomed with // a spec — and prepares its workspace against the dckt store. func newPrivateLeakWorkspace(t *testing.T) (*leakWorkspace, layout.Layout) { + t.Helper() + return newPrivateLeakWorkspaceFor(t, validChangeCreateRequest(), true) +} + +// newPrivateLeakWorkspaceFor is newPrivateLeakWorkspace for the given create +// request, groomed with a spec (spec true) or as trivial (no spec copy). +func newPrivateLeakWorkspaceFor(t *testing.T, create ChangeCreateRequest, spec bool) (*leakWorkspace, layout.Layout) { t.Helper() ctx := context.Background() r, _ := newPrivateInitRepo(t, nil) @@ -107,24 +126,29 @@ func newPrivateLeakWorkspace(t *testing.T) (*leakWorkspace, layout.Layout) { repo := &gitRepo{root: r.root, origin: r.origin, meta: lay.DefaultBareRemote, writer: r.writer, invocation: r.invocation} node := planningDepsFor(t, r.invocation) - created := ChangeCreate(ctx, node.deps, node.dir, validChangeCreateRequest()) + created := ChangeCreate(ctx, node.deps, node.dir, create) if created.Result != ResultApplied { t.Fatalf("change create = %q (%s, findings %v)", created.Result, created.HumanText(), created.Findings) } if created.ID != 1 { t.Fatalf("created change id = %d, want 1 (the seeded change references name 0001)", created.ID) } - groom := ChangeGroom(ctx, node.deps, node.dir, ChangeGroomRequest{ + req := 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) } - return prepareLeakWorkspace(t, repo, privateFixtureBranch, created.ID, created.Path), lay + if !spec { + req.Outcome, req.SpecMarkdown = GroomTrivial, "" + req.Sections = []SectionEditRequest{{Heading: "## Why", Intent: "replace", Markdown: "Too small to design.\n"}} + } + groom := ChangeGroom(ctx, node.deps, node.dir, req) + if groom.Result != ResultApplied || (spec && groom.SpecPath == "") { + t.Fatalf("change groom = %q (%s, findings %v), want applied (with a spec: %v)", groom.Result, groom.HumanText(), groom.Findings, spec) + } + return prepareLeakWorkspace(t, repo, privateFixtureBranch, created.ID, created.Path, spec), lay } // newSharedLeakWorkspace builds the shared (docket-branch) fixture @@ -142,7 +166,7 @@ func newSharedLeakWorkspace(t *testing.T) *leakWorkspace { recPath: specLinkedBuildReadyChange(id, slug, specPath), specPath: workflowMetadataSpec(id, slug), }) - return prepareLeakWorkspace(t, repo, m.branch, id, recPath) + return prepareLeakWorkspace(t, repo, m.branch, id, recPath, true) } // commitInWorkspace writes files into the worktree, commits everything with @@ -412,6 +436,25 @@ func TestIntegrationWorkflowLifecyclePrivateLeakCheckUnverifiedPushesNothing(t * } } +// TestIntegrationWorkflowLifecyclePrivateLeakCheckScansBranchName proves the +// pushed feature branch name is scanned like any other published text: a +// trivial change titled "Fix the dckt alias" — no spec copy, clean commits — +// still refuses, because its slug puts dckt in the branch origin would show. +func TestIntegrationWorkflowLifecyclePrivateLeakCheckScansBranchName(t *testing.T) { + create := validChangeCreateRequest() + create.Title, create.Type = "Fix the dckt alias", "fix" + lw, _ := newPrivateLeakWorkspaceFor(t, create, false) + if lw.featureRef != "refs/heads/fix/fix-the-dckt-alias" { + t.Fatalf("feature ref = %q, want refs/heads/fix/fix-the-dckt-alias", lw.featureRef) + } + + head := commitInWorkspace(t, lw.wp, "Tidy the alias table", map[string]string{"aliases.txt": "tidy\n"}) + requireLeakRefusal(t, lw.publish(t, head), LeakHit{Source: "branch-name", Text: "dckt", Rule: "alias"}) + if originHasBranch(t, lw.origin, lw.featureRef) { + t.Errorf("a refused publish left %s on origin", lw.featureRef) + } +} + // TestIntegrationWorkflowLifecyclePrivateLeakCheckGatesPR proves a private // repository's pr.publish scans the authored title and body (as well as the // outgoing commits) before any GitHub write: a change reference in the title or diff --git a/internal/app/leak_check_test.go b/internal/app/leak_check_test.go index 843282cc7..3c71a9b70 100644 --- a/internal/app/leak_check_test.go +++ b/internal/app/leak_check_test.go @@ -104,6 +104,33 @@ func TestRunLeakCheckScansFetchedBaseToHead(t *testing.T) { } } +// TestRunLeakCheckScansFeatureBranchName pins that the feature branch name — +// shown on origin and the PR — is scanned even when every outgoing commit, path, +// and line is clean. +func TestRunLeakCheckScansFeatureBranchName(t *testing.T) { + wc, target := leakContext(t, true) + target.FeatureRef = "refs/heads/fix/fix-the-dckt-alias" + git := &fakeLeakGit{ + fetchRev: gitcli.Revision{Commit: leakBase}, + out: gitcli.Outgoing{ + Commits: []gitcli.OutgoingCommit{{Commit: "c1", Message: "Tidy the alias table\n"}}, + AddedPaths: []gitcli.OutgoingPath{{Path: "aliases.txt", Commit: "c1"}}, + AddedLines: []gitcli.OutgoingLine{{Path: "aliases.txt", Line: 1, Text: "tidy", Commit: "c1"}}, + }, + } + hits, err := runLeakCheck(context.Background(), git, wc, target, leakHead, nil) + if err != nil { + t.Fatalf("runLeakCheck: %v", err) + } + want := []LeakHit{{Source: "branch-name", Text: "dckt", Rule: "alias"}} + if !reflect.DeepEqual(hits, want) { + t.Errorf("hits = %+v, want %+v", hits, want) + } + if msg := leakMessage(hits); !strings.Contains(msg, `the feature branch name "dckt" (alias)`) { + t.Errorf("leak message does not name the branch-name hit: %q", msg) + } +} + func TestRunLeakCheckFetchFailureIsUnverified(t *testing.T) { wc, target := leakContext(t, true) git := &fakeLeakGit{fetchErr: errors.New("network down")} diff --git a/internal/app/pr_publish.go b/internal/app/pr_publish.go index 5def51f53..2e01847ab 100644 --- a/internal/app/pr_publish.go +++ b/internal/app/pr_publish.go @@ -258,7 +258,7 @@ func PRPublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } // (7b) The private-repository leak check: the authored title and body, the - // outgoing commit messages, and the paths and lines each outgoing commit and + // feature branch name, the outgoing commit messages, and the paths and lines each outgoing commit and // the merge-base diff add, of exactly the requested head, are scanned before any run admission or GitHub // write. A hit refuses with no PR created or edited; a scan that could not // run is unverified, never clean. A shared repository never runs it. diff --git a/internal/leakscan/leakscan.go b/internal/leakscan/leakscan.go index a9c64364f..d0f06db6f 100644 --- a/internal/leakscan/leakscan.go +++ b/internal/leakscan/leakscan.go @@ -1,7 +1,8 @@ // Package leakscan holds the fingerprint rules for text a private repository -// publishes: commit messages, added paths and lines, and PR text. It is pure -// and stdlib-only. Each line yields at most one hit; the rules are tried in a -// fixed order (marker, trailer, path, alias, change-ref, word). +// publishes: the feature branch name, commit messages, added paths and lines, +// and PR text. It is pure and stdlib-only. Each line yields at most one hit; +// the rules are tried in a fixed order (marker, trailer, path, alias, +// change-ref, word). package leakscan import ( @@ -25,6 +26,7 @@ const ( type Source string const ( + SourceBranchName Source = "branch-name" SourceCommitMessage Source = "commit-message" SourceAddedLine Source = "added-line" SourceAddedPath Source = "added-path" @@ -77,18 +79,24 @@ type AddedPath struct{ Path, Commit string } // PRText is a pull request's title and body. type PRText struct{ Title, Body string } -// Input is everything one publish exposes. PR is nil when no PR text is written. +// Input is everything one publish exposes. Branch is the short name of the +// branch the publish pushes or opens a PR from; PR is nil when no PR text is +// written. type Input struct { + Branch string Commits []Commit AddedPaths []AddedPath AddedLines []AddedLine PR *PRText } -// Scan checks every input in order: each commit message, each added path, each -// added line, then the PR title and body when PR is non-nil. +// Scan checks every input in order: the branch name, each commit message, each +// added path, each added line, then the PR title and body when PR is non-nil. func Scan(in Input, opts Options) []Hit { var hits []Hit + if m, ok := Line(in.Branch, opts); ok { + hits = append(hits, Hit{Source: SourceBranchName, Text: m.Text, Rule: m.Rule}) + } for _, c := range in.Commits { for _, h := range Text(SourceCommitMessage, c.Message, opts) { h.Commit = c.ID diff --git a/internal/leakscan/leakscan_test.go b/internal/leakscan/leakscan_test.go index bf7d325c1..ea8d627b4 100644 --- a/internal/leakscan/leakscan_test.go +++ b/internal/leakscan/leakscan_test.go @@ -68,6 +68,7 @@ func TestTextLineNumbersAndCRLF(t *testing.T) { func TestScanAttributesEverySource(t *testing.T) { in := Input{ + Branch: "fix/fix-the-dckt-alias", Commits: []Commit{{ID: "c1", Message: "Add widget\n\nRefs (0612)\n"}}, AddedPaths: []AddedPath{{Path: "notes/.docket-old.md", Commit: "c1"}}, AddedLines: []AddedLine{{Path: "a.go", Line: 7, Text: "// dckt"}, {Path: "b.go", Line: 2, Text: "see .docket/x", Commit: "c1"}}, @@ -75,6 +76,7 @@ func TestScanAttributesEverySource(t *testing.T) { } got := Scan(in, Options{MatchWord: true, ChangeIDs: map[int]bool{12: true, 612: true}}) want := []Hit{ + {Source: SourceBranchName, Text: "dckt", Rule: RuleAlias}, {Source: SourceCommitMessage, Commit: "c1", Line: 3, Text: "(0612)", Rule: RuleChangeRef}, {Source: SourceAddedPath, Commit: "c1", File: "notes/.docket-old.md", Text: ".docket", Rule: RulePath}, {Source: SourceAddedLine, File: "a.go", Line: 7, Text: "dckt", Rule: RuleAlias}, From 62d525b97cf57d7488cf3b6ea7562f01d1660c7b Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 12:54:35 -0400 Subject: [PATCH 13/13] fix(skills): hand finalize's repair and resolver agents the visibility and private writing rule Review finding: finalize's integration-repair worker commits on the feature branch without the repository visibility or the private-repository writing rule, so a habitual repair commit could trip leak-detected at publish. Both finalize gate dispatches now carry the Visibility payload line, both agents state the rule, and the guard derives the writer set from the feature-scoped inventory instead of a hand list. --- agents/docket-integration-repair.md | 2 + agents/docket-rebase-resolver.md | 2 + internal/assets/embedded/manifest.json | 18 ++-- .../tree/agents/docket-integration-repair.md | 2 + .../tree/agents/docket-rebase-resolver.md | 2 + .../skills/docket-finalize-change/SKILL.md | 4 +- .../references/gate-failure.md | 1 + .../golden/docket-integration-repair.md | 2 + .../testdata/golden/docket-rebase-resolver.md | 2 + .../golden/docket-integration-repair.toml | 2 + .../golden/docket-rebase-resolver.toml | 2 + .../golden/docket-integration-repair.md | 2 + .../testdata/golden/docket-rebase-resolver.md | 2 + .../golden/docket-integration-repair.md | 2 + .../testdata/golden/docket-rebase-resolver.md | 2 + internal/repoguard/budgets_test.go | 4 +- .../repoguard/private_writing_rule_test.go | 99 +++++++++++++++++++ skills/docket-finalize-change/SKILL.md | 4 +- .../references/gate-failure.md | 1 + 19 files changed, 142 insertions(+), 13 deletions(-) diff --git a/agents/docket-integration-repair.md b/agents/docket-integration-repair.md index 05b0ae7f3..3019f2ebd 100644 --- a/agents/docket-integration-repair.md +++ b/agents/docket-integration-repair.md @@ -8,6 +8,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/agents/docket-rebase-resolver.md b/agents/docket-rebase-resolver.md index 0cb77041f..28f7132a3 100644 --- a/agents/docket-rebase-resolver.md +++ b/agents/docket-rebase-resolver.md @@ -8,6 +8,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index be63897ab..24001efc5 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:5d0136e7d91b740646f0a5837ba0d599d8b5eccc2267252b0d269edeccf0ecba", + "asset_set_id": "sha256:54fb90cc80ae3642203dd0b106f9f4671b5fd393bbcce731b1fede565391724d", "entries": [ { "path": ".docket.example.yml", @@ -84,8 +84,8 @@ "path": "agents/docket-integration-repair.md", "role": "agent-source", "mode": 420, - "size": 2582, - "sha256": "54a86258b4997f7af763b40f70ae5d7b54d90f5f8083ef3e821fb3a319d8efbe" + "size": 2988, + "sha256": "772bdc58376698701f00e328753181b7fcbd660f66e69287bfaf4914a585443b" }, { "path": "agents/docket-plan-writer.md", @@ -98,8 +98,8 @@ "path": "agents/docket-rebase-resolver.md", "role": "agent-source", "mode": 420, - "size": 3225, - "sha256": "262c7832f0a85ce4f81276a0183e80d2ef3017d73bde4c6b59508c86fbadc47b" + "size": 3574, + "sha256": "3ffa4798cb1e0d4478596ddee285be806467234e78976aea4c2297507348b25e" }, { "path": "agents/docket-review-deep.md", @@ -357,15 +357,15 @@ "path": "skills/docket-finalize-change/SKILL.md", "role": "skill", "mode": 420, - "size": 41759, - "sha256": "cf26a62323d1437a36e422491d13f3660b4be666feaf92649f8c7389a340d569" + "size": 41898, + "sha256": "38d691d52b4a0cc0dd5513101e0f935750b9f48c4f17b261594cf274969a0a8f" }, { "path": "skills/docket-finalize-change/references/gate-failure.md", "role": "skill", "mode": 420, - "size": 14803, - "sha256": "deda0bf0adfade52197583059ef7f2965100a48e1a38df00b1cecfe820e0e3c7" + "size": 14832, + "sha256": "7f531866048e6275c2c80097f9970f5d9224e4b04c2d552f6f85c2e36059c668" }, { "path": "skills/docket-groom-next/SKILL.md", diff --git a/internal/assets/embedded/tree/agents/docket-integration-repair.md b/internal/assets/embedded/tree/agents/docket-integration-repair.md index 05b0ae7f3..3019f2ebd 100644 --- a/internal/assets/embedded/tree/agents/docket-integration-repair.md +++ b/internal/assets/embedded/tree/agents/docket-integration-repair.md @@ -8,6 +8,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/internal/assets/embedded/tree/agents/docket-rebase-resolver.md b/internal/assets/embedded/tree/agents/docket-rebase-resolver.md index 0cb77041f..28f7132a3 100644 --- a/internal/assets/embedded/tree/agents/docket-rebase-resolver.md +++ b/internal/assets/embedded/tree/agents/docket-rebase-resolver.md @@ -8,6 +8,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md index 3aba56ade..470384ed9 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md @@ -95,6 +95,7 @@ When the effective base merely advances under a **completed, clean** owned rebas - `reserved` — authorizes exactly ONE resolver dispatch. Its payload includes the returned `reservation` token and contains: Dispatch `docket-rebase-resolver` foreground at the model/effort its wrapper resolves. Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> The resolver echoes the token back as the report's `resolver_reservation` field. It edits only the conflicted regions in the returned workspace and returns a versioned `ResolverReport` (fields in step 3 of *The two agents* in `references/gate-failure.md`) — it never runs the rebase mechanics or the suite. In Docket's own repository, conflicts under `internal/assets/embedded/` are derived outputs the controller clears by regenerating the bundle: generated-only stops never reach this reserve step (the operation clears them itself, spending no budget), and on a mixed stop the resolver reports only the authored paths while `finalize.rebase-continue` regenerates and stages the bundle in the same continue. - `pending` — a reservation is already outstanding: dispatch NOTHING new. If the child it belongs to is yours and identifiable, wait for it or feed its matching report to `finalize.rebase-continue`; if you cannot establish dispatch ownership, this is `halted` with the reservation retained. - `exhausted` (`resolver-budget-exhausted`) — the configured `finalize.resolver_max_attempts` budget (default 10) is spent: `halted` via the abort flow below. Raising the limit takes effect on the next explicit finalize attempt, never this receipt. @@ -119,8 +120,9 @@ The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebas A red suite after the rebase is repair work, regardless of cause. Dispatch `docket-integration-repair` foreground at the model/effort its wrapper resolves. Its payload contains: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> Repair-attempt budget: <finalize.repair_max_attempts from the startup-check prepared context> (initial attempt included) -Red-test root cause, feature-branch fix bounded to the dispatched repair-attempt budget, and claimed commits plus `repaired`/`stuck`; it never rebases, merges, or transitions metadata. +Both `Visibility` lines carry the startup-check prepared context's `visibility`. Red-test root cause, feature-branch fix bounded to the dispatched repair-attempt budget, and claimed commits plus `repaired`/`stuck`; it never rebases, merges, or transitions metadata. <!-- docket:feature-dispatch:end --> Then re-gate the repaired head through the gate driver — a drive you start yourself, separate from the gate `finalize.rebase` composes: the `gate.drive.start` operation with `--repo-dir <feature worktree> --owner finalize --change-id <id> --run-root <dir> --json`. Capture that first JSON response into `gate_reply` and read the drive id and owner generation from it — never into a zsh read-only special parameter such as `status`. Then run the `gate.drive.advance` operation with `--drive-id <id> --owner-gen <gen>`, one slice per call, until a terminal disposition, under `docket-build`'s gate-run posture (a dispatched agent never yields). The `--owner finalize` drive runs `finalize.test_command` and charges no build attempt. A `PASSED` drive whose head equals the repaired head feeds the `evidence.record` operation with `--owner finalize --id <id> --run <raw run dir from the PASSED document> --head <repaired head>`, which records `finalize.test_command` (the command that ran) and returns the immutable block — no agent-supplied `passed` boolean. `FAILED` returns to repair within the configured `finalize.repair_max_attempts` budget; `HALTED`, a repair that cannot reach green within that budget, or unavailable repair dispatch is `halted`. The raw `gate.launch`/`gate.observe` operations are operator primitives, never this re-gate. diff --git a/internal/assets/embedded/tree/skills/docket-finalize-change/references/gate-failure.md b/internal/assets/embedded/tree/skills/docket-finalize-change/references/gate-failure.md index c197ea055..52e3c46c0 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/references/gate-failure.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/references/gate-failure.md @@ -12,6 +12,7 @@ or tests; `docket-integration-repair` owns the **red suite** *after* the rebase cause. Neither wraps a skill (only `docket-convention`); both are dispatched **foreground at the model/effort its wrapper resolves** — never a literal model or effort. Either dispatch payload includes: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> This harness-neutral input serves the feature-scoped role; Codex enters it through its installed contract, other harnesses through their native worktree mechanism. diff --git a/internal/harness/claude/testdata/golden/docket-integration-repair.md b/internal/harness/claude/testdata/golden/docket-integration-repair.md index 00ae70f03..5ade0c9b6 100644 --- a/internal/harness/claude/testdata/golden/docket-integration-repair.md +++ b/internal/harness/claude/testdata/golden/docket-integration-repair.md @@ -10,6 +10,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/internal/harness/claude/testdata/golden/docket-rebase-resolver.md b/internal/harness/claude/testdata/golden/docket-rebase-resolver.md index 388eaa19d..92f6f3e8b 100644 --- a/internal/harness/claude/testdata/golden/docket-rebase-resolver.md +++ b/internal/harness/claude/testdata/golden/docket-rebase-resolver.md @@ -10,6 +10,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/harness/codex/testdata/golden/docket-integration-repair.toml b/internal/harness/codex/testdata/golden/docket-integration-repair.toml index 19503a929..f6f60b59c 100644 --- a/internal/harness/codex/testdata/golden/docket-integration-repair.toml +++ b/internal/harness/codex/testdata/golden/docket-integration-repair.toml @@ -13,6 +13,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/internal/harness/codex/testdata/golden/docket-rebase-resolver.toml b/internal/harness/codex/testdata/golden/docket-rebase-resolver.toml index bb3d0e5c6..22c73ec16 100644 --- a/internal/harness/codex/testdata/golden/docket-rebase-resolver.toml +++ b/internal/harness/codex/testdata/golden/docket-rebase-resolver.toml @@ -13,6 +13,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/harness/cursor/testdata/golden/docket-integration-repair.md b/internal/harness/cursor/testdata/golden/docket-integration-repair.md index 1beddc221..97514c0d6 100644 --- a/internal/harness/cursor/testdata/golden/docket-integration-repair.md +++ b/internal/harness/cursor/testdata/golden/docket-integration-repair.md @@ -11,6 +11,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/internal/harness/cursor/testdata/golden/docket-rebase-resolver.md b/internal/harness/cursor/testdata/golden/docket-rebase-resolver.md index ce8906408..0ffabfdba 100644 --- a/internal/harness/cursor/testdata/golden/docket-rebase-resolver.md +++ b/internal/harness/cursor/testdata/golden/docket-rebase-resolver.md @@ -11,6 +11,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/harness/opencode/testdata/golden/docket-integration-repair.md b/internal/harness/opencode/testdata/golden/docket-integration-repair.md index f9380872b..617b39ace 100644 --- a/internal/harness/opencode/testdata/golden/docket-integration-repair.md +++ b/internal/harness/opencode/testdata/golden/docket-integration-repair.md @@ -11,6 +11,8 @@ You make the test suite pass after `docket-finalize-change` has rebased a featur Charter: own every red-test outcome regardless of cause — genuine base drift, or a bad conflict resolution you can see in the Git state. Apply systematic-debugging discipline: find the root cause, write a MINIMAL fix, never game or weaken the tests, then commit the fix on the feature branch. You are bounded to the repair-attempt budget your dispatch payload names (`repair_max_attempts`, resolved from `finalize.repair_max_attempts`; treat an unstated budget as 6, the built-in default). The initial attempt counts as attempt 1; stop as soon as the suite is green. You do **not** re-run the gate for record, publish, merge, or transition any metadata — the controller re-gates your repaired head through the gate driver (`--owner finalize`), records the exact-head evidence through the `evidence.record` operation, and drives publish and merge. Never run the `finalize.merge`/`publish`/`closeout` operations, `gh pr merge`, or any metadata write yourself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every commit message, code line, comment, and test name you write follows the convention's private-repository writing rule — never a change id or docket wording; when the fix cannot be made without breaking it, return `disposition: stuck` naming the conflict, never commit the violation. + Return your work as a structured repair report — an authored hint the controller re-verifies against the real branch delta before acting — naming: - the **claimed commits** you added on the feature branch (their SHAs); diff --git a/internal/harness/opencode/testdata/golden/docket-rebase-resolver.md b/internal/harness/opencode/testdata/golden/docket-rebase-resolver.md index 05c4dbd87..2755b46af 100644 --- a/internal/harness/opencode/testdata/golden/docket-rebase-resolver.md +++ b/internal/harness/opencode/testdata/golden/docket-rebase-resolver.md @@ -11,6 +11,8 @@ You resolve the conflicts of an owned rebase of a feature branch onto its integr Charter: for each conflicted hunk in the returned feature workspace, reconcile it with merge-intent judgment — work out what the base changed and what the PR intends, then keep one side or synthesize both. Edit ONLY the conflicted regions of the reported paths. You do **not** drive the rebase and you do **not** run the suite: the `finalize.rebase-continue`/`rebase-abort` operations own every Git rebase mechanic — staging, `--continue`, `--abort` — and the integration-repair agent owns making the suite pass after the rebase lands. Never run `git rebase`, `git add`, `git commit`, `git checkout`, `git reset`, or the test command yourself. Paths under `internal/assets/embedded/` (the generated bundle: `manifest.json` and everything under `tree/`) are controller-owned derived outputs — never hand-merge them and never list them in `conflicted_paths`; resolve and report the authored inputs only, and the finalize controller regenerates the bundle itself. +Your dispatch payload names the repository's visibility (`Visibility: <shared|private>`). In a private repository, every line you write into a resolved hunk follows the convention's private-repository writing rule — never a change id or docket wording; when a hunk cannot be reconciled without breaking it, return `disposition: stuck` naming it. + Return your work as a structured **ResolverReport** JSON document (the controller feeds it to the `finalize.rebase-continue` or `rebase-abort` operation via `--input`; it is an authored hint that Go re-verifies against the live unmerged set before staging anything). Emit exactly these fields: - `change_id` (integer) — the change id from your dispatch. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index ebc2c75a1..e5b3752a3 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -203,8 +203,8 @@ var skillBudgets = []skillBudget{ {"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/<name>.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, 6020}, // 0532: the private-repository writing rule and visibility hand-off (239/6000 -> 239/6020); 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-finalize-change/SKILL.md", 241, 6033}, // 0532 review: both finalize gate dispatches carry the Visibility payload line (239/6020 -> 241/6033); 0532: the private-repository writing rule and visibility hand-off (239/6000 -> 239/6020); 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", 162, 2111}, // 0532 review: the gate-agent payload names Visibility (161/2109 -> 162/2111); 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, 2141}, // 0532: the private-repository writing rule and visibility hand-off (76/2109 -> 76/2141); 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", 215, 8411}, // 0532: the private-repository writing rule and visibility hand-off (212/8282 -> 215/8411); 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, 1265}, // 0532: the private-repository writing rule and visibility hand-off (109/1226 -> 109/1265); 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) diff --git a/internal/repoguard/private_writing_rule_test.go b/internal/repoguard/private_writing_rule_test.go index 4401657f5..2260f362e 100644 --- a/internal/repoguard/private_writing_rule_test.go +++ b/internal/repoguard/private_writing_rule_test.go @@ -4,6 +4,9 @@ import ( "regexp" "strings" "testing" + + "github.com/danielhanold/docket/internal/assets" + "github.com/danielhanold/docket/internal/harness" ) // TestPrivateWritingRuleReachesEveryWriter guards the private-repository writing @@ -102,3 +105,99 @@ func TestPrivateWritingRuleReachesEveryWriter(t *testing.T) { } } } + +// visibilityDispatchLine is the payload line that hands a feature-branch writer +// the repository's visibility. +const visibilityDispatchLine = "Visibility: <shared|private>" + +// TestPrivateWritingRuleReachesEveryFeatureWriter derives the writer set rather +// than listing it: every feature-scoped role whose description does not declare +// it read-only writes text that ships through the feature branch. Each such role +// must state the rule and the `Visibility:` payload field in one source of its +// own instructions (its body, or one preloaded skill — docket-convention alone +// defines the rule but never tells a role to read its payload), and every +// marker-delimited dispatch naming it must carry the raw visibility line. +func TestPrivateWritingRuleReachesEveryFeatureWriter(t *testing.T) { + catalog, err := assets.EmbeddedCatalog() + if err != nil { + t.Fatal(err) + } + sources, err := harness.ParseInventory(catalog) + if err != nil { + t.Fatal(err) + } + writers := map[string]bool{} + scoped := map[string]bool{} + for _, src := range sources { + if src.WorktreeScope != harness.WorktreeScopeFeature { + continue + } + scoped[src.Name] = true + if strings.Contains(strings.ToLower(src.Description), "read-only") { + continue + } + writers[src.Name] = true + ownTexts := []string{readRepoFile(t, "agents/"+src.Name+".md")} + for _, skill := range src.Skills { + ownTexts = append(ownTexts, readRepoFile(t, "skills/"+skill+"/SKILL.md")) + } + if !namesRuleAndVisibility(ownTexts) { + t.Errorf("%s writes on the feature branch but no single source of its instructions names both %q and the %s", src.Name, "Visibility:", privateRulePhrase) + } + } + if len(writers) < 4 { + t.Fatalf("derived feature-writer set shrank to %d roles: %v", len(writers), writers) + } + + root := guardRoot(t) + dispatched := map[string]bool{} + for _, rel := range maintainedPop(t, root) { + if !strings.HasPrefix(rel, "skills/") || !strings.HasSuffix(rel, ".md") { + continue + } + sites, _ := parseFeatureDispatches(rel, readMaintained(t, root, rel), scoped) + for _, site := range sites { + for _, target := range site.targets { + if !writers[target] { + continue + } + dispatched[target] = true + if !hasExactLine(site.lines, visibilityDispatchLine) { + t.Errorf("%s:%d-%d dispatch of %s lacks raw line %q", site.rel, site.start, site.end, target, visibilityDispatchLine) + } + } + } + } + for w := range writers { + if !dispatched[w] { + t.Errorf("feature writer %s has no marker-delimited dispatch to check", w) + } + } + + t.Run("non_vacuity", func(t *testing.T) { + if namesRuleAndVisibility([]string{"Visibility: private", "the " + privateRulePhrase}) { + t.Error("rule and payload field split across sources was accepted") + } + if !namesRuleAndVisibility([]string{"Payload says `Visibility: private`; follow the Private-Repository\nwriting rule."}) { + t.Error("hard-wrapped rule beside the payload field was rejected") + } + }) +} + +func namesRuleAndVisibility(texts []string) bool { + for _, text := range texts { + if strings.Contains(text, "Visibility:") && strings.Contains(normalizeProse(text), privateRulePhrase) { + return true + } + } + return false +} + +func hasExactLine(lines []string, want string) bool { + for _, line := range lines { + if line == want { + return true + } + } + return false +} diff --git a/skills/docket-finalize-change/SKILL.md b/skills/docket-finalize-change/SKILL.md index 3aba56ade..470384ed9 100644 --- a/skills/docket-finalize-change/SKILL.md +++ b/skills/docket-finalize-change/SKILL.md @@ -95,6 +95,7 @@ When the effective base merely advances under a **completed, clean** owned rebas - `reserved` — authorizes exactly ONE resolver dispatch. Its payload includes the returned `reservation` token and contains: Dispatch `docket-rebase-resolver` foreground at the model/effort its wrapper resolves. Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> The resolver echoes the token back as the report's `resolver_reservation` field. It edits only the conflicted regions in the returned workspace and returns a versioned `ResolverReport` (fields in step 3 of *The two agents* in `references/gate-failure.md`) — it never runs the rebase mechanics or the suite. In Docket's own repository, conflicts under `internal/assets/embedded/` are derived outputs the controller clears by regenerating the bundle: generated-only stops never reach this reserve step (the operation clears them itself, spending no budget), and on a mixed stop the resolver reports only the authored paths while `finalize.rebase-continue` regenerates and stages the bundle in the same continue. - `pending` — a reservation is already outstanding: dispatch NOTHING new. If the child it belongs to is yours and identifiable, wait for it or feed its matching report to `finalize.rebase-continue`; if you cannot establish dispatch ownership, this is `halted` with the reservation retained. - `exhausted` (`resolver-budget-exhausted`) — the configured `finalize.resolver_max_attempts` budget (default 10) is spent: `halted` via the abort flow below. Raising the limit takes effect on the next explicit finalize attempt, never this receipt. @@ -119,8 +120,9 @@ The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebas A red suite after the rebase is repair work, regardless of cause. Dispatch `docket-integration-repair` foreground at the model/effort its wrapper resolves. Its payload contains: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> Repair-attempt budget: <finalize.repair_max_attempts from the startup-check prepared context> (initial attempt included) -Red-test root cause, feature-branch fix bounded to the dispatched repair-attempt budget, and claimed commits plus `repaired`/`stuck`; it never rebases, merges, or transitions metadata. +Both `Visibility` lines carry the startup-check prepared context's `visibility`. Red-test root cause, feature-branch fix bounded to the dispatched repair-attempt budget, and claimed commits plus `repaired`/`stuck`; it never rebases, merges, or transitions metadata. <!-- docket:feature-dispatch:end --> Then re-gate the repaired head through the gate driver — a drive you start yourself, separate from the gate `finalize.rebase` composes: the `gate.drive.start` operation with `--repo-dir <feature worktree> --owner finalize --change-id <id> --run-root <dir> --json`. Capture that first JSON response into `gate_reply` and read the drive id and owner generation from it — never into a zsh read-only special parameter such as `status`. Then run the `gate.drive.advance` operation with `--drive-id <id> --owner-gen <gen>`, one slice per call, until a terminal disposition, under `docket-build`'s gate-run posture (a dispatched agent never yields). The `--owner finalize` drive runs `finalize.test_command` and charges no build attempt. A `PASSED` drive whose head equals the repaired head feeds the `evidence.record` operation with `--owner finalize --id <id> --run <raw run dir from the PASSED document> --head <repaired head>`, which records `finalize.test_command` (the command that ran) and returns the immutable block — no agent-supplied `passed` boolean. `FAILED` returns to repair within the configured `finalize.repair_max_attempts` budget; `HALTED`, a repair that cannot reach green within that budget, or unavailable repair dispatch is `halted`. The raw `gate.launch`/`gate.observe` operations are operator primitives, never this re-gate. diff --git a/skills/docket-finalize-change/references/gate-failure.md b/skills/docket-finalize-change/references/gate-failure.md index c197ea055..52e3c46c0 100644 --- a/skills/docket-finalize-change/references/gate-failure.md +++ b/skills/docket-finalize-change/references/gate-failure.md @@ -12,6 +12,7 @@ or tests; `docket-integration-repair` owns the **red suite** *after* the rebase cause. Neither wraps a skill (only `docket-convention`); both are dispatched **foreground at the model/effort its wrapper resolves** — never a literal model or effort. Either dispatch payload includes: Feature worktree: <absolute canonical feature-worktree root> +Visibility: <shared|private> This harness-neutral input serves the feature-scoped role; Codex enters it through its installed contract, other harnesses through their native worktree mechanism.