From 4547c1648ba92043bc517fe78a19356e435e9192 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 23:01:43 -0400 Subject: [PATCH 01/28] docs(plan): change 0530 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch.md --- ...d-build-evidence-on-the-metadata-branch.md | 1499 +++++++++++++++++ 1 file changed, 1499 insertions(+) create mode 100644 docs/superpowers/plans/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch.md diff --git a/docs/superpowers/plans/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch.md b/docs/superpowers/plans/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch.md new file mode 100644 index 000000000..552bbffc2 --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch.md @@ -0,0 +1,1499 @@ + +> ↩ **[Change 0530 — Keep plan, results, and build evidence on the metadata branch, and ship the spec with the PR](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0530-keep-plan-results-and-build-evidence-on-the-metadata-branch.md)** + +# Keep Plan, Results, and Build Evidence on the Metadata Branch — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. In this repository the plan is executed by `docket-build`. + +**Goal:** Plan files, results files, and build evidence live on the `docket` metadata branch (plan and results at their existing paths, evidence in a `## Build evidence` section of the change record); the feature branch's first commit is a copy of the spec, so a PR carries exactly the spec copy plus code; same-branch links become relative; the post-merge backlink commit to `main`, the `Docket-Plan-Path` trailer, `artifact.backlink`, and `finalize.skip_results_only_delta` are retired. + +**Architecture:** Five moves, sequenced so every intermediate commit builds and passes. (1) A new app-layer helper set (`internal/app/evidence_section.go`) reads and writes the existing evidence codec's block inside an operation-owned `## Build evidence` record section; writers move onto it first (`change.mark-implemented`, then `finalize.publish` / `evidence.recertify` dual-writing), then every reader switches (merge gate, clear-block, rebase no-op skip, `run.verify`), then the PR-body writes are deleted. (2) A new local-write operation `workspace.commit-spec` commits the spec copy as the feature branch's first commit. (3) `change.attach-plan` and then `change.attach-results` become metadata transactions that carry the artifact Markdown (`--markdown `), write the file on `docket`, stamp its backlink, and set the field in one commit; every reader of those files moves to the metadata tip. (4) The renderer emits relative links for same-branch rows and file backlinks (PR-body backlinks stay absolute), with a legacy rule that keeps pre-cutover plan/results rows absolute; the existing `repository check`/`repair` artifact-links drift machinery performs the one-time conversion. (5) The integration-branch backlink legs and `artifact.backlink` are deleted, a guard pins "no operation pushes the integration branch outside a PR merge", the deferred config key becomes an obsolete tombstone, and every skill, agent, embedded twin, and living doc is corrected. + +**Tech Stack:** Go (`internal/app`, `internal/render`, `internal/repository`, `internal/domain`, `internal/config`, `internal/cli`, `internal/repoguard`), real-git fixtures behind the `integration` build tag (shard prefixes only), the `e2e` tag for `internal/app/finalize_e2e_test.go`, the protocol-faithful fake `gh`, Markdown skills mirrored into `internal/assets/embedded/tree/` by `go generate ./internal/assets/`. + +**Spec:** `docs/superpowers/specs/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch-design.md` on the `docket` branch. Read it at `/Users/homer/dev/docket/.docket/docs/superpowers/specs/2026-10-05-keep-plan-results-and-build-evidence-on-the-metadata-branch-design.md`. The change record is `/Users/homer/dev/docket/.docket/docs/changes/active/0530-keep-plan-results-and-build-evidence-on-the-metadata-branch.md`. + +## Global Constraints + +- **Cutover (spec "Cutover").** This run itself is built with the pre-change binary and the pre-change flow: this plan and the results file ride this feature branch as today. No build task writes to the real `docket` branch, pushes anywhere, or runs the installed `docket` binary to exercise new behavior (the installed binary is the OLD one). Exercise new behavior only through `go test` or `go run ./cmd/docket`. +- **One location for every repository** (spec Decision 1). Nothing in this change branches on a visibility mode. Private visibility is never mentioned in any `docs/` page. The one seam #532 needs is `specCopyChangeLine` (Task 6): a single function that renders the copy's change line. +- **Paths do not change** (Decision 2): plan `docs/superpowers/plans/-.md`, results `/--results.md`, spec copy at the metadata spec's own path `docs/superpowers/specs/--design.md`. +- **Evidence block bytes are unchanged** (Decision 3): the record section's body is exactly `evidence.Render(rec)` (converted to the record's line ending). Do not change `internal/evidence` (its boundary guard `TestEvidenceImportsOnlyDocument` forbids importing `internal/render`; the section helpers live in `internal/app`). +- **No compatibility read of PR-body evidence** (Decision 7). After Task 5 no code reads or writes a `build-evidence` block in a PR body. +- **No new blocking gate.** The only new refusals are typed input/state refusals of the operations this change rewrites, each named in its task (`spec-path-occupied`, `artifact-path-mismatch`, `artifact-path-occupied`, `workspace-not-ready`, `spec-missing`). Nothing new can stop a close-out, cleanup, or sweep. +- **`pr.publish` writes no metadata.** The spec lists `pr.publish` among the evidence writers; this plan narrows that deliberately: `change.mark-implemented` already verifies the same evidence bytes at the same head in the same run and runs in a metadata transaction, every evidence reader runs after it, and a second record write at `pr.publish` would move the record revision under the run between publish and mark-implemented. `pr.publish` keeps verifying `--evidence` against the head (it still never opens a PR on an uncertified head) but writes the evidence nowhere. Record this narrowing in the results file. +- **Same-branch links are relative; cross-branch links are absolute** (Decision 6). PR row, `Spec (merged)` row, PR-body backlink, PR-body plan/results links: absolute. Everything else on the metadata branch: relative to the file that contains the link. +- **Docs describe current behavior only**, with no change or PR numbers (`TestLivingDocsAlignment`). ADR citations are allowed. Never hand-copy under `internal/assets/embedded/`; regenerate with `go generate ./internal/assets/` in the same task that edits a mirrored file (`TestEmbeddedMatchesAuthored`). Harness goldens under `internal/harness/*/testdata/golden/` are regenerated with their tests' `-update` flag (read the flag's definition in that package's golden test before running it). +- **Skill size budgets** (`internal/repoguard/budgets_test.go`, `TestSkillSizeBudgets`): every edited skill file sits at its ceiling today. Prose tasks aim to be net-neutral or shrinking (deleting the trailer, delta, and staleness prose frees room). If a file must grow, re-baseline exactly that row with a comment giving the reason, never a blanket bump. +- **Finding codes** are minted from named constants, never `Code: "..."` literals (`TestNoInlineFindingCodeLiterals`). When a task removes the last minting site of a reason constant, delete the constant and any registry row that lists it. +- **Cross-references** in maintained source anchor on symbol names or quoted clauses, never line numbers (`TestCommentAnchorStyle`). +- **Every mutation probe and re-verification uses `go test -count=1`** (learning `cached-runner-serves-a-mutated-tree`). Every mutation is restored from a backup copy (`cp f f.bak` … `mv -f f.bak f`), never `git checkout --` (learning `mutation-restore-needs-a-backup-copy`). After restoring, re-run the test green. +- **Integration tests run only through shard prefixes:** `go test -tags integration -count=1 -run '^' ./internal/app/`. Never run `./internal/app/` integration tests without `-run`. Prefixes used here all exist: `TestIntegrationWorkflowLifecycle`, `TestIntegrationWorkflowRepo`, `TestIntegrationChangeRuntime`, `TestIntegrationRecordOps`, `TestIntegrationEvidence`, `TestIntegrationFinalizeOps`, `TestIntegrationFinalizeState`, `TestIntegrationFinalizeMerge`, `TestIntegrationFinalizeRebaseGate`, `TestIntegrationFinalizeRebaseRecovery`, `TestIntegrationFinalizeRebaseForwardRefresh`, `TestIntegrationFinalizeCloseout`, `TestIntegrationFinalizeArchive`, `TestIntegrationFinalizeCleanup`, `TestIntegrationSweep`, `TestIntegrationRepoCheck`, `TestIntegrationRepoRepair`, `TestIntegrationNamed`, `TestIntegrationRunFence`, `TestIntegrationRunCompletion`. Do not add shards. The e2e file runs as `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/` (also driven by `tests/test_go_finalize_e2e.sh`). +- **Whole-repo greps, never memory** (AGENTS.md "Never hand-list the sites"): every task that removes or renames a symbol, flag, reason, or phrase ends with a `git grep -n -F -- ''` sweep over the whole tree, sorting hits into executable / test / maintained prose / point-in-time records (results, archived changes, specs, Accepted ADRs, `docs/superpowers/plans/`) / frozen `testdata/`. Only the first three are edited. Frozen `testdata/` corpora are never edited. +- **Shell (AGENTS.md):** capture into a variable before `grep`; `grep -F -- ''` for patterns starting with `--`; `mv -f`; templated `mktemp`. +- **Whole-suite gate:** `go run ./cmd/docket development test` (docket-build runs it once at the end). Read its budget report even when green. +- **ADR:** the spec's expected ADR ("build artifacts live on the metadata branch; the spec ships with the PR", relating to ADR-0001 and ADR-0066) is recorded by `docket-implement-next` Step 6 through `docket-adr`, not by any build task. No source comment cites the new ADR's number. + +## Review Focus + +1. **A spec whose first `# ` line sits inside a fenced code block, or a spec with no H1 at all.** A person expects the change line right after the real title, or at the very top when there is none, and never inside a code fence. Pinned in Task 6, `TestSpecCopyBytesSkipsFencedH1` and `TestSpecCopyBytesNoTitle`. +2. **CRLF change records and specs** (any file edited on Windows or through the GitHub web UI). A person expects the evidence section and the spec copy to keep the file's line endings, never a mixed file. Pinned in Task 1, `TestUpsertRecordEvidenceKeepsCRLF`, and Task 6, `TestSpecCopyBytesKeepsCRLF`. +3. **An artifact body resubmitted exactly as read back from the metadata worktree** (it still carries its `docket:backlink` block). A person expects the operation to replace that block, not duplicate it, and a body whose backlink markers are malformed to be refused untouched. Pinned in Task 7, `TestAttachPlanStripsSubmittedBacklink` and `TestAttachPlanRefusesMalformedBacklink`. +4. **A `## Build evidence` heading written inside a fenced code block of authored record prose.** A person expects it to be content, not the evidence section. Pinned in Task 1, `TestReadRecordEvidenceIgnoresFencedHeading`. +5. **A legacy archived record** (status `done`, no `## Build evidence` section) whose plan and results live on `main`. A person expects `repository repair` to convert its spec and ADR rows to relative links, keep its Plan/Results rows pointing at `main`, and a second repair to change nothing. Pinned in Task 10, `TestArtifactBlockLegacyDoneKeepsIntegrationRows`, and `TestIntegrationRepoRepairConvertsAbsoluteSameBranchLinksOnce`. + +--- + +## File Structure + +| File | Responsibility | Task | +|---|---|---| +| `internal/repository/decode.go`, `internal/domain/entities.go` | `HasBuildEvidence` presence predicate (shape-keyed like `HasRunHalted`) | 1 | +| `internal/app/evidence_section.go` (new) | read/verify/upsert the `## Build evidence` section; `recordEvidenceFacts`; `recordBuildEvidence` transaction | 1, 3 | +| `internal/app/change_implemented.go` | writes the section in the implemented transaction; results read from the metadata tip | 2, 8 | +| `internal/app/finalize_publish.go`, `internal/app/evidence_recertify.go` | write the section; stop editing the PR body | 3, 5 | +| `internal/app/finalize_merge.go`, `finalize_block.go`, `finalize_rebase.go`, `run_verify.go`, `workspace_ops.go` | read evidence from the record | 4 | +| `internal/app/pr_publish.go` | no evidence block; PR-body plan/results links block | 5, 10 | +| `internal/app/workspace_spec.go` (new), `internal/cli/workspace.go` | `workspace.commit-spec` | 6 | +| `internal/app/change_attach.go`, `internal/cli/change.go` | metadata-transaction attach ops | 7, 8 | +| `internal/app/status.go`, `run_verify.go` | plan/results existence on the metadata tip, integration fallback | 7, 8 | +| `internal/app/artifact_backlink.go`, `internal/cli/artifact.go` (deleted) | retired op | 9 | +| `internal/render/artifacts.go`, `internal/render/relpath.go` (new) | relative rows and backlinks, legacy rule, `Spec (merged)`, PR plan/results block | 10 | +| `internal/app/finalize_closeout.go`, `finalize_cleanup.go`, `maintenance_assess.go`, `finalize_backlink_loader.go` (deleted), `change_kill.go` | retire integration legs; kill retargets plan/results | 10, 11 | +| `internal/repoguard/integration_push_test.go` (new) | no integration-branch push outside a PR merge | 11 | +| `internal/config/schema.go`, `decode.go`, `.docket.yml` | obsolete tombstone for `finalize.skip_results_only_delta` | 12 | +| skills, agents, cursor-rules, embedded twins, goldens | prose | 14, 15 | +| `README.md`, `docs/guide/*`, `docs/concepts/*`, `docs/reference/*`, `docs/release/upgrading-from-bash.md` | living docs | 16 | + +--- + +### Task 1: The `## Build evidence` record section and the `HasBuildEvidence` predicate + +**Build tier:** standard + +**Files:** +- Modify: `internal/repository/decode.go` (the `presenceMarkers` const block and the line that sets `spec.HasRunHalted`) +- Modify: `internal/domain/entities.go` (the change spec struct field list next to `HasRunHalted`, and a `HasBuildEvidence` method next to `HasRunHalted()`) +- Create: `internal/app/evidence_section.go` +- Test: `internal/app/evidence_section_test.go` (new, untagged) +- Test: the existing decode test file that covers `HasRunHalted` (find it with `git grep -n 'HasRunHalted' -- 'internal/repository/*_test.go' 'internal/domain/*_test.go'`) + +**Interfaces:** +- Produces: + - `const buildEvidenceHeading = "## Build evidence"` (package `app`) + - `func ReadRecordEvidence(record []byte) (evidence.Record, error)` — `evidence.ErrMissing` when the section is absent + - `func VerifyRecordEvidence(record []byte, head string) evidence.Verdict` + - `func UpsertRecordEvidence(record []byte, rec evidence.Record) ([]byte, error)` — accepts green and skipped + - `func recordEvidenceFacts(record []byte) (head, command string, green bool)` + - `func (c domain.Change) HasBuildEvidence() bool` + +- [ ] **Step 1: Write the failing tests** + +`internal/app/evidence_section_test.go`: + +```go +package app + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/evidence" +) + +const esHead = "0123456789abcdef0123456789abcdef01234567" + +func esRecord() string { + return "---\nid: 7\nslug: x\n---\n\n## Artifacts\n\n\n\n\n## Why\n\nBecause.\n" +} + +func esGreen(t *testing.T) evidence.Record { + t.Helper() + r, err := evidence.NewRecord("go test ./...", esHead, time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + return r +} + +func TestUpsertRecordEvidenceAppendsSection(t *testing.T) { + rec := esGreen(t) + out, err := UpsertRecordEvidence([]byte(esRecord()), rec) + if err != nil { + t.Fatalf("upsert: %v", err) + } + want := esRecord() + "\n## Build evidence\n\n" + evidence.Render(rec) + "\n" + if string(out) != want { + t.Fatalf("upsert bytes:\n%q\nwant\n%q", out, want) + } + got, err := ReadRecordEvidence(out) + if err != nil || got != rec { + t.Fatalf("read back = %+v, %v; want %+v", got, err, rec) + } +} + +func TestUpsertRecordEvidenceReplacesInPlace(t *testing.T) { + first := esGreen(t) + src := esRecord() + "\n## Build evidence\n\n" + evidence.Render(first) + "\n\n## Reconcile log\n\n### 2026-10-05\n\nok\n" + second, _ := evidence.NewSkippedRecord(esHead, time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)) + out, err := UpsertRecordEvidence([]byte(src), second) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if !strings.Contains(string(out), "## Build evidence\n\n"+evidence.Render(second)+"\n\n## Reconcile log") { + t.Fatalf("section not replaced in place:\n%s", out) + } + if strings.Count(string(out), "## Build evidence") != 1 { + t.Fatalf("duplicate section:\n%s", out) + } + if !strings.HasSuffix(string(out), "## Reconcile log\n\n### 2026-10-05\n\nok\n") { + t.Fatalf("bytes after the section changed:\n%s", out) + } +} + +func TestUpsertRecordEvidenceKeepsCRLF(t *testing.T) { + crlf := strings.ReplaceAll(esRecord(), "\n", "\r\n") + out, err := UpsertRecordEvidence([]byte(crlf), esGreen(t)) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if strings.Contains(strings.ReplaceAll(string(out), "\r\n", ""), "\n") { + t.Fatalf("mixed line endings in a CRLF record:\n%q", out) + } +} + +func TestReadRecordEvidenceMissingAndFenced(t *testing.T) { + if _, err := ReadRecordEvidence([]byte(esRecord())); !errors.Is(err, evidence.ErrMissing) { + t.Fatalf("absent section err = %v, want ErrMissing", err) + } +} + +func TestReadRecordEvidenceIgnoresFencedHeading(t *testing.T) { + src := esRecord() + "\n```\n## Build evidence\n\n" + evidence.Render(esGreen(t)) + "\n```\n" + if _, err := ReadRecordEvidence([]byte(src)); !errors.Is(err, evidence.ErrMissing) { + t.Fatalf("fenced heading read as the section: err = %v", err) + } + if v := VerifyRecordEvidence([]byte(src), esHead); v != evidence.VerdictMissing { + t.Fatalf("verify fenced = %q, want missing", v) + } +} + +func TestVerifyRecordEvidenceVerdicts(t *testing.T) { + out, _ := UpsertRecordEvidence([]byte(esRecord()), esGreen(t)) + if v := VerifyRecordEvidence(out, esHead); v != evidence.VerdictVerified { + t.Fatalf("exact head = %q", v) + } + if v := VerifyRecordEvidence(out, strings.Repeat("f", 40)); v != evidence.VerdictStale { + t.Fatalf("other head = %q", v) + } + dup := string(out) + "\n## Build evidence\n\nx\n" + if v := VerifyRecordEvidence([]byte(dup), esHead); v != evidence.VerdictMalformed { + t.Fatalf("duplicated section = %q, want malformed", v) + } +} + +func TestRecordEvidenceFacts(t *testing.T) { + out, _ := UpsertRecordEvidence([]byte(esRecord()), esGreen(t)) + h, cmd, green := recordEvidenceFacts(out) + if h != esHead || cmd != "go test ./..." || !green { + t.Fatalf("facts = %q %q %v", h, cmd, green) + } + if h, cmd, green := recordEvidenceFacts([]byte(esRecord())); h != "" || cmd != "" || green { + t.Fatalf("absent facts = %q %q %v", h, cmd, green) + } +} +``` + +In the decode test file, add a case beside the `HasRunHalted` case: a record body carrying a bare `## Build evidence` line decodes with `HasBuildEvidence() == true`; one carrying it only inside a fenced block, or as `## Build evidence — note`, decodes `false`. + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 -run 'RecordEvidence|BuildEvidence' ./internal/app/ ./internal/repository/ ./internal/domain/` +Expected: FAIL — `undefined: UpsertRecordEvidence` (and `HasBuildEvidence` undefined). + +- [ ] **Step 3: Implement** + +`internal/repository/decode.go`: add `markerBuildEvidence = "## Build evidence"` to the `presenceMarkers` const block and set `spec.HasBuildEvidence = hasHeading(text, markerBuildEvidence)` next to `spec.HasRunHalted`. `internal/domain/entities.go`: add the field `HasBuildEvidence bool // "## Build evidence" body section present` next to `HasRunHalted`, and: + +```go +// HasBuildEvidence reports whether the body carries the operation-owned +// "## Build evidence" section — the durable build-evidence record that +// change.mark-implemented first writes. Its presence also marks a change built +// by the metadata-branch artifact flow (render's legacy-link rule keys on it). +func (c Change) HasBuildEvidence() bool { return c.spec.HasBuildEvidence } +``` + +`internal/app/evidence_section.go`: + +```go +package app + +import ( + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/render" +) + +// This file owns the change record's operation-owned "## Build evidence" +// section: the durable home of a change's build evidence. The section body is +// exactly the evidence codec's build-evidence block (evidence.Render), so the +// bytes a reader extracts are the bytes the PR description used to carry. The +// section is never authored: change.groom's owned-heading allowlist +// (render.ChangeOwnedHeadings) does not contain it. + +// buildEvidenceHeading is the exact H2 line of the section. +const buildEvidenceHeading = "## Build evidence" + +// recordEvidenceSection returns the section body, whether it is present, and an +// error for a duplicated heading. Headings inside fenced code are content +// (namedSectionBody scans fence-aware). +func recordEvidenceSection(record []byte) (string, bool, error) { + return namedSectionBody(record, buildEvidenceHeading) +} + +// ReadRecordEvidence extracts the record's build evidence. An absent section is +// evidence.ErrMissing; a duplicated section or a malformed block is an error. +func ReadRecordEvidence(record []byte) (evidence.Record, error) { + body, present, err := recordEvidenceSection(record) + if err != nil { + return evidence.Record{}, fmt.Errorf("record evidence: %w", err) + } + if !present { + return evidence.Record{}, evidence.ErrMissing + } + return evidence.Extract([]byte(body)) +} + +// VerifyRecordEvidence is evidence.Verify scoped to the record's section: missing +// when absent, malformed when the section is duplicated or its block unreadable. +func VerifyRecordEvidence(record []byte, head string) evidence.Verdict { + body, present, err := recordEvidenceSection(record) + if err != nil { + return evidence.VerdictMalformed + } + if !present { + return evidence.VerdictMissing + } + return evidence.Verify([]byte(body), head) +} + +// UpsertRecordEvidence makes rec (green or skipped) the whole body of the +// record's "## Build evidence" section, appending the section at EOF when it is +// absent and preserving every other byte. The record is re-normalized through +// the codec constructors, rendered in the record's own line ending, and the +// candidate must read back as exactly rec before any bytes are returned. +func UpsertRecordEvidence(record []byte, rec evidence.Record) ([]byte, error) { + var norm evidence.Record + var err error + switch rec.Result { + case evidence.ResultGreen: + norm, err = evidence.NewRecord(rec.Command, rec.Head, rec.RanAt) + case evidence.ResultSkipped: + norm, err = evidence.NewSkippedRecord(rec.Head, rec.RanAt) + default: + return nil, fmt.Errorf("record evidence: result %q is neither green nor skipped", rec.Result) + } + if err != nil { + return nil, err + } + doc, err := document.Parse(record) + if err != nil { + return nil, fmt.Errorf("record evidence: malformed record: %w", err) + } + block := evidence.Render(norm) + if le := doc.LineEnding(); le != "" && le != "\n" { + block = strings.ReplaceAll(block, "\n", le) + } + out, err := render.ApplySectionEdits(record, []string{buildEvidenceHeading}, + []render.SectionEdit{{Heading: buildEvidenceHeading, Intent: render.SectionReplace, Markdown: block}}) + if err != nil { + return nil, fmt.Errorf("record evidence: %w", err) + } + got, err := ReadRecordEvidence(out) + if err != nil { + return nil, fmt.Errorf("record evidence: candidate failed reparse: %w", err) + } + if got != norm { + return nil, fmt.Errorf("record evidence: candidate reparsed to a different record") + } + return out, nil +} + +// recordEvidenceFacts is the record-section reader the finalize gates share: the +// recorded head and command, and whether the record is green. Any read failure +// — absent, duplicated, malformed — reads as no green evidence. +func recordEvidenceFacts(record []byte) (head, command string, green bool) { + rec, err := ReadRecordEvidence(record) + if err != nil { + return "", "", false + } + return rec.Head, rec.Command, rec.Result == evidence.ResultGreen +} +``` + +If `document.Document.LineEnding()` returns `""` for a single-line file, the `le != ""` guard keeps LF. If `TestUpsertRecordEvidenceAppendsSection`'s expected bytes differ only in the blank-line layout `render.ApplySectionEdits` produces (read `appendSection`), adjust the expectation to that layout — never the helper — and keep the assert byte-exact. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test -count=1 -run 'RecordEvidence|BuildEvidence' ./internal/app/ ./internal/repository/ ./internal/domain/` +Expected: PASS. + +- [ ] **Step 5: Mutation-test the fence guard** + +`cp internal/app/evidence_section.go internal/app/evidence_section.go.bak`; replace the body of `recordEvidenceSection` with a naive `strings.Index(string(record), buildEvidenceHeading)` slice; run `go test -count=1 -run 'IgnoresFencedHeading' ./internal/app/` — expect FAIL; `mv -f internal/app/evidence_section.go.bak internal/app/evidence_section.go`; re-run — PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/repository/decode.go internal/domain/entities.go internal/app/evidence_section.go internal/app/evidence_section_test.go +git commit -m "feat(evidence): the change record's ## Build evidence section" +``` + +--- + +### Task 2: `change.mark-implemented` records the evidence it verifies + +**Build tier:** standard + +**Files:** +- Modify: `internal/app/change_implemented.go` (`ChangeMarkImplemented`, `changeImplementedOp`, `(o changeImplementedOp) Plan`) +- Test: `internal/app/change_implemented_test.go` (`TestMarkImplementedApplies`) +- Test: `internal/app/change_implemented_integration_test.go` (`TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence`) + +**Interfaces:** +- Consumes: `UpsertRecordEvidence`, `ReadRecordEvidence` (Task 1). +- Produces: after an applied `change.mark-implemented`, the committed record carries `## Build evidence` holding exactly the `--evidence` record. Later tasks rely on this. + +- [ ] **Step 1: Write the failing tests** + +In `TestMarkImplementedApplies`, after the applied assertion, read the committed record bytes from the metadata remote (the test already reads the record for its status/pr asserts — reuse that read) and add: + +```go +gotEv, err := ReadRecordEvidence(recordBytes) +if err != nil { + t.Fatalf("implemented record carries no readable build evidence: %v", err) +} +wantEv, _ := evidence.Extract(evidenceBytes) // the bytes the test passed as EvidenceRecord +if gotEv != wantEv { + t.Fatalf("recorded evidence = %+v, want %+v", gotEv, wantEv) +} +``` + +In `TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence`, assert the same for the skipped record (`gotEv.Result == evidence.ResultSkipped`, `gotEv.Reason == evidence.ReasonBuildGateOff`). + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -count=1 -run 'TestMarkImplementedApplies' ./internal/app/` and `go test -tags integration -count=1 -run '^TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence' ./internal/app/` +Expected: FAIL — `evidence: no build-evidence block`. + +- [ ] **Step 3: Implement** + +In `ChangeMarkImplemented`, right after the condition-3 `evidence.Verify` check, extract the record once (`rec, err := evidence.Extract(req.EvidenceRecord)`; on error refuse `ReasonImplementedEvidenceUnverified`, the same fail-closed shape `FinalizePublish` uses). Add `evidence evidence.Record` to `changeImplementedOp` and set it when building `txOp`. In `Plan`, after `finalBytes` is computed and before `files` is built: + +```go +// The implemented transition records the evidence it just verified as the +// record's durable "## Build evidence" section, in this same commit. +finalBytes, err = UpsertRecordEvidence(finalBytes, o.evidence) +if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("mark-implemented: recording build evidence: %w", err) +} +``` + +Update the `changeImplementedOp` doc comment to name the section among the owned writes. + +- [ ] **Step 4: Run to verify pass** + +Same commands as Step 2, then the shard: `go test -tags integration -count=1 -run '^TestIntegrationRecordOps' ./internal/app/` and `go test -tags integration -count=1 -run '^TestIntegrationChangeRuntime' ./internal/app/`. +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add internal/app/change_implemented.go internal/app/change_implemented_test.go internal/app/change_implemented_integration_test.go +git commit -m "feat(mark-implemented): record the verified build evidence in the change record" +``` + +--- + +### Task 3: `finalize.publish` and `evidence.recertify` also write the record section + +**Build tier:** premium — the finalize publication order and its CAS against a concurrently moving metadata branch are consequential. + +**Files:** +- Modify: `internal/app/evidence_section.go` (add the transaction helper) +- Modify: `internal/app/finalize_publish.go` (`FinalizePublish`) +- Modify: `internal/app/evidence_recertify.go` (`publishRecertifiedEvidence`, and the `build.gate: off` branch of `EvidenceRecertify`) +- Modify: `internal/cli/finalize.go` (`newFinalizePublishSubcommand` capability + its "opens no metadata transaction" comment), `internal/cli/evidence.go` (`evidence.recertify` capability) +- Test: `internal/app/evidence_section_test.go`, `internal/app/finalize_publish_integration_test.go`, `internal/app/evidence_recertify_integration_test.go`, `internal/app/finalize_state_integration_test.go` + +**Interfaces:** +- Consumes: `UpsertRecordEvidence` (Task 1). +- Produces: + +```go +// recordEvidenceOutcome is the folded result of one record-evidence write. +type recordEvidenceOutcome struct { + Result Result // ResultApplied, ResultNoOp, ResultContended, or a failure class + Reason string + Message string + Revision string // the applied metadata commit, when applied +} + +// recordBuildEvidence writes rec into change id's "## Build evidence" section in +// one exact-revision metadata transaction keyed on the record blob it reads. +func recordBuildEvidence(ctx context.Context, deps PlanningDeps, repoDir, opKey string, id int, rec evidence.Record) recordEvidenceOutcome +``` + +- [ ] **Step 1: Write the failing tests** + +1. `TestIntegrationFinalizeOpsFinalizePublishAcceptsSkippedEvidence` and `TestIntegrationFinalizeStatePublishOrder`: after the publish, read the record at the metadata remote tip and assert `ReadRecordEvidence` equals the published record (the PR-body assertions stay in this task; they leave in Task 5). +2. `TestIntegrationEvidenceEvidenceRecertifyHappyPath`: assert the record section equals the recertified record. `TestIntegrationEvidenceEvidenceRecertifyGateOffRecordsSkipped`: assert the record section holds the skipped record (until now gate-off wrote nothing durable). +3. New `TestIntegrationFinalizeStatePublishRecordEvidenceContended` (prefix `TestIntegrationFinalizeState`): advance the change record on the metadata remote between the publish's corpus read and its transaction (use the same injection seam the existing `...PublishCrashReplay` test uses to interpose; if none fits, advance the record before the call and stub the pin so the publish reads the stale revision) and assert the result is `contended` with the record untouched. +4. A unit test `TestRecordBuildEvidenceIsIdempotent` beside the Task 1 tests if the package already has a fake-engine harness for transactions (look for the fake `Engine` used in `change_attach_test.go`); otherwise cover idempotency in the integration test by calling publish twice and asserting the second record write is a no-op (no new metadata commit). + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -tags integration -count=1 -run '^TestIntegrationFinalizeOps' ./internal/app/`, `... -run '^TestIntegrationFinalizeState' ...`, `... -run '^TestIntegrationEvidence' ...` +Expected: the new record assertions FAIL. + +- [ ] **Step 3: Implement `recordBuildEvidence`** + +Append to `internal/app/evidence_section.go`: + +```go +// recordEvidenceOp is the SemanticOperation recordBuildEvidence drives. It edits +// only the change record's "## Build evidence" section; no board-visible field +// changes, so no board render is included. An unchanged section is the engine's +// clean no-op path. +type recordEvidenceOp struct { + opKey string + changeID int + rec evidence.Record +} + +func (o recordEvidenceOp) Key() transaction.OperationKey { return transaction.OperationKey(o.opKey) } + +func (o recordEvidenceOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { + c, out := st.State.Snapshot.Change(domain.ChangeID(o.changeID)) + if out != domain.LookupFound { + return refuseLifecycle(FindingCode(ReasonImplementedUnknownChange), fmt.Sprintf("change %04d is not a single record in the current corpus", o.changeID)) + } + src, ok := st.State.Sources[c.Path()] + if !ok { + return refuseLifecycle(FCPathMismatch, fmt.Sprintf("no record source loaded at %q for change %04d", c.Path(), o.changeID)) + } + updated, err := UpsertRecordEvidence(src, o.rec) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + receipt, err := json.Marshal(struct { + Head string `json:"head"` + ID int `json:"id"` + Op string `json:"op"` + }{Head: o.rec.Head, ID: o.changeID, Op: o.opKey}) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + var files []transaction.FileMutation + if !bytes.Equal(updated, src) { + files = append(files, transaction.FileMutation{Path: gitcli.RepoPath(c.Path()), Kind: transaction.MutationReplace, Bytes: updated}) + } + return transaction.MutationPlan{ + Files: files, + CommitSubject: fmt.Sprintf("change %04d build evidence recorded", o.changeID), + Receipt: receipt, + }, transaction.OperationResult{}, nil +} +``` + +`recordBuildEvidence` pins (`deps.Reader.PinContext`), reads the corpus once and resolves the change, its path, and its blob revision the same way `resolveImplementedChange` does (reuse that function — it already returns `(change, recPath, revision, refusal)`; map its refusal's first finding code into `Reason`), discovers the repository, and runs `deps.Engine.Execute` with `TargetRef` `branchRefPrefix + reposetup.MetadataBranchName`, `Expected` = the record path at that blob revision, `Loader: newPlanningLoader(eff)`, `Scope: changeScope(id, recPath, false)`, `Operation: recordEvidenceOp{...}`. Fold the outcome with `mapOutcome(res, execErr, ResultInvalidState)`; set `Revision` from `res.AppliedCommit` when applied. Receipt fields are alphabetical (the engine's receipt validator requires the sorted compact form, as `changeAttachReceipt` notes). + +- [ ] **Step 4: Call it from the two writers** + +`FinalizePublish`: after `publishResultFromEnsure` would return a success disposition (published or noop), call `recordBuildEvidence(ctx, deps.Planning, repoDir, OperationFinalizePublish, id, rec)`. A `contended` record write returns `PublishDispContended` with `Reason: ReasonPublishRecordContended` (new constant `"record-evidence-contended"`); any other non-applied/no-op result returns `PublishDispUnknown` with `Reason: ReasonPublishRecordFailed` (new constant `"record-evidence-failed"`) and the message. When the PR edit was a noop but the record write applied, the disposition is `published`. + +`EvidenceRecertify`: in `publishRecertifiedEvidence`, after the PR ensure succeeds, call `recordBuildEvidence(..., OperationEvidenceRecertify, id, rec)` and map failures to new reasons `ReasonRecertifyRecordContended` / `ReasonRecertifyRecordFailed` on the existing contended/unknown result shapes. In the `build.gate: off` branch, write the minted skipped record with `recordBuildEvidence` (this is now its durable home) and report applied/no-op from it. + +Capabilities: `finalize.publish` → `EffectExternalWrite, EffectMetadataWrite`; `evidence.recertify` → `EffectExternalWrite, EffectMetadataWrite` (it still edits the PR until Task 5). Replace the "opens no metadata transaction" comment and recertify's "no Docket metadata mutation" file-header sentence with the truth. Update any capability-catalog golden or effects table test the catalog change reddens (`go test -count=1 ./internal/cli/ ./internal/repoguard/` names them). + +- [ ] **Step 5: Run to verify pass** + +Run the three shards from Step 2 plus `go test -count=1 ./internal/cli/ ./internal/app/`. +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/evidence_section.go internal/app/evidence_section_test.go internal/app/finalize_publish.go internal/app/evidence_recertify.go internal/cli/finalize.go internal/cli/evidence.go +git commit -m "feat(finalize): publish and recertify record build evidence in the change record" +``` + +--- + +### Task 4: Every evidence reader reads the record section + +**Build tier:** premium — the finalize merge gate decides whether code merges. + +**Files:** +- Modify: `internal/app/workspace_ops.go` (`workspaceContext`, `loadWorkspaceContext`: carry the record bytes from the same corpus read that yields `revision`) +- Modify: `internal/app/finalize_merge.go` (`mergeContext` gains `record []byte` from `loadMergeContext`'s corpus read; the `prBodyEvidence(pr)` call in `FinalizeMerge`) +- Modify: `internal/app/finalize_block.go` (`FinalizeClearBlock`'s `prBodyEvidence(prs[0])` call) +- Modify: `internal/app/finalize_rebase.go` (`rebaseContext` gains `record []byte` from `loadRebaseContext`'s workspace context; `composeLocalGate`'s `prBodyEvidence(pr)` call; delete `prBodyEvidence`) +- Modify: `internal/app/run_verify.go` (evidence condition; the `ReasonRunEvidenceUnverified` doc comment) +- Test: `internal/app/finalize_rebase_test.go` (`TestPRBodyEvidenceReportsCommandAndGreenFlag` → `TestRecordEvidenceFactsReportsCommandAndGreenFlag`), `finalize_merge_integration_test.go`, `finalize_state_integration_test.go` (`TestIntegrationFinalizeStateClearBlockReprobes`), `finalize_rebase_integration_test.go`, `run_verify_integration_test.go`, `change_integration_test.go` (`TestIntegrationChangeRuntimeRunVerify*`), `run_verify_test.go` + +**Interfaces:** +- Consumes: `recordEvidenceFacts`, `VerifyRecordEvidence` (Task 1); the section written by Tasks 2–3. +- Produces: `workspaceContext.record`, `mergeContext.record`, `rebaseContext.record` (`[]byte`, the change record's bytes from the one pinned corpus read — decide and act on the same copy). + +- [ ] **Step 1: Convert the fixtures and write the parity tests** + +Every test that seeds evidence in a fake or real PR body for these four readers now seeds it in the change record's `## Build evidence` section instead (build the record with `UpsertRecordEvidence` before writing it to the metadata branch fixture) and leaves the PR body without a block. Keep each test's original expectation (pass/fail, reason) — that is the parity claim (spec acceptance 3). Specifically: + +- `TestIntegrationFinalizeMergeConditionAssembly`: the `gate-no-evidence` case seeds no section; `gate-stale-evidence` seeds a section at another head; the happy case seeds green at the head. Add a case `gate-evidence-only-in-pr-body`: green block in the PR body, no record section → `gate-unsatisfied` (no compatibility read, Decision 7). +- `TestIntegrationFinalizeMergeConditionsRechecked`: the "sets `Body = ""`" step becomes "removes the record section" (rewrite the record on the metadata remote without the section). +- `TestIntegrationFinalizeStateClearBlockReprobes`: the `ReasonClearEvidenceUnverified` row seeds a stale record section. +- Rebase gate tests that rely on the no-op skip (`TestIntegrationFinalizeRebaseGateOutcomes` and the others the evidence report names) seed record evidence; the gate-decision unit tests (`TestGateDecision*`) are unchanged. +- `TestIntegrationChangeRuntimeRunVerify*` and `TestIntegrationEvidenceRunVerifyAcceptsSkippedEvidenceAtExactHead`: seed the record section. Add `TestIntegrationEvidenceRunVerifyEvidenceReadsRecordNotPR`: green block in the PR body only → `run-incomplete` with `evidence-unverified`. +- Rename/rewrite `TestPRBodyEvidenceReportsCommandAndGreenFlag` to drive `recordEvidenceFacts` over a record built with `UpsertRecordEvidence` (green → command and `true`; skipped → empty command and `false`). + +- [ ] **Step 2: Run to verify failure** + +Run each shard: `TestIntegrationFinalizeMerge`, `TestIntegrationFinalizeState`, `TestIntegrationFinalizeRebaseGate`, `TestIntegrationFinalizeRebaseRecovery`, `TestIntegrationFinalizeRebaseForwardRefresh`, `TestIntegrationEvidence`, `TestIntegrationChangeRuntime`; and `go test -count=1 -run 'RecordEvidenceFacts|RunVerify' ./internal/app/`. +Expected: FAIL — readers still consult the PR body. + +- [ ] **Step 3: Implement** + +- `loadWorkspaceContext`: where it scans `blobs` for `b.Path == c.Path()` to set `revision`, also keep `record = b.Data`; add `record []byte` to `workspaceContext` and set it. +- `loadRebaseContext`: copy `wc.record` into `rebaseContext.record`. +- `composeLocalGate`: `evidenceHead, evidenceCommand, evidenceGreen := recordEvidenceFacts(rc.record)`. Update the surrounding comments ("The PR-body evidence skip" → "The record-evidence skip"; spec §4 wording stays). +- `loadMergeContext`: capture the record bytes from its single corpus read into `mergeContext.record`; `FinalizeMerge`: `evHead, _, evGreen := recordEvidenceFacts(mc.record)`. +- `FinalizeClearBlock`: read the record bytes from the same corpus read that gives it the record revision it pins, and call `recordEvidenceFacts(record)`; change its refusal message to "the change record's build evidence does not verify green for the exact current head; the marker stays". +- `RunVerify`: take the record bytes from `blobs` (the entry whose `Path == c.Path()`); replace the PR-body `evidence.Verify` with `if v := VerifyRecordEvidence(record, head); v != evidence.VerdictVerified && v != evidence.VerdictSkipped { add(ReasonRunEvidenceUnverified, string(v)) }`, evaluated once, independent of the PR count (delete the `"no unique pull-request body to read evidence from"` add). Update `ReasonRunEvidenceUnverified`'s comment to "the durable build evidence (the change record's ## Build evidence section)". +- Delete `prBodyEvidence`. `git grep -n 'prBodyEvidence\|pr\.Body' -- internal/app` afterwards: no evidence read of a PR body may remain (the `pr_backlink_repoint.go` body edits are backlink, not evidence — leave them). + +- [ ] **Step 4: Run to verify pass** + +Re-run every command from Step 2. Expected: PASS. + +- [ ] **Step 5: Mutation-test the reads (spec acceptance 3)** + +For each of `FinalizeMerge`, `composeLocalGate`, `FinalizeClearBlock`, `RunVerify`, one at a time: back up the file, replace the record read with a constant "green at the requested head" (`evHead, evGreen = req.Head, true` / the equivalent), run that reader's shard with `-count=1` — expect at least one FAIL naming the missing or stale case — restore with `mv -f`, re-run green. Record the four readings for the results file. + +- [ ] **Step 6: Commit** + +```bash +git add internal/app/workspace_ops.go internal/app/finalize_merge.go internal/app/finalize_block.go internal/app/finalize_rebase.go internal/app/run_verify.go +git commit -m "feat(finalize): merge gate, clear-block, rebase skip, and run verify read the record's build evidence" +``` + +--- + +### Task 5: Nothing writes build evidence into a PR body + +**Build tier:** standard + +**Files:** +- Modify: `internal/app/pr_publish.go` (`assemblePRBody` drops its `rec` parameter and the `evidence.Upsert` call; `PRPublish` keeps the `--evidence` verify/extract as a head gate) +- Modify: `internal/app/finalize_publish.go` (delete the `evidence.Upsert` + `EnsurePullRequest` block; the record write from Task 3 becomes the publication; delete `finalizePublishEnsurer` if unused, `ReasonPublishBodyAssembly`, `ReasonPublishEnsurerUnavailable`; rewrite `publishResultFromEnsure` into a mapper over the rewrite outcome and the record outcome) +- Modify: `internal/app/evidence_recertify.go` (delete the PR-body upsert/ensure in `publishRecertifiedEvidence`; the record write is the publication) +- Modify: `internal/cli/pr.go`, `internal/cli/finalize.go`, `internal/cli/evidence.go` (comments: `evidence.record`'s "becomes the durable record only later, at `pr publish`" → "at `change mark-implemented`"; `evidence.recertify` capability → `EffectMetadataWrite` only) +- Test: `internal/app/pr_publish_test.go`, `pr_publish_integration_test.go`, `change_integration_test.go` (`TestIntegrationChangeRuntimePRPublishBodyAssembly`), `finalize_publish_helpers_test.go`, `finalize_publish_integration_test.go`, `finalize_state_integration_test.go`, `evidence_recertify_integration_test.go`, `runtracker_fence_integration_test.go` + +**Interfaces:** +- Consumes: `recordBuildEvidence` (Task 3). +- Produces: `func assemblePRBody(authored []byte, backlink string) ([]byte, error)` (Task 10 adds a third parameter). +- `FinalizePublish` dispositions: `published` when the rewrite published or the record write applied; `noop` when the rewrite was a noop and the record write a no-op; `contended`/`unknown` as Task 3 mapped them. The PR is still probed (exactly one open PR whose head equals the request head) before the record write, with the existing reasons. + +- [ ] **Step 1: Rewrite the assertions** + +- `TestIntegrationChangeRuntimePRPublishBodyAssembly`: the assembled body equals the authored body with the backlink block inserted, and `evidence.Extract(body)` returns `evidence.ErrMissing`. A pre-existing stale build-evidence block inside the AUTHORED body is authored bytes and survives untouched (pin that). +- `TestIntegrationFinalizeOpsPRPublishAcceptsSkippedEvidenceAtExactHead`: a skipped record now publishes the PR (no `body-assembly-failed`); assert applied and no block in the body. +- `TestIntegrationFinalizeStatePublishOrder` and the `finalize_publish_helpers_test.go` fakes: no `EnsurePullRequest` call is made (`gh.ensLast` stays zero); the record section is the publication. +- Recertify tests (`...HappyPath`, `...EditContended`, `...EditFailureThenRetry`): the GitHub edit cases become record-write cases (contended = the record moved; failure-then-retry = the second call converges with a no-op or applied record write). Delete cases that only exercised the PR edit, recording each deletion's reason in the commit body. +- `TestIntegrationRunFencePRPublishJournalsPublicationIdentity`: the body digest is computed from the new assembled body; update the expected digest input, not the mechanism. + +- [ ] **Step 2: Run to verify failure** + +Run: shards `TestIntegrationChangeRuntime`, `TestIntegrationFinalizeOps`, `TestIntegrationFinalizeState`, `TestIntegrationEvidence`, `TestIntegrationRunFence`; `go test -count=1 ./internal/app/ ./internal/cli/`. +Expected: FAIL on the rewritten assertions. + +- [ ] **Step 3: Implement** + +Delete the writes listed under **Files**. `assemblePRBody` keeps inserting/replacing only the backlink block. In `FinalizePublish`, after the PR head check, call `recordBuildEvidence` and return `publishResultFromRecord(id, req.Head, rout, repo, pr, recOutcome)` — a new mapper replacing `publishResultFromEnsure` with the dispositions in **Interfaces**. In `publishRecertifiedEvidence`, after the second probe's head/PR/command checks, call `recordBuildEvidence` and map it. + +- [ ] **Step 4: Sweep** + +`git grep -n -F -- 'evidence.Upsert' -- internal/` → only `internal/evidence` and its tests remain. `git grep -n 'EnsurePullRequest' -- internal/app` → only `pr_publish.go` (PR creation) remains. Remove now-unused helpers the compiler or `go vet` flags. + +- [ ] **Step 5: Run to verify pass** + +Same as Step 2, plus `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/` (update its PR-body evidence expectations the same way). +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add -- internal/app internal/cli +git commit -m "feat(pr): build evidence no longer rides the PR description" +``` + +(Stage the exact files you changed by path; `git add -- internal/app internal/cli` is acceptable only if `git status --porcelain` shows nothing else under those directories.) + +--- + +### Task 6: `workspace.commit-spec` — the spec copy is the feature branch's first commit + +**Build tier:** premium — a new operation that commits on the feature branch. + +**Files:** +- Create: `internal/app/workspace_spec.go` +- Modify: `internal/app/workspace_ops.go` (`workspaceContext` gains `pin StatusPin`; `loadWorkspaceContext` sets it) +- Modify: `internal/app/schema_registry.go` (row `{ID: "workspace.commit-spec", Request: nil, Result: WorkspaceOpResult{}}`) +- Modify: `internal/cli/workspace.go` (subcommand `commit-spec`, flag `--id`, `capability("workspace.commit-spec", EffectLocalWrite)`), `internal/cli/install.go` (`"workspace commit-spec": true` in `assetIndependent`) +- Test: `internal/app/workspace_spec_test.go` (new, untagged: pure `specCopyBytes` tests) +- Test: `internal/app/workflow_integration_test.go` (new `TestIntegrationWorkflowLifecycleCommitSpec*` tests) +- Test: `internal/cli/workspace_test.go` (or the file that asserts the workspace subcommands are registered) + +**Interfaces:** +- Produces: + +```go +const OperationWorkspaceCommitSpec = "workspace.commit-spec" + +// Dispositions (WorkspaceOpResult.Disposition): +const ( + SpecCopyCommitted = "committed" // first copy committed (applied) + SpecCopyRefreshed = "refreshed" // a revised spec replaced the copy (applied) + SpecCopyCurrent = "current" // the feature head already carries the exact copy (no-op) + SpecCopyNoSpec = "no-spec" // trivial change: no spec, no copy (no-op) +) + +// Refusal reasons: +const ( + ReasonSpecCopyNotReady = "workspace-not-ready" // workspace not in the ready (clean, owned) state + ReasonSpecCopyMissing = "spec-missing" // spec: names no file on the metadata tip + ReasonSpecCopyPathOccupied = "spec-path-occupied" // integration branch holds different bytes at the path + ReasonSpecCopyMalformed = "spec-malformed" // the metadata spec's managed markers do not parse +) + +func WorkspaceCommitSpec(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req WorkspaceIDRequest) WorkspaceOpResult +func specCopyBytes(spec []byte, c domain.Change) ([]byte, error) +func specCopyChangeLine(c domain.Change) string +``` + +`WorkspaceOpResult` fields used: `ID`, `Path` (the spec path), `Head` (the feature head after the call), `Disposition`, `Reason`, `Message`. + +- [ ] **Step 1: Write the failing pure tests** + +`internal/app/workspace_spec_test.go`: + +```go +package app + +import ( + "strings" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/repository" +) + +func specFixture() string { + return "\n" + + "> ↩ **[Change 0042 — Widget](https://example.invalid/blob/docket/docs/changes/active/0042-widget.md)**\n" + + "\n\n" + + "# Widget: design\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n" +} + +// specChange builds change 42 "Widget" through the same corpus path the +// operations use (snapshotOf / mustChange live in named_branch_facts_test.go). +func specChange(t *testing.T) domain.Change { + t.Helper() + rec := strings.Replace(lifecycleChange(42, "widget", "in-progress"), "title: 'A change'", "title: 'Widget'", 1) + snap := snapshotOf(t, []StatusBlob{{ + Kind: repository.KindChange, + Location: repository.LocationActive, + Path: groomPath(42, "widget"), + Revision: "blobspec0042", + Data: []byte(rec), + }}) + return mustChange(t, snap, 42) +} + +func TestSpecCopyBytesStripsBacklinkAndAddsChangeLine(t *testing.T) { + got, err := specCopyBytes([]byte(specFixture()), specChange(t)) + if err != nil { + t.Fatal(err) + } + want := "# Widget: design\n\nChange 0042 — Widget\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n" + if string(got) != want { + t.Fatalf("copy:\n%q\nwant\n%q", got, want) + } +} + +func TestSpecCopyBytesSkipsFencedH1(t *testing.T) { + src := "```\n# not a title\n```\n\n# Real title\n\ntext\n" + got, _ := specCopyBytes([]byte(src), specChange(t)) + if !strings.Contains(string(got), "# Real title\n\nChange 0042 — Widget\n\ntext\n") { + t.Fatalf("change line not after the real title:\n%s", got) + } + if strings.Contains(string(got), "# not a title\n\nChange") { + t.Fatalf("change line placed inside the fence:\n%s", got) + } +} + +func TestSpecCopyBytesNoTitle(t *testing.T) { + got, _ := specCopyBytes([]byte("Just prose.\n"), specChange(t)) + if string(got) != "Change 0042 — Widget\n\nJust prose.\n" { + t.Fatalf("no-title copy = %q", got) + } +} + +func TestSpecCopyBytesKeepsCRLF(t *testing.T) { + src := strings.ReplaceAll(specFixture(), "\n", "\r\n") + got, _ := specCopyBytes([]byte(src), specChange(t)) + if strings.Contains(strings.ReplaceAll(string(got), "\r\n", ""), "\n") { + t.Fatalf("mixed line endings:\n%q", got) + } + if strings.Contains(string(got), "docket:backlink") { + t.Fatalf("backlink survived:\n%q", got) + } +} + +func TestSpecCopyBytesRefusesMalformedMarkers(t *testing.T) { + src := "\n# T\n" + if _, err := specCopyBytes([]byte(src), specChange(t)); err == nil { + t.Fatal("a dangling backlink marker must refuse, never be copied") + } +} +``` + +`lifecycleChange`, `groomPath`, `snapshotOf`, and `mustChange` are existing untagged helpers in this package; if `snapshotOf` needs a different pin than the one it builds, keep it and adjust only the record bytes. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -count=1 -run 'SpecCopy' ./internal/app/` +Expected: FAIL — `undefined: specCopyBytes`. + +- [ ] **Step 3: Implement the copy renderer** + +In `internal/app/workspace_spec.go`: + +```go +// 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). +func specCopyChangeLine(c domain.Change) string { + return fmt.Sprintf("Change %04d — %s", int(c.ID()), c.Title()) +} + +// 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) { + doc, err := document.Parse(spec) + if err != nil { + return nil, fmt.Errorf("spec copy: %w", err) + } + le := doc.LineEnding() + if le == "" { + le = "\n" + } + body := spec + if _, ok := doc.Block(backlinkBlockName); ok { + var ps document.PatchSet + ps.RemoveBlock(backlinkBlockName) + if body, err = doc.Apply(ps); err != nil { + return nil, fmt.Errorf("spec copy: removing backlink: %w", err) + } + } + text := strings.TrimLeft(string(body), "\r\n") + line := specCopyChangeLine(c) + if end, ok := firstTitleLineEnd(text); ok { + return []byte(text[:end] + le + line + le + text[end:]), nil + } + return []byte(line + le + le + text), nil +} +``` + +`firstTitleLineEnd(text string) (int, bool)` returns the offset just past the line terminator of the first line that starts with `"# "` outside fenced code, scanning with the same fence rules as `scanTopHeadings` (reuse its fence-run detection; do not write a second fence parser — extract a shared helper if needed). Run Step 2's tests; adjust only `firstTitleLineEnd`'s implementation until all five pass. + +- [ ] **Step 4: Write the failing integration tests** + +In `internal/app/workflow_integration_test.go`, prefix `TestIntegrationWorkflowLifecycle`: + +- `TestIntegrationWorkflowLifecycleCommitSpecFirstCommit`: docket-mode repo (`newDocketModeRepo`) with an in-progress record (`lifecycleChange(31, "copyme", "in-progress")` with `spec:` set to `docs/superpowers/specs/2026-08-17-copyme-design.md`) and that spec (carrying a backlink block) on `docket`; `WorkspacePrepare`; `WorkspaceCommitSpec` → `Result == ResultApplied`, `Disposition == "committed"`; `git rev-list ..HEAD` in the workspace is exactly one commit whose subject is `Add design spec: A change` and whose only path is the spec path; file bytes equal `specCopyBytes(metadataSpec, change)`; the worktree is clean. A second call → `ResultNoOp`, `"current"`, head unchanged. +- `TestIntegrationWorkflowLifecycleCommitSpecRefreshesRevisedSpec`: after the first commit, advance the spec on `docket` (`repo.writerAdvance(t, "docket", ...)`), call again → `"refreshed"`, a second commit `Update design spec: A change`, file equals the new copy. +- `TestIntegrationWorkflowLifecycleCommitSpecTrivialNoSpec`: record with empty `spec:` → `ResultNoOp`, `"no-spec"`, no commit. +- `TestIntegrationWorkflowLifecycleCommitSpecRefusesDirtyWorkspace`: an untracked file in the workspace → `ResultInvalidState`, reason `workspace-not-ready`, no commit, the untracked file untouched. +- `TestIntegrationWorkflowLifecycleCommitSpecRefusesOccupiedIntegrationPath`: `main` already has different bytes at the spec path → `ResultInvalidState`, reason `spec-path-occupied`, no commit. +- `TestIntegrationWorkflowLifecycleCommitSpecMissingSpecFile`: `spec:` names a path absent on `docket` → reason `spec-missing`. + +Run: `go test -tags integration -count=1 -run '^TestIntegrationWorkflowLifecycleCommitSpec' ./internal/app/` — expect FAIL (undefined). + +- [ ] **Step 5: Implement the operation** + +`WorkspaceCommitSpec`: +1. `loadWorkspaceContext(ctx, deps, repoDir, req.ID, OperationWorkspaceCommitSpec)`; refuse a non-in-progress change with `ReasonWorkspaceNotInProgress` (as prepare does). +2. `specPath := strings.TrimSpace(wc.change.Spec().Value)`; empty → `ResultNoOp` with `SpecCopyNoSpec`. +3. `resolveWorkspaceTarget`, then `wdeps.Service.Inspect`; `insp.Kind != workspace.StateReady` → `ResultInvalidState`, `ReasonSpecCopyNotReady`, message naming `insp.Kind` (never the dirty path contents). +4. Metadata spec: `deps.Reader.ReadArtifact(ctx, wc.pin, sourceMetadata, specPath)`; not found → `ReasonSpecCopyMissing`; read error → `ResultExternalFailed` via `classifyStatusError`. +5. `copyBytes, err := specCopyBytes(art.Data, wc.change)`; error → `ResultInvalidState`, reason `ReasonSpecCopyMalformed` (`"spec-malformed"`). +6. Integration probe: `deps.Reader.ReadArtifact(ctx, wc.pin, sourceIntegration, specPath)`; found with bytes different from `copyBytes` → `ReasonSpecCopyPathOccupied`. +7. Feature head probe: open `insp.HeadCommit` with `deps.Client.OpenObjectSource` and read `specPath` (`trackedRegularBlobBytes`). Equal bytes → `ResultNoOp`, `SpecCopyCurrent`. Absent → subject `"Add design spec: " + title`, disposition `committed`; present but different → `"Update design spec: " + title`, `refreshed`. +8. Fence: `done, ferr := admitWorkflowMutation(repoDir, OperationWorkspaceCommitSpec, nil)`; on error return `workspaceFenceRefusal(req.ID, ferr)`. +9. Write `filepath.Join(insp.Path, filepath.FromSlash(specPath))` (`os.MkdirAll` the parent, `os.WriteFile` 0o644), make an empty hooks dir with `os.MkdirTemp("", "docket-commit-spec-hooks-*")` (removed with `defer os.RemoveAll`), and `deps.Client.CommitPaths(ctx, wc.repo, gitcli.CommitRequest{Dir: insp.Path, Paths: []gitcli.RepoPath{gitcli.RepoPath(specPath)}, Subject: subject, HooksPath: hooks, When: deps.Clock.Now()})`. On a commit error, restore the prior bytes (or remove the file when it was absent) so the workspace is left clean, and return `ResultExternalFailed`. +10. `done(mutationJournalOutcome(result))`; return `ResultApplied` with `Head` = the new commit, `Path` = `specPath`. + +Wire the CLI subcommand after `prepare` in `internal/cli/workspace.go` (`Use: "commit-spec"`, Short: "Commit the change's spec copy onto its feature branch (the branch's first commit)", flags `--id` required and `--repo-dir`), the `assetIndependent` row, and the schema-registry row. Update the CLI registration test and any capability-catalog golden. + +- [ ] **Step 6: Run to verify pass** + +Run: `go test -count=1 -run 'SpecCopy' ./internal/app/`; `go test -tags integration -count=1 -run '^TestIntegrationWorkflowLifecycle' ./internal/app/`; `go test -count=1 ./internal/cli/ ./internal/repoguard/`. +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add internal/app/workspace_spec.go internal/app/workspace_spec_test.go internal/app/workspace_ops.go internal/app/schema_registry.go internal/app/workflow_integration_test.go internal/cli/workspace.go internal/cli/install.go +git commit -m "feat(workspace): commit-spec ships the spec copy as the feature branch's first commit" +``` + +--- + +### Task 7: `change.attach-plan` writes the plan on the metadata branch + +**Build tier:** premium — the attach contract every run depends on. + +**Files:** +- Modify: `internal/app/change_attach.go` +- Modify: `internal/cli/change.go` (`changeAttachSubcommand` gains a kind-specific flag set: `attach-plan` takes `--id --revision --path --markdown`, no `--commit`; `attach-results` keeps `--commit` until Task 8) +- Modify: `internal/app/run_verify.go` (plan existence on the metadata tip) +- Modify: `internal/app/status.go` (`artifactChecks`: plan on metadata, integration fallback) +- Test: `internal/app/change_attach_test.go`, `internal/app/workflow_integration_test.go` (`TestIntegrationWorkflowRepoChangeAttachPlanGitVerification*`, `...AttachPlanBoardLinkAtomicity`, `...EffectiveBaseConsumedFromDomain`), `internal/app/workflow_e2e_test.go` (`runClaimToImplemented` step 5–6), `internal/app/claim_workflow_git_test.go` (`commitPlanFile` — delete when unused), `internal/app/named_isolation_integration_test.go`, `internal/app/finalize_e2e_test.go`, `internal/app/change_attach_git_integration_test.go`, `internal/app/status_test.go` (`TestStatusSourceDistinction`), `internal/app/run_verify_test.go`, `internal/cli/change_test.go` + +**Interfaces:** +- Produces: + +```go +// ChangeAttachRequest (Commit removed in Task 8; Markdown added here). +type ChangeAttachRequest struct { + ID int `json:"id" docket:"required"` + Revision string `json:"revision" docket:"required"` + Path string `json:"path" docket:"required"` + Commit string `json:"commit"` // results only, until Task 8 + // Markdown is the authored artifact body, read from --markdown at the CLI + // boundary (a non-JSON file input, ADR-0138), never a JSON key. + Markdown []byte `json:"-"` +} + +func ChangeAttachPlan(ctx context.Context, deps PlanningDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult +// ChangeAttachResults keeps its WorkspaceDeps signature until Task 8. + +const ( + ReasonAttachEmptyMarkdown = "empty-markdown" + ReasonAttachMarkdownTooLarge = "authored-input-too-large" + ReasonAttachPathMismatch = "artifact-path-mismatch" // the field already names a different path + ReasonAttachPathOccupied = "artifact-path-occupied" // a file at the path belongs to another change +) + +// metadataArtifactBytes assembles the stored artifact: any submitted backlink +// block removed, the freshly rendered backlink prepended. +func metadataArtifactBytes(markdown []byte, backlink string) ([]byte, error) +``` + +- [ ] **Step 1: Write the failing unit tests** + +In `internal/app/change_attach_test.go` (use its existing fake-engine/fake-reader kit; rename tests that no longer apply): + +- `TestAttachPlanWritesFileFieldAndBacklink`: plan Markdown `"# Plan\n\n## Task 1\n\nDo it.\n"` → applied; the mutation plan declares two files: the record (with `plan:` set) and `docs/superpowers/plans/2026-08-17-widget-plan.md` whose bytes equal `assembleSpecFile(render.BacklinkContent(change, link), markdown)` (the same prepend shape the spec file uses). +- `TestAttachPlanStripsSubmittedBacklink`: Markdown that already starts with a backlink block (any interior) → stored bytes contain exactly one `docket:backlink:start` and its interior is the freshly rendered one. +- `TestAttachPlanRefusesMalformedBacklink`: a dangling `docket:backlink:start` → `ReasonAttachUnbalancedBacklink`, nothing declared. +- `TestAttachPlanReplacesContentOnReplan`: field already set to the path, file present with older content → one `MutationReplace` of the plan file; the record is not declared when its bytes are unchanged (same day). +- `TestAttachPlanRefusesDifferentPathWhenLinked`: field set to path A, request path B → `ReasonAttachPathMismatch`. +- `TestAttachPlanRefusesOccupiedPath`: field unset, a file exists at the path whose backlink targets another change → `ReasonAttachPathOccupied`. +- `TestAttachPlanIdenticalReattachIsNoOp`: same Markdown twice → second is `ResultNoOp` (keep the 0458 property). +- `TestAttachPlanPlaceholderStillRefuses`: a section whose body is only `TBD` → `ReasonAttachPlaceholderToken`. +- `TestAttachPlanRejectsBadShape`: empty Markdown → `ReasonAttachEmptyMarkdown`; over `maxAuthoredMarkdownBytes` → `ReasonAttachMarkdownTooLarge`; absolute / escaping / outside-root paths keep their reasons. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -count=1 -run 'AttachPlan' ./internal/app/` +Expected: FAIL. + +- [ ] **Step 3: Implement the plan path** + +In `changeAttach`, branch at the top: `if kind == attachKindPlan { return changeAttachMetadata(ctx, deps, repoDir, req, kind) }` (Task 8 routes results here too and deletes the old body). `changeAttachMetadata`: + +1. Shape: `validateLifecycleShape("id", req.ID, req.Path, req.Revision)`; empty Markdown → `ReasonAttachEmptyMarkdown`; `len(req.Markdown) > maxAuthoredMarkdownBytes` → `ReasonAttachMarkdownTooLarge`. +2. Pin, `resolveBoardSurface`, `Discover`, `resolveAttachChange`, `verifyAttachPath` (unchanged). +3. If the kind's field is set and differs from `req.Path` → `ReasonAttachPathMismatch`. +4. `backlink, err := render.BacklinkContent(ac.change, ac.link)`; `artifact, err := metadataArtifactBytes(req.Markdown, backlink)` — `document.Parse(markdown)` (error → `ReasonAttachUnbalancedBacklink`), `PatchSet.RemoveBlock(backlinkBlockName)` when present, then `assembleSpecFile(backlink, string(stripped))` (move `assembleSpecFile` to a neutral name `prependBacklink` if you prefer, updating `change_groom.go` in the same commit). +5. Plan content: `planPlaceholderSlot(artifact)` → `ReasonAttachPlaceholderToken` (unchanged message). +6. Digest: `canonicalDigest(opKey, attachDigestPayload{Blob: sha256hex(artifact), ID: req.ID, Path: req.Path})` — keep the payload struct, rename the field's meaning in its comment to "the sha256 of the stored artifact bytes"; `RequestID: fmt.Sprintf("attach-%s-%d-%s", kind, req.ID, sha256hex(artifact)[:16])`. +7. Transaction: as today, plus `changeAttachOp` gains `artifactBytes []byte`. In `Plan`, before the record patch: `existing, _, exists, err := treeBlob(ctx, st.Tree, o.artifact)`; when `exists` and the field is not already `o.artifact`, require `backlinkTargets(existing, c, o.link)` to be true, else `refuseLifecycle(FindingCode(ReasonAttachPathOccupied), ...)`; declare the artifact file (`MutationCreate` when absent, `MutationReplace` when present and different, nothing when equal). + +Delete for the plan kind: the `IsAncestor` descent check, `verifySingleArtifactDelta`, `verifyPathTrailer`, `planPathTrailerKey`, `resultsPathTrailerKey` (never used), `ReasonAttachCommitNotDescendant`, `ReasonAttachMultiArtifactDelta`, `ReasonAttachMissingTrailer`. Keep `readAttachBlob`, `verifyBacklink`, and `ReasonAttachCommitNotHead` / `ReasonAttachUntrackedFile` / `ReasonAttachSymlinkedPlan` / `ReasonAttachMissingBacklink` / `ReasonAttachBacklinkMismatch` / `ReasonAttachArtifactUnreadable` for the results kind until Task 8. Update the file-header comment to describe both profiles truthfully. + +`ChangeAttachPlan` drops its `WorkspaceDeps` parameter. CLI: `attach-plan` reads `--markdown` with `readRecordSource(c.InOrStdin(), src)` and builds deps with `newPlanningDeps(repoDir)`; flag help: "plan Markdown `file`, or - for stdin; the operation writes it on the metadata branch with its backlink (required)". + +- [ ] **Step 4: Move the plan readers to the metadata tip** + +- `RunVerify`: replace the head-source `trackedRegularBlob(ctx, src, planPath)` with `art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, planPath)` (error → `ReasonRunArtifactRead` operational refusal; `!art.Found` → `ReasonRunPlanMissing`). Keep the head source open only for results until Task 8. Update `ReasonRunPlanMissing`'s comment: "the linked plan no longer resolves to a file on the metadata branch". +- `artifactChecks`: the plan link checks `sourceMetadata` first and, only when absent there, `sourceIntegration` (a record closed before plans moved); `artifact-missing` only when both are absent. Update `TestStatusSourceDistinction` to expect the metadata ask for the plan, then the integration ask only when the fake reports it absent on metadata. + +- [ ] **Step 5: Convert the workflow tests** + +- `runClaimToImplemented` steps (5)–(6): no plan file in the workspace, no `ArtifactBacklink`, no commit; call `ChangeAttachPlan(ctx, node.deps, node.dir, ChangeAttachRequest{ID: id, Revision: ver(), Path: planPath, Markdown: []byte("# Implementation Plan\n\nConcrete steps here.\n")})`. Assert the plan file exists at the metadata remote tip with its backlink, and the workspace head is unchanged by planning. +- `TestIntegrationWorkflowRepoChangeAttachPlanGitVerification` (orphan/symlink/rename/commit-not-head/commit-not-descendant/multi-artifact-delta/missing-trailer cases): replace with the metadata-transaction cases (occupied path, path mismatch, malformed backlink) against real git, and delete the feature-commit cases; keep the HappyPath sibling, converted. +- `TestIntegrationWorkflowRepoEffectiveBaseConsumedFromDomain`: keep the stacked-base assertions; replace the plan half with a metadata attach (the ancestry property it pinned no longer exists — say so in a comment). +- `named_isolation_integration_test.go`, `finalize_e2e_test.go`, `change_attach_git_integration_test.go`: convert every plan attach the same way. Delete `commitPlanFile` once `git grep -n commitPlanFile` shows no caller. + +- [ ] **Step 6: Run to verify pass** + +Run: `go test -count=1 ./internal/app/ ./internal/cli/`; shards `TestIntegrationWorkflowRepo`, `TestIntegrationWorkflowLifecycle`, `TestIntegrationRecordOps`, `TestIntegrationNamed`, `TestIntegrationChangeRuntime`, `TestIntegrationEvidence`; `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/`. +Expected: PASS. + +- [ ] **Step 7: Sweep and commit** + +`git grep -n -F -- 'Docket-Plan-Path'` and `git grep -n 'planPathTrailerKey\|verifyPathTrailer\|verifySingleArtifactDelta'`: no executable or test hit remains (prose hits are Tasks 14–16). + +```bash +git add +git commit -m "feat(attach-plan): the plan is written on the metadata branch with its backlink" +``` + +--- + +### Task 8: `change.attach-results` writes results on the metadata branch; every results reader follows + +**Build tier:** premium + +**Files:** +- Modify: `internal/app/change_attach.go` (route results through `changeAttachMetadata`; delete the feature-head driver and the reasons/helpers only it used: `readAttachBlob`, `verifyBacklink`, `ReasonAttachCommitNotHead`, `ReasonAttachUntrackedFile`, `ReasonAttachSymlinkedPlan`, `ReasonAttachMissingBacklink`, `ReasonAttachBacklinkMismatch`, `ReasonAttachArtifactUnreadable`, `FCEmptyCommit` use; drop `Commit` from `ChangeAttachRequest`; drop `WorkspaceDeps` from `ChangeAttachResults`) +- Modify: `internal/cli/change.go` (`attach-results` takes `--markdown`, no `--commit`; collapse `changeAttachSubcommand` back to one flag set) +- Modify: `internal/app/change_implemented.go` (`verifyImplementedResults` reads at the metadata tip) +- Modify: `internal/app/run_verify.go` (results at the metadata tip; no head object source remains) +- Modify: `internal/app/status.go` (`artifactChecks` results: metadata, integration fallback) +- Test: `internal/app/change_attach_test.go`, `workflow_integration_test.go` (`...ChangeAttachResultsCheckpointContent`), `workflow_e2e_test.go` (steps 7–7b), `change_implemented_test.go` (`miAdvanceHead`, `miResults*` fixtures), `change_integration_test.go` (`TestIntegrationChangeRuntimeMarkImplementedConditions` condition-5 rows), `run_verify_test.go` (`rvResults*`), `run_verify_integration_test.go`, `named_isolation_integration_test.go`, `finalize_e2e_test.go`, `status_test.go`, `internal/cli/change_test.go` + +**Interfaces:** +- Consumes: `changeAttachMetadata`, `metadataArtifactBytes` (Task 7). +- Produces: `func ChangeAttachResults(ctx context.Context, deps PlanningDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult`; `ChangeAttachRequest{ID, Revision, Path, Markdown}`. + +- [ ] **Step 1: Write the failing tests** + +- `change_attach_test.go`: `TestAttachResultsWritesFileAndField`, `TestAttachResultsCheckpointContentRefuses` (raw template scaffolding → `ReasonAttachResultsContent`), `TestAttachResultsLaterCheckpointReplacesContent`, `TestAttachResultsFeatureHeadNeverConsulted` (the request carries no commit; the fake workspace service records no Inspect call). +- `runClaimToImplemented`: the implementation commit carries only `widget.go`; results attach with `Markdown` before mark-implemented; mark-implemented and run.verify still reach `run-complete`. Add: a SECOND `ChangeAttachResults` after `EvidenceRecord` (new Outcome text) leaves the workspace head equal to `head` and `EvidenceVerify(evidenceBytes, head)` still verifies (spec acceptance 2, first half). +- `TestIntegrationChangeRuntimeMarkImplementedConditions`: the condition-5 rows seed results on the metadata branch (`results-missing` rows unchanged; `results-content-invalid`: a metadata results file failing the final contract; `results-identity-broken`: `results:` set but no file on the metadata tip, and a metadata file whose backlink targets another change). +- `run_verify_*`: results fixtures on the metadata branch; a results file present only at the feature head and absent on metadata → `results-identity-broken`. +- `TestStatusSourceDistinction`: results follow the plan rule. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -count=1 -run 'AttachResults|MarkImplemented|RunVerify|StatusSource' ./internal/app/`; shards `TestIntegrationChangeRuntime`, `TestIntegrationEvidence`, `TestIntegrationWorkflowLifecycle`, `TestIntegrationWorkflowRepo`. +Expected: FAIL. + +- [ ] **Step 3: Implement** + +- `changeAttach` routes both kinds to `changeAttachMetadata`; for results, the content check is `ValidateResultsContent(artifact, ResultsPhaseCheckpoint)` (unchanged reason and message). Delete the old driver body and everything listed under **Files**. +- `verifyImplementedResults(ctx, deps PlanningDeps, pin StatusPin, resultsPath string, ch domain.Change, link render.LinkContext, id int)`: `art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, resultsPath)`; error → `results-identity-broken` with the error; `!art.Found` → `results-identity-broken` ("the attached results path %q is not a file on the metadata branch"); then the unchanged `backlinkTargets` and `ValidateResultsContent(ResultsPhaseFinal)` checks. The call site passes `pin`. Update condition 5's comment and `ReasonImplementedResultsIdentity`'s comment ("at the supplied head" → "on the metadata branch"). +- `RunVerify`: results via `deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, resultsPath)` with the same unmet mapping; remove the head `OpenObjectSource` block entirely; update `ReasonRunResultsIdentity`'s comment. +- `artifactChecks`: results metadata-first with integration fallback. +- CLI: one `changeAttachSubcommand` flag set for both verbs (`--id --revision --path --markdown`); Short texts: "Write a plan on the metadata branch and link it to an in-progress change" / "Write a results record on the metadata branch and link it to an in-progress change". + +- [ ] **Step 4: Run to verify pass** + +Re-run Step 2's commands plus `TestIntegrationRecordOps`, `TestIntegrationNamed`, `TestIntegrationRunCompletion`, `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/`, `go test -count=1 ./internal/cli/`. +Expected: PASS. + +- [ ] **Step 5: Sweep and commit** + +`git grep -n 'readAttachBlob\|ReasonAttachCommitNotHead\|Commit:.*ChangeAttachRequest\|--commit' -- internal/` → no attach-related executable or test hit remains. + +```bash +git add +git commit -m "feat(attach-results): results checkpoints are metadata commits; readers follow" +``` + +--- + +### Task 9: Retire `artifact.backlink` + +**Build tier:** standard + +**Files:** +- Delete: `internal/app/artifact_backlink.go`, `internal/app/artifact_backlink_test.go`, `internal/cli/artifact.go`, `internal/cli/artifact_test.go` (or the `TestArtifactCommandsRegistered` part if the file holds more). `artifact_backlink_test.go` defines `changeByPath`, `backlinkGolden`, and `backlinkDeps`; `workflow_integration_test.go` also calls some of them — move every still-called helper into a surviving `_test.go` file (for example `change_attach_git_helpers_test.go`) before deleting. +- Modify: `internal/cli/root.go` (or wherever `newArtifactCommand` is added), `internal/cli/install.go` (remove `"artifact"` and `"artifact backlink"` from `assetIndependent`), `internal/app/schema_registry.go` (remove the `artifact.backlink` row), `internal/repoguard/budgets_test.go` (the comment that cites the op) +- Keep: `backlinkBlockName` and `backlinkBlockAnnotation` — move them to `internal/app/change_attach.go` (they are shared by attach, groom, kill, close-out). + +**Interfaces:** removes `ArtifactBacklink`, `ArtifactBacklinkRequest`, `ArtifactBacklinkResult`, `OperationArtifactBacklink`. + +- [ ] **Step 1: Prove there is no caller** + +Run: `git grep -n 'ArtifactBacklink\b\|ArtifactBacklink(\|artifact\.backlink\|artifact backlink' -- ':!docs/superpowers' ':!docs/results' ':!docs/changes' ':!testdata'` +Expected: hits only in the files this task deletes or edits, plus maintained prose (skills/agents/docs — Tasks 14–16). Any remaining Go caller is a defect from Tasks 7–8: convert it first. + +- [ ] **Step 2: Delete and rewire** + +Delete the files, the CLI registration, the `assetIndependent` rows, and the schema-registry row; move the two constants. + +- [ ] **Step 3: Run the guards that track the command surface** + +Run: `go build ./... && go test -count=1 ./internal/cli/ ./internal/app/ ./internal/repoguard/` +Expected: PASS — including the `assetIndependent` ↔ Cobra-tree correspondence test and the schema-registry coverage test. Fix any capability golden they name. + +- [ ] **Step 4: Commit** + +```bash +git add -A -- internal/app/artifact_backlink.go internal/app/artifact_backlink_test.go internal/cli/artifact.go internal/cli/artifact_test.go +git add +git commit -m "refactor: retire artifact.backlink; the attach operations stamp their own backlinks" +``` + +--- + +### Task 10: Relative links, the legacy rule, `Spec (merged)`, and the PR-body plan/results links + +**Build tier:** premium — every record's rendering changes and many goldens move. + +**Files:** +- Create: `internal/render/relpath.go` (`RelativeLink`) +- Modify: `internal/render/artifacts.go` (`ArtifactBlockContent`, `pathRow`, `adrCell`; delete `lifecycleBranch`; add `ArtifactBacklinkContent`, `PRArtifactLinksContent`; `BacklinkContent` stays absolute for PR bodies) +- Modify: every file-backlink caller to `ArtifactBacklinkContent`: `internal/app/change_groom.go` (spec create, spec revise, `restampSpecBacklink`), `internal/app/change_kill.go`, `internal/app/finalize_closeout.go` (`retargetArtifactBacklinks`), `internal/app/change_attach.go` (`backlinkTargets` gains an `artifactPath` parameter; the attach writer), `internal/app/change_implemented.go` (`verifyImplementedResults` passes the results path) +- Keep absolute (`BacklinkContent`): `pr_publish.go`, `archivedBacklinkInterior` (PR leg), `repository_repair_prbacklinks.go`, `sweepAssessPRBacklinkLeg` +- Modify: `internal/app/change_kill.go` (`changeKillOp.Plan` retargets plan and results backlinks too, via the same per-artifact loop `retargetArtifactBacklinks` uses — reuse it, do not copy it) +- Modify: `internal/app/pr_publish.go` (`assemblePRBody(authored []byte, backlink, artifacts string)` upserts a `docket:artifacts` block after the backlink) +- Test: `internal/render/relpath_test.go` (new), `internal/render/artifacts_test.go`, `internal/render/testdata/artifacts/*` (read `PROVENANCE.md` first), `internal/app/*` tests that pin rendered rows or backlinks (find them with `git grep -n 'blob/docket\|blob/main\|blob/feat' -- 'internal/**/*_test.go'`), `internal/app/repository_repair_integration_test.go`, `internal/app/repocheck_corpus_link_integration_test.go`, `internal/app/finalize_closeout_integration_test.go`, `change_kill` tests + +**Interfaces:** +- Produces: + +```go +// RelativeLink returns the slash-separated relative path from the directory of +// fromFile to toFile, both canonical repo-relative paths. +func RelativeLink(fromFile, toFile string) string + +// ArtifactBacklinkContent renders the docket:backlink block for a file on the +// metadata branch: "> ↩ **[Change NNNN — Title]()**". +func ArtifactBacklinkContent(c domain.Change, artifactPath string) string + +// PRArtifactLinksContent renders the interior of the PR description's +// docket:artifacts block: absolute metadata-branch links to the plan and +// results. "" when RepoWebURL is empty or neither field is set. +func PRArtifactLinksContent(c domain.Change, link LinkContext) string +``` + +Row rules (`ArtifactBlockContent`), in order Spec, Spec (merged), Plan, Results, ADRs: +- **Spec:** `| Spec | []() |`. +- **Spec (merged):** only when `c.Status() == domain.StatusDone && c.HasBuildEvidence() && spec != ""`: `| Spec (merged) | []() |`, or `` | Spec (merged) | `` | `` when `RepoWebURL` is empty. +- **Plan / Results:** legacy (`c.Status() == domain.StatusDone && !c.HasBuildEvidence()`) → today's done rendering (absolute on `IntegrationBranch`, backtick path without a web URL); otherwise relative. +- **ADRs:** `[ADR-NNNN]()`. +Relative rows render the same with or without `RepoWebURL`. + +- [ ] **Step 1: Write the failing render tests** + +`internal/render/relpath_test.go`: + +```go +package render + +import ( + "path" + "testing" +) + +func TestRelativeLink(t *testing.T) { + cases := []struct{ from, to, want string }{ + {"docs/changes/active/0001-a.md", "docs/superpowers/specs/s.md", "../../superpowers/specs/s.md"}, + {"docs/changes/archive/2026-10-06-0001-a.md", "docs/superpowers/specs/s.md", "../../superpowers/specs/s.md"}, + {"docs/superpowers/plans/p.md", "docs/changes/active/0001-a.md", "../../changes/active/0001-a.md"}, + {"docs/results/r.md", "docs/changes/active/0001-a.md", "../changes/active/0001-a.md"}, + {"docs/changes/active/0001-a.md", "docs/changes/active/0002-b.md", "0002-b.md"}, + {"README.md", "docs/x.md", "docs/x.md"}, + } + for _, c := range cases { + got := RelativeLink(c.from, c.to) + if got != c.want { + t.Errorf("RelativeLink(%q, %q) = %q, want %q", c.from, c.to, got, c.want) + } + if resolved := path.Join(path.Dir(c.from), got); resolved != c.to { + t.Errorf("link %q from %q resolves to %q, not %q", got, c.from, resolved, c.to) + } + } +} +``` + +In `internal/render/artifacts_test.go` add (build changes with the file's existing change-fixture helper; set the body to carry `## Build evidence` via the decode path so `HasBuildEvidence` is true where needed): +- `TestArtifactBlockRelativeRowsSurviveArchive`: an in-progress change at `docs/changes/active/0007-x.md` and the same change at `docs/changes/archive/2026-10-06-0007-x.md` render byte-identical blocks, every link resolves (via `path.Join(path.Dir(recPath), link)`) to the field's path, and no row contains `/blob/` (spec acceptance 6). +- `TestArtifactBlockLegacyDoneKeepsIntegrationRows`: done, no evidence section → Plan/Results rows absolute on `main`; Spec and ADR rows relative; no `Spec (merged)` row. +- `TestArtifactBlockDoneWithEvidenceAddsSpecMerged`: done with the section → `Spec (merged)` row absolute on `main`; Plan/Results relative. +- `TestArtifactBacklinkContentRelative`: plan at `docs/superpowers/plans/p.md` → interior links `../../changes/active/0007-x.md`; after archive, `../../changes/archive/...`. +- `TestPRArtifactLinksContent`: both fields set → two lines with `/blob/docket/` URLs; no web URL → `""`; neither field → `""`. +Replace the lifecycle tests (`TestArtifactBlockLifecyclePinsPlanResults`, `TestArtifactBlockStackedMergedUsesFeatureBranch`, `TestArtifactBlockMissingBranchFallsBackToMetadata`, `TestArtifactBlockRelativeModeIgnoresLifecycle`) with the legacy-rule tests above; their premise (plan/results on the feature branch) no longer exists. + +- [ ] **Step 2: Run to verify failure** + +Run: `go test -count=1 ./internal/render/` +Expected: FAIL. + +- [ ] **Step 3: Implement the renderer** + +`RelativeLink`: split both paths' directory parts on `/`, drop the common prefix, emit one `..` per remaining `from` directory, then the rest of `to`. `ArtifactBlockContent` per the row rules; `pathRow` splits into `relativeRow(label, recPath, target)` and `absoluteRow(label, target, branch, link)`. `ArtifactBacklinkContent` uses the same marker constants as `BacklinkContent`. `PRArtifactLinksContent` lines: `- Plan: []()` and `- Results: []()`. Update the package doc comments that say plan/results "never live on the metadata branch". + +Goldens: follow `internal/render/testdata/artifacts/PROVENANCE.md`'s own remedy for an intentional output change (learning `config-edit-trips-its-own-frozen-drift-guard`: obey the guard). If it describes them as frozen parity copies of a retired Bash renderer, replace the affected goldens with the new Go output and record in `PROVENANCE.md` that the Go renderer is now their source and why. + +- [ ] **Step 4: Switch the file-backlink callers and the kill retarget** + +Change each caller listed under **Files** to `render.ArtifactBacklinkContent(change, )`; `backlinkTargets(artifactBytes []byte, ch domain.Change, artifactPath string) (bool, error)`. `retargetArtifactBacklinks` renders per artifact path. `changeKillOp.Plan`: replace its spec-only block with a call to `retargetArtifactBacklinks` over the killed change's candidate. `assemblePRBody` gains the `artifacts` block (insert after the backlink block when absent, `ReplaceBlock` when present; omitted when the interior is `""`); `PRPublish` passes `render.PRArtifactLinksContent(change, link)`. + +- [ ] **Step 5: Update the app-level pins and add the conversion test** + +Run `go test -count=1 ./internal/app/` and every shard that renders records (`TestIntegrationChangeAuthoring`, `TestIntegrationChangeRuntime`, `TestIntegrationRecordOps`, `TestIntegrationWorkflowRepo`, `TestIntegrationWorkflowLifecycle`, `TestIntegrationFinalizeCloseout`, `TestIntegrationFinalizeArchive`, `TestIntegrationRepoCheck`, `TestIntegrationRepoRepair`, `TestIntegrationNamed`); update each failing expectation of absolute same-branch URLs to the relative form — never weaken an assert to a substring that would also match the old form. `TestIntegrationRepoCheckCorpusPinsDoneChangeToIntegrationBranch` stays true for a legacy done record; add a done-with-evidence record to it asserting relative Plan/Results and the `Spec (merged)` row. + +New `TestIntegrationRepoRepairConvertsAbsoluteSameBranchLinksOnce` (prefix `TestIntegrationRepoRepair`): seed `docket` with (a) an active record whose `## Artifacts` block carries the old absolute spec/ADR URLs, and (b) a legacy done archived record with absolute Plan/Results rows on `main`. `RunRepositoryCheck` reports `artifact-links-stale` for (a) and for (b)'s spec/ADR rows; `RunRepositoryRepair` with `Authorized: true` lands exactly one metadata commit; afterwards (a) is relative, (b)'s Plan/Results rows still point at `main`; a second repair is a clean no-op (spec acceptance 6). + +- [ ] **Step 6: Run to verify pass** + +Re-run Steps 2 and 5 plus `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/`. Expected: PASS. + +- [ ] **Step 7: Mutation-test the legacy rule** + +Back up `internal/render/artifacts.go`, make the legacy predicate always false, run `go test -count=1 -run 'Legacy' ./internal/render/` → FAIL; restore with `mv -f`, re-run green. + +- [ ] **Step 8: Commit** + +```bash +git add +git commit -m "feat(render): same-branch artifact links and backlinks are relative" +``` + +--- + +### Task 11: Retire the integration-branch backlink legs; pin "no integration push outside a PR merge" + +**Build tier:** standard + +**Files:** +- Modify: `internal/app/finalize_closeout.go` (delete `runCloseoutBacklinkLeg`, `closeoutBacklinkOp`, `closeoutBacklinkTargets`, `closeoutBacklinkTarget`, `closeoutBacklinkReceipt`, `OperationFinalizeCloseoutBacklink`, `ReasonCloseoutBacklinkPending`, the call in `FinalizeCloseout`; rewrite the file-header topology comment and `retargetArtifactBacklinks`'s "left to the follow-up leg" note) +- Modify: `internal/app/finalize_cleanup.go` (delete `finalizeCleanupBacklinkRepair`, `cleanupBacklinkOp`, `ReasonCleanupBacklinkPending`, its "Leg 1" call in `finalizeCleanupDone`; keep the PR-body "Leg 1b") +- Modify: `internal/app/maintenance_assess.go` (delete `sweepAssessBacklinkLeg`, `sweepBacklinkArtifactPaths`, `sweepLegBacklink` and its call in `sweepAssessHistorical`) +- Delete: `internal/app/finalize_backlink_loader.go` and `finalize_backlink_loader_test.go` once nothing uses `newBacklinkArtifactLoader`/`backlinkLegDetail` (if the PR leg uses `backlinkLegDetail`, move that function into `pr_backlink_repoint.go` instead of deleting it) +- Delete helpers whose last caller you removed: `backlinkLegRetarget`, `backlinkLegHasWork` (check `git grep` first) +- Modify: `internal/cli/finalize.go` (the cleanup annotation comment that calls it a metadata-branch leg), `internal/cli/maintenance.go` (comment) +- Modify: `internal/repoguard/retired_vocabulary_test.go` (the `terminal-backlink-pending` → `final-backlink-pending` row and the allowlist sentence: `final-backlink-pending` is now itself retired; follow the file's own convention for a retired code with no successor) +- Create: `internal/repoguard/integration_push_test.go` +- Test: `finalize_closeout_integration_test.go`, `finalize_cleanup_integration_test.go`, `finalize_state_integration_test.go`, `finalize_git_test.go` (`matrixCloseout`), `finalize_closeout_test.go` (`setupCloseoutFixture`), `maintenance_assess_test.go`, `maintenance_test.go`, `finalize_cleanup_test.go` (`TestFinalLifecycleCodeSpellings`) + +**Interfaces:** consumes `retargetArtifactBacklinks` (relative, Task 10). Produces nothing new. + +- [ ] **Step 1: Write the guard (it must fail first)** + +`internal/repoguard/integration_push_test.go`: + +```go +package repoguard + +// TestNoIntegrationPushOutsidePRMerge pins that no operation writes the +// integration branch outside a PR merge. Two syntactic shapes are checked over +// every non-test Go file under internal/app: +// +// 1. Every transaction.Request composite literal's TargetRef expression must +// mention MetadataBranchName — the engine may only commit to the metadata +// branch. +// 2. Every direct PushLease / PushCreateLease call must sit in a function on +// pushAllowlist. Each entry names why it may push; only migrateExecute +// touches the integration branch, for the one-time human-run legacy +// migration prune. A new direct pusher reddens this test and must argue its +// way onto the list. +// +// Mutation-tested: re-adding a TargetRef of branchRefPrefix + integrationBranch +// reddens shape 1; adding a PushLease call to a new function reddens shape 2. +``` + +Implement with `go/parser` + `go/ast` over `filepath.Glob(/internal/app/*.go)` minus `_test.go` (resolve the repo root the way sibling repoguard tests do). For shape 1, match `*ast.CompositeLit` whose `Type` is a selector `transaction.Request`, find the `TargetRef` key, print its value with `go/printer`, and require `strings.Contains(src, "MetadataBranchName")`. For shape 2, walk each `*ast.FuncDecl`, find `*ast.CallExpr` whose `Fun` is a selector named `PushLease` or `PushCreateLease`, and require the FuncDecl name to be in: + +```go +var pushAllowlist = map[string]string{ + "publishOrAdoptMetadataRoot": "repository init seeds the metadata branch", + "executeRepositoryRepair": "repository repair publishes one metadata commit", + "reconcileResumeSeed": "repository migrate resumes the metadata seed", + "publishSeed": "repository migrate publishes the metadata seed", + "migrateExecute": "repository migrate's one-time, human-run legacy prune of the integration branch", +} +``` + +Also assert the population floor: shape 1 found at least 15 literals and shape 2 found exactly the allowlisted functions (an allowlist entry with no call is stale and fails). Run: `go test -count=1 -run TestNoIntegrationPushOutsidePRMerge ./internal/repoguard/` → FAIL today on `finalize_closeout.go` and `finalize_cleanup.go`. + +- [ ] **Step 2: Delete the legs** + +Delete everything listed under **Files**. In `FinalizeCloseout`, the archive transaction's result plus the PR-body leg's findings are the whole outcome. + +- [ ] **Step 3: Convert the tests** + +- `setupCloseoutFixture`: plan and results live on `docket` (with relative backlinks to the active record), not on `main`. +- `TestIntegrationFinalizeCloseoutBacklinkLegDocketMode` → `TestIntegrationFinalizeCloseoutArchiveRetargetsAllArtifactBacklinks`: the single archive commit on `docket` retargets spec, plan, and results backlinks to the archived path, and `origin/main`'s tip after close-out equals the merge commit — no integration commit (spec acceptance 4, behavioral half). +- Delete `...BacklinkLegIgnoresUnrelatedCorpusErrors`, `...BacklinkPendingFindingNamesTheCause`, `TestIntegrationFinalizeCleanupBacklinkRepairIgnoresUnrelatedCorpusErrors`, the backlink-leg sweep assessment tests (`TestAssessStaleBacklinkLegIsActionable`, `TestAssessMissingManifestWithStaleBacklinkIsActionable`, and the malformed-markers case that targets the integration leg); keep and adjust the PR-backlink assessment tests. Record each deletion and its reason in the commit body. +- `TestIntegrationFinalizeArchiveNeverEditsAuthoredBytes`, `TestIntegrationFinalizeStateBytePreservation`, `matrixCloseout`: read plan/results on `docket`; assert no integration commit on replay. +- `TestFinalLifecycleCodeSpellings`: drop the two retired constants. + +- [ ] **Step 4: Run to verify pass** + +Run: `go test -count=1 ./internal/app/ ./internal/repoguard/ ./internal/cli/`; shards `TestIntegrationFinalizeCloseout`, `TestIntegrationFinalizeArchive`, `TestIntegrationFinalizeCleanup`, `TestIntegrationFinalizeState`, `TestIntegrationSweep`; `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/`. +Expected: PASS. + +- [ ] **Step 5: Mutation-test the guard** + +Back up `internal/app/finalize_block.go`, change one `TargetRef` to `gitcli.RefName(branchRefPrefix + "main")`, run the guard → FAIL; restore with `mv -f`. Back up `internal/app/repository_repair.go`, wrap its `PushLease` call in a new helper function → FAIL; restore. Re-run green. + +- [ ] **Step 6: Sweep and commit** + +`git grep -n 'final-backlink-pending\|closeout-backlink\|final backlinks retargeted' -- ':!docs/superpowers' ':!docs/results' ':!docs/changes' ':!testdata'` → only maintained prose hits remain (Tasks 14–16). + +```bash +git add +git commit -m "refactor(finalize): no post-merge backlink commit to the integration branch" +``` + +--- + +### Task 12: Retire `finalize.skip_results_only_delta` + +**Build tier:** standard + +**Files:** +- Modify: `internal/config/schema.go` (the `finalize.skip_results_only_delta` row: `disp: dispObsolete`, mirroring the `metadata_branch` row's shape) +- Modify: `internal/config/decode.go` (`decodeLeaf` per-path switch: add a case mirroring `case "metadata_branch":` — an `obsolete-setting` warning whose remedy says "remove finalize.skip_results_only_delta from ") +- Modify: `.docket.yml` (delete the comment block beginning "Arms the gate's docs-only post-gate skip" and the `skip_results_only_delta: false` line) +- Test: `internal/config/capability_test.go`, `decode_test.go`, `fixtures_test.go`, `preflight_test.go`, `resolve_test.go`, `schema_test.go`, `setting_paths_test.go`; `internal/app/config_test.go`, `status_corpus_test.go`; `internal/app/finalize_e2e_test.go` (`TestE2EUnsupportedConfigRefused`: switch to another `dispDeferred` key from `schema.go`, e.g. the next deferred bool row) +- Modify: `docs/release/upgrading-from-bash.md` (its `skip_results_only_delta` row now says it is obsolete and ignored with a warning; remedy "Remove it.") + +- [ ] **Step 1: Write the failing decode test** + +In `decode_test.go` beside the `metadata_branch` obsolete case: a layer setting `finalize: {skip_results_only_delta: true}` decodes with one `obsolete-setting` diagnostic, classification Obsolete, no blocker, and `PreflightMutation` allows writes. Run `go test -count=1 -run 'Obsolete|SkipResults' ./internal/config/` → FAIL. + +- [ ] **Step 2: Implement** the row change and the decode case. Run the whole `./internal/config/` package; update each test that pinned the deferred classification (`deferred-setting` notice for `false`, `deferred-capability-requested` blocker for `true`) to the obsolete classification. Frozen corpora under `testdata/` stay untouched; a test that loads one and counted deferred notices now counts an obsolete warning instead — update the count, never the corpus. + +- [ ] **Step 3: Run** + +`go test -count=1 ./internal/config/ ./internal/app/ ./internal/repoguard/` and `go test -tags e2e -count=1 -run '^TestE2EUnsupportedConfigRefused' ./internal/app/`. `TestLivingDocsAlignment` must still pass (the docs row no longer describes the key as unsupported-blocking). Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add internal/config/schema.go internal/config/decode.go .docket.yml docs/release/upgrading-from-bash.md +git commit -m "refactor(config): finalize.skip_results_only_delta is an obsolete tombstone" +``` + +--- + +### Task 13: End-to-end acceptance pins + +**Build tier:** standard + +**Files:** +- Modify: `internal/app/workflow_e2e_test.go` (`runClaimToImplemented`) +- Modify: `internal/app/finalize_e2e_test.go` (`TestE2EOrdinaryFinalize`) + +**Interfaces:** consumes Tasks 2–11. + +- [ ] **Step 1: Extend `runClaimToImplemented`** + +The record fixture `buildReadyChange(id, slug)` gains `spec:` pointing at a spec seeded on `docket` (with a backlink block). Insert, right after `WorkspacePrepare`, `WorkspaceCommitSpec` → `committed`. After `PRPublish`, assert (spec acceptance 1): + +```go +diff := runGit(t, wp, "diff", "--name-only", "--no-renames", prep.BaseCommit+".."+head) +if got := strings.Fields(diff); !reflect.DeepEqual(got, []string{specPath, "widget.go"}) { + t.Fatalf("PR diff = %v, want exactly the spec copy and the code", got) +} +first := runGit(t, wp, "rev-list", "--reverse", prep.BaseCommit+".."+head) +if strings.Fields(first)[0] != specCommit { // specCommit from the WorkspaceCommitSpec result's Head + t.Fatalf("the spec copy is not the feature branch's first commit") +} +``` + +After mark-implemented, assert the plan file, the results file, and the record's `## Build evidence` section all exist at the metadata remote tip (`blobRevisionAt` / a blob read on `m.branch`). Keep the Task 8 post-gate results checkpoint and add: the record evidence still verifies against `head` (`VerifyRecordEvidence`). Extend `assertEngineOnlyMetadataCommits`'s operation list only if a new metadata operation key now appears in this flow (it should not: `workspace.commit-spec` is a feature-branch commit). + +- [ ] **Step 2: Extend `TestE2EOrdinaryFinalize`** + +Seed a post-gate results checkpoint (a second `ChangeAttachResults` after evidence) before finalize; assert finalize's rebase reports the no-op skip (`Gate.Compose == gateComposeSkipped`) — spec acceptance 2, second half — and that `origin/main` after close-out holds the merge commit plus nothing else (no backlink commit). + +- [ ] **Step 3: Run** + +`go test -tags integration -count=1 -run '^TestIntegrationWorkflowLifecycle' ./internal/app/`; `... -run '^TestIntegrationNamed' ...`; `go test -tags e2e -count=1 -run '^TestE2E' ./internal/app/`. Expected: PASS. If an assert passes on the first run, mutation-check it once (e.g., temporarily commit a plan file in the workspace before publishing → the diff assert must FAIL), then restore. + +- [ ] **Step 4: Commit** + +```bash +git add internal/app/workflow_e2e_test.go internal/app/finalize_e2e_test.go +git commit -m "test(workflow): pin the spec-plus-code PR and the post-gate results checkpoint" +``` + +--- + +### Task 14: `docket-implement-next` prose + +**Build tier:** standard + +**Files:** +- Modify: `skills/docket-implement-next/SKILL.md`, `skills/docket-implement-next/references/edge-paths.md`, `skills/docket-implement-next/references/fix-pass.md`, `skills/docket-implement-next/results-template.md` +- Regenerate: `internal/assets/embedded/tree/skills/docket-implement-next/**` (`go generate ./internal/assets/`) +- Test: `internal/repoguard/prose_contracts_test.go` (only where a pinned phrase must change — keep every pinned clause that is still true verbatim), `internal/repoguard/budgets_test.go` (only if a ceiling must move) + +Describe current behavior only. Rewrite (sites from the prose map; re-derive with `git grep -n -i 'feature branch\|Docket-Plan-Path\|artifact.backlink\|build-evidence\|PR body\|post-gate' skills/docket-implement-next`): + +- [ ] **Step 1: Step 0 and Step 4** + - Step 0: bookkeeping on `docket` includes the plan, results, and build evidence; "only the spec copy and the code land on the feature branch". + - Step 4: after `workspace.prepare`, run the `workspace.commit-spec` operation with `--id ` (it commits the spec copy as the branch's first commit; a trivial change reports `no-spec`; a `spec-path-occupied` or `workspace-not-ready` refusal halts). Record HEAD after it as the pre-dispatch HEAD. + - The `docket:feature-dispatch` block for `docket-plan-writer` keeps the exact `Feature worktree: ` line; its payload adds the exact record revision; the child authors the plan in a scratch file outside both worktrees and runs `change.attach-plan` with `--id --revision --path docs/superpowers/plans/-.md --markdown `, which writes the plan with its backlink on `docket` and sets `plan:` in one commit; it returns `PLAN_PATH=`. + - **Verification (parent):** re-sync `.docket/` (`repository.prepare`), then verify from git on the metadata branch that `plan:` equals the returned path and the file exists at the metadata tip with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. Delete every trailer, single-file-delta, and "plan file merges with the code" clause. + - **Continue:** delete "at close-out `finalize.closeout` re-points the merged copy"; the archive transaction retargets every metadata backlink in its own commit. + - Build workers and reviewers read the plan from the metadata worktree path (`.docket/`, after a re-sync). +- [ ] **Step 2: Step 6 and Step 6.5** + - Results are authored from `results-template.md` in a scratch file; the current content is read from `.docket/` after a re-sync; each checkpoint is one `change.attach-results` call with `--id --revision --path --markdown`, a metadata commit that never moves the feature head. Replace the six per-checkpoint mechanics with: verify ownership/quiescence; read and preserve prior content; write the update; attach. Delete "commit only the results path", "publish through the feature-branch transport", and the "local commit vs remote publication" paragraph. + - **Evidence sequencing:** results prose is never gate evidence, and a results checkpoint never moves HEAD, so it never stales evidence; obtain gate evidence for the final code head; any code commit (a fix) still invalidates evidence. Delete the "post-gate results edit invalidates stale evidence" sentence. Keep "never commit or move HEAD beneath a live gate, a running worker, or a transferred drive" verbatim (pinned) — it still governs code commits. + - Keep every pinned clause of `TestResultsReviewPlacementDocContracts` verbatim (the review disposition table stays in the PR body). +- [ ] **Step 3: Step 7, postconditions, branch discipline** + - PR-body assembly: the authored body carries the `#` reference and the backlink line; `pr.publish` adds the plan/results links block; the PR body carries no build-evidence block. `pr.publish` still requires `--evidence` and verifies it against the head. + - Mark implemented: it also records the verified evidence as the record's `## Build evidence` section in the same transaction; the results artifact is read from the metadata branch. + - Postcondition rows: row 4 → "the spec copy committed as the first commit on `/` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the `docket` branch, the feature HEAD unchanged by planning"; row 7 → "… `results:` set and the final results file with its backlink on the `docket` branch, and the record's `## Build evidence` section naming the PR head". Delete the "a later commit moving branch HEAD (Step 6.5's results file)" staleness sentence. + - Field-write rule and feature-branch invariants: the feature branch adds only the spec copy and the code; plan and results files are metadata writes through their attach transactions. +- [ ] **Step 4: `edge-paths.md`, `fix-pass.md`, `results-template.md`** + - Plan seam: `plan:` set → planning is complete (the attach wrote file and field together); unset → re-dispatch the plan-writer. Results seam: reuse the `results:` path when set; never mint a second file. Resume after a mid-flight spec revision: re-run `workspace.commit-spec` before building continues (it refreshes the copy). + - Delete the "Build-evidence block" paragraph of `## PR-body assembly (Step 7)` and its expected-staleness rule; keep the issue-reference and backlink paragraphs; add one sentence on the plan/results links block. + - `fix-pass.md`: keep "the **PR body remains the disposition table's durable home**"; replace "the block `docket-finalize-change` reads" with a statement that build evidence lives in the change record; keep "The results file never carries the machine build-evidence block". + - `results-template.md` header comment: authored by the coordinator, written on the `docket` branch through `change.attach-results` at each checkpoint; the backlink block is owned by that operation. +- [ ] **Step 5: Regenerate and run the guards** + +```bash +go generate ./internal/assets/ +go test -count=1 ./internal/assets/ ./internal/repoguard/ +``` + +Expected: PASS (`TestEmbeddedMatchesAuthored`, `TestProseContracts`, `TestResultsReviewPlacementDocContracts`, `TestFollowUpBacklogMatchDocContracts`, `TestFeatureDispatchPayloadsCarryCanonicalWorktree`, `TestSkillSizeBudgets`, `TestLivingDocsAlignment`, `TestCapabilitySurface`). A size breach is fixed by tightening prose first; re-baseline a row only with a reason. + +- [ ] **Step 6: Commit** + +```bash +git add skills/docket-implement-next internal/assets/embedded +git commit -m "docs(implement-next): plan, results, and evidence on the metadata branch; the spec copy rides the PR" +``` + +--- + +### Task 15: Agents, cursor rules, and the other skills + +**Build tier:** standard + +**Files:** +- Modify: `agents/docket-plan-writer.md`, `cursor-rules/dispatch/docket-plan-writer.md` +- Modify: `skills/docket-convention/SKILL.md`, `skills/docket-convention/references/close-out.md`, `skills/docket-convention/references/stacked-changes.md` +- Modify: `skills/docket-build/SKILL.md`, `skills/docket-finalize-change/SKILL.md`, `skills/docket-status/SKILL.md`, `skills/docket-review/SKILL.md` (only if it calls the evidence record's home the PR body) +- Regenerate: `internal/assets/embedded/tree/**` and the harness goldens for `docket-plan-writer` (`internal/harness/{claude,cursor,opencode}/testdata/golden/docket-plan-writer.md`, `internal/harness/codex/testdata/golden/docket-plan-writer.toml`) + +- [ ] **Step 1: `docket-plan-writer`** (agent + cursor dispatch copy). Keep `worktree-scope: feature` (it reads the feature tree and verifies it stays clean). Contract: owns exactly one durable artifact, the plan file on the metadata branch, written through exactly one docket operation, `change.attach-plan` (argv from the capability catalog); writes nothing in either worktree; the payload carries the record revision. Sequence: (1) confirm the feature worktree is clean at the pre-dispatch HEAD; (2) read change, spec, feature code, learnings; (3) invoke `superpowers:writing-plans` DIRECTED to write the plan to a scratch file made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there; (4) missing-skill fallback; (5) run `change.attach-plan` with `--id --revision --path docs/superpowers/plans/-.md --markdown `; a refusal is a blocking diagnostic; (6) re-confirm the feature worktree is clean at the pre-dispatch HEAD; (7) `PLAN_PATH=`. The description frontmatter changes to match ("writes the plan artifact with its backlink on the metadata branch through change.attach-plan"). +- [ ] **Step 2: `docket-convention`** + - Agent-layer exceptions sentence: the plan-writer performs exactly one docket metadata operation (`change.attach-plan`); the others perform none. Composition: the plan-writer's proof is git state on the `docket` branch (`plan:` set, the plan file with its backlink at the tip). + - Role table: plan → "a plan file on the `docket` branch, recorded in `plan:`". + - Directory layout: `/` comment → "on the `docket` branch" (keep the pinned clause "required close-out artifacts (one per implemented change, trivial included)" verbatim); add the plans directory if the layout lists specs. + - Results artifact shape: the results file and `results:` field are written together by `change.attach-results` on the `docket` branch. + - Manifest comments for `plan:` and `results:`. + - Frozen-artifact rule → "Closed-out plans and results are frozen build records." The old sentence `Merged plans and results are frozen build records.` is pinned by `test_results_artifact` in `prose_contracts_test.go`; its claim is now false (plans no longer merge), so change that pin to the new sentence in this task. The only later writes re-stamp the backlink, done by the archive transaction. + - `## Artifacts` bullet: relative links for same-branch artifacts; the attach operations and `change.groom` write file backlinks; `pr.publish` writes the PR body's (absolute), repointed at archive. + - Derived views paragraph: drop `artifact.backlink`. + - Branch model: "the feature branch adds the spec copy and the code"; "the integration branch gets code and specs through PRs alone". + - `close-out.md`: the archive commit re-renders `## Artifacts` (relative rows, plus `Spec (merged)`) and re-stamps every metadata backlink — spec, plan, results; nothing touches the integration branch. `stacked-changes.md`: "carries only the spec copy and the code". + - Missing-skill rule's "in the PR body" for plan/build/review stays (it is about warnings, not evidence). +- [ ] **Step 3: `docket-build`, `docket-finalize-change`, `docket-status`** + - Build: Inputs → the plan at `.docket/`; the controller's build checkpoint and the time-limit audit line go to the results file through `change.attach-results`; "Step 7 writes it into the PR body" → "`change.mark-implemented` records it in the change record"; Output's "(and the PR description where evidence belongs)" → "(and the change record's build evidence)". + - Finalize: §3/§4 — the no-op skip reads green build evidence for the exact head **from the change record's `## Build evidence` section**, recorded against the resolved `finalize.test_command`; keep "There is no strict-ancestor or results-only skip." (still true and pinned); §7 — `finalize.publish` records the rebased head's evidence in the change record; §9 — delete the integration-ref leg and `final-backlink-pending`; close-out is one metadata commit plus the best-effort PR-description backlink repoint; §10 — cleanup repairs only the PR description's backlink. + - Status: `artifact-missing` (pinned) is reported when a linked plan or results file is on neither the metadata branch nor (for records closed before the move) the integration branch. +- [ ] **Step 4: Regenerate and run** + +```bash +go generate ./internal/assets/ +go test -count=1 ./internal/harness/... -update # only the golden tests; read the flag first +go test -count=1 ./internal/assets/ ./internal/harness/... ./internal/repoguard/ +``` + +Expected: PASS. Commit: + +```bash +git add agents/docket-plan-writer.md cursor-rules/dispatch/docket-plan-writer.md skills internal/assets/embedded internal/harness internal/repoguard/prose_contracts_test.go +git commit -m "docs(skills): the plan-writer attaches on the metadata branch; evidence lives in the record" +``` + +--- + +### Task 16: Living docs and the final sweep + +**Build tier:** standard + +**Files (derive the final list with the Step 1 grep; these are known):** `README.md`; `docs/guide/where-the-metadata-lives.md`, `landing-changes.md`, `proving-the-build.md`, `building-without-supervision.md`, `reviewing-before-the-human.md`, `keeping-the-backlog-honest.md`; `docs/concepts/two-branches.md`, `build-tiers-and-gate.md`, `finalize-sequencer.md`, `skills-agents-dispatch.md`, `run-tracker.md`; `docs/reference/glossary.md`, `skills-and-agents.md`, `outcomes.md`, `fields.md` (only if it describes a location). + +- [ ] **Step 1: Derive the sites** + +```bash +hits=$(git grep -n -i -E 'feature branch|plan and results|plans, and results|build evidence|build-evidence|pull request body|PR body|PR description|artifact\.backlink|artifact backlink|final-backlink-pending|Docket-Plan-Path|retarget' -- README.md docs/guide docs/concepts docs/reference docs/install) +printf '%s\n' "$hits" +``` + +Sort each hit: true today (leave) or describes removed behavior (rewrite). + +- [ ] **Step 2: Rewrite** to current behavior only, no change numbers, no mention of private visibility: + - Where artifacts live: change record, ADRs, learnings, board, spec, plan, results, and build evidence on the metadata branch; the integration branch receives the spec copy and the code through the pull request; nothing is pushed to it at close-out. Update the `where-the-metadata-lives.md` table to the spec's "After" table. + - Build evidence: lives in the change record's `## Build evidence` section; `finalize publish` and `evidence recertify` refresh it there; a results checkpoint never moves the feature head, so it never forces a retest. + - Close-out: one metadata commit archives the record and retargets every artifact backlink; the merged PR's description backlink is repointed best-effort. + - Links: same-branch links in records and artifacts are relative; `docket repository repair` converts older absolute ones. + - Glossary entries: Archived record, Derived view/backlink, Frozen build record, Plan, Results, Build evidence, Plan writer (attaches through `change attach-plan`), PR publish (no evidence block; adds plan/results links), Re-certify, Run verify (drop the stale "verify-run" alias line if it is the only claim left there), Finalize gate, Finalize publish; remove the `artifact.backlink` entries. +- [ ] **Step 3: Run the docs guards** + +`go test -count=1 -run 'LivingDocs|Alignment|ProseContracts|CapabilitySurface' ./internal/repoguard/` → PASS. + +- [ ] **Step 4: Final whole-repo sweep** + +For each retired token — `Docket-Plan-Path`, `Docket-Results-Path`, `artifact.backlink`, `artifact backlink`, `final-backlink-pending`, `closeout-backlink`, `skip_results_only_delta`, `prBodyEvidence`, `lifecycleBranch`, `commitPlanFile` — run `git grep -n -F -- ''` and confirm every remaining hit is a point-in-time record (`docs/superpowers/`, `docs/results/`, `docs/changes/`, `docs/adrs/`), a frozen `testdata/` corpus, a retired-vocabulary guard row, or the obsolete-key tombstone. List the residual classification in the commit body. + +- [ ] **Step 5: Commit** + +```bash +git add README.md docs/guide docs/concepts docs/reference +git commit -m "docs: build artifacts live on the metadata branch; the spec ships with the PR" +``` + +--- + +## Spec coverage (self-review) + +| Spec item | Task | +|---|---| +| §1 plan on the metadata branch; attach-plan transaction; refusals removed; receipt/verification on metadata | 7, 14, 15 | +| §2 results on the metadata branch; each checkpoint a metadata commit; readers at the metadata tip | 8, 13, 14 | +| §3 `## Build evidence` section; writers (`finalize.publish`, `evidence.recertify`, `mark-implemented`); readers; PR body carries none; catalog effects | 1–5 (`pr.publish` narrowing: Global Constraints) | +| §4 spec copy as first commit; change line seam; refresh after re-groom; trivial; occupied path; `Spec (merged)` row | 6, 10, 13 | +| §5 relative links; backlinks retargeted in the archive commit; PR rows absolute; PR plan/results links; one-time conversion via check/repair; status fallback | 7, 8, 10 | +| §6 retire integration legs, trailer, `Docket-Results-Path`, `skip_results_only_delta`, `artifact.backlink` | 7, 9, 11, 12 | +| §7 skills, agents, embedded twins, prose pins | 14, 15 | +| §8 living docs | 16 | +| Acceptance 1 (PR diff = spec copy + code; artifacts on metadata) | 13 | +| Acceptance 2 (post-gate results checkpoint keeps head; no finalize retest) | 8, 13 | +| Acceptance 3 (record-read parity; removing the read reddens) | 4 | +| Acceptance 4 (no integration push outside a PR merge, pinned) | 11 | +| Acceptance 5 (spec copy first commit, content, refresh) | 6, 13 | +| Acceptance 6 (relative links survive archive; repair converts once; second run no-op) | 10 | +| Acceptance 7 (whole suite green; docs guards) | 16 + docket-build's final gate | +| ADR expected | implement-next Step 6 (`docket-adr`), not a build task | + +## Human verification items (for the results file) + +- **Important — install ordering (spec Cutover).** Every change that is `in-progress` or `implemented` when this merges must finish (merge and close out) on the previous binary before the new binary is installed. A change caught mid-flight recovers with `docket evidence recertify` (writes the record section) and re-attaches its plan/results through the new attach operations. +- **Important — one-time link conversion.** After installing, run `docket repository check`: expect `artifact-links-stale` warnings for records with absolute same-branch links. Run `docket repository repair`, review the preview, apply with `--yes`, and re-run `repository check` to confirm the warnings are gone and archived records closed before this change still link their plan/results on `main`. +- **Optional — first run on the new flow.** On the next implement-next run, confirm the PR's "Files changed" tab shows only the spec copy and code, and that the change record on `docket` shows the plan, results, and `## Build evidence` section. From f02eb3821adfd8c06826b04e25beea143f0d9e3e Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 23:07:13 -0400 Subject: [PATCH 02/28] feat(evidence): the change record's ## Build evidence section --- internal/app/evidence_section.go | 106 ++++++++++++++++++++++++ internal/app/evidence_section_test.go | 112 ++++++++++++++++++++++++++ internal/domain/entities.go | 7 ++ internal/repository/decode.go | 2 + internal/repository/decode_test.go | 24 ++++++ 5 files changed, 251 insertions(+) create mode 100644 internal/app/evidence_section.go create mode 100644 internal/app/evidence_section_test.go diff --git a/internal/app/evidence_section.go b/internal/app/evidence_section.go new file mode 100644 index 000000000..d6a7711bb --- /dev/null +++ b/internal/app/evidence_section.go @@ -0,0 +1,106 @@ +package app + +import ( + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/render" +) + +// This file owns the change record's operation-owned "## Build evidence" +// section: the durable home of a change's build evidence. The section body is +// exactly the evidence codec's build-evidence block (evidence.Render), so the +// bytes a reader extracts are the bytes the PR description used to carry. The +// section is never authored: change.groom's owned-heading allowlist +// (render.ChangeOwnedHeadings) does not contain it. + +// buildEvidenceHeading is the exact H2 line of the section. +const buildEvidenceHeading = "## Build evidence" + +// recordEvidenceSection returns the section body, whether it is present, and an +// error for a duplicated heading. Headings inside fenced code are content +// (namedSectionBody scans fence-aware). +func recordEvidenceSection(record []byte) (string, bool, error) { + return namedSectionBody(record, buildEvidenceHeading) +} + +// ReadRecordEvidence extracts the record's build evidence. An absent section is +// evidence.ErrMissing; a duplicated section or a malformed block is an error. +func ReadRecordEvidence(record []byte) (evidence.Record, error) { + body, present, err := recordEvidenceSection(record) + if err != nil { + return evidence.Record{}, fmt.Errorf("record evidence: %w", err) + } + if !present { + return evidence.Record{}, evidence.ErrMissing + } + return evidence.Extract([]byte(body)) +} + +// VerifyRecordEvidence is evidence.Verify scoped to the record's section: missing +// when absent, malformed when the section is duplicated or its block unreadable. +func VerifyRecordEvidence(record []byte, head string) evidence.Verdict { + body, present, err := recordEvidenceSection(record) + if err != nil { + return evidence.VerdictMalformed + } + if !present { + return evidence.VerdictMissing + } + return evidence.Verify([]byte(body), head) +} + +// UpsertRecordEvidence makes rec (green or skipped) the whole body of the +// record's "## Build evidence" section, appending the section at EOF when it is +// absent and preserving every other byte. The record is re-normalized through +// the codec constructors, rendered in the record's own line ending, and the +// candidate must read back as exactly rec before any bytes are returned. +func UpsertRecordEvidence(record []byte, rec evidence.Record) ([]byte, error) { + var norm evidence.Record + var err error + switch rec.Result { + case evidence.ResultGreen: + norm, err = evidence.NewRecord(rec.Command, rec.Head, rec.RanAt) + case evidence.ResultSkipped: + norm, err = evidence.NewSkippedRecord(rec.Head, rec.RanAt) + default: + return nil, fmt.Errorf("record evidence: result %q is neither green nor skipped", rec.Result) + } + if err != nil { + return nil, err + } + doc, err := document.Parse(record) + if err != nil { + return nil, fmt.Errorf("record evidence: malformed record: %w", err) + } + block := evidence.Render(norm) + if le := doc.LineEnding(); le != "" && le != "\n" { + block = strings.ReplaceAll(block, "\n", le) + } + out, err := render.ApplySectionEdits(record, []string{buildEvidenceHeading}, + []render.SectionEdit{{Heading: buildEvidenceHeading, Intent: render.SectionReplace, Markdown: block}}) + if err != nil { + return nil, fmt.Errorf("record evidence: %w", err) + } + got, err := ReadRecordEvidence(out) + if err != nil { + return nil, fmt.Errorf("record evidence: candidate failed reparse: %w", err) + } + if got != norm { + return nil, fmt.Errorf("record evidence: candidate reparsed to a different record") + } + return out, nil +} + +// recordEvidenceFacts is the record-section reader the finalize gates share: the +// recorded head and command, and whether the record is green. Any read failure +// — absent, duplicated, malformed — reads as no green evidence. +func recordEvidenceFacts(record []byte) (head, command string, green bool) { + rec, err := ReadRecordEvidence(record) + if err != nil { + return "", "", false + } + return rec.Head, rec.Command, rec.Result == evidence.ResultGreen +} diff --git a/internal/app/evidence_section_test.go b/internal/app/evidence_section_test.go new file mode 100644 index 000000000..b9786b244 --- /dev/null +++ b/internal/app/evidence_section_test.go @@ -0,0 +1,112 @@ +package app + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/danielhanold/docket/internal/evidence" +) + +const esHead = "0123456789abcdef0123456789abcdef01234567" + +func esRecord() string { + return "---\nid: 7\nslug: x\n---\n\n## Artifacts\n\n\n\n\n## Why\n\nBecause.\n" +} + +func esGreen(t *testing.T) evidence.Record { + t.Helper() + r, err := evidence.NewRecord("go test ./...", esHead, time.Date(2026, 10, 5, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatal(err) + } + return r +} + +func TestUpsertRecordEvidenceAppendsSection(t *testing.T) { + rec := esGreen(t) + out, err := UpsertRecordEvidence([]byte(esRecord()), rec) + if err != nil { + t.Fatalf("upsert: %v", err) + } + want := esRecord() + "\n## Build evidence\n\n" + evidence.Render(rec) + "\n" + if string(out) != want { + t.Fatalf("upsert bytes:\n%q\nwant\n%q", out, want) + } + got, err := ReadRecordEvidence(out) + if err != nil || got != rec { + t.Fatalf("read back = %+v, %v; want %+v", got, err, rec) + } +} + +func TestUpsertRecordEvidenceReplacesInPlace(t *testing.T) { + first := esGreen(t) + src := esRecord() + "\n## Build evidence\n\n" + evidence.Render(first) + "\n\n## Reconcile log\n\n### 2026-10-05\n\nok\n" + second, _ := evidence.NewSkippedRecord(esHead, time.Date(2026, 10, 6, 0, 0, 0, 0, time.UTC)) + out, err := UpsertRecordEvidence([]byte(src), second) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if !strings.Contains(string(out), "## Build evidence\n\n"+evidence.Render(second)+"\n\n## Reconcile log") { + t.Fatalf("section not replaced in place:\n%s", out) + } + if strings.Count(string(out), "## Build evidence") != 1 { + t.Fatalf("duplicate section:\n%s", out) + } + if !strings.HasSuffix(string(out), "## Reconcile log\n\n### 2026-10-05\n\nok\n") { + t.Fatalf("bytes after the section changed:\n%s", out) + } +} + +func TestUpsertRecordEvidenceKeepsCRLF(t *testing.T) { + crlf := strings.ReplaceAll(esRecord(), "\n", "\r\n") + out, err := UpsertRecordEvidence([]byte(crlf), esGreen(t)) + if err != nil { + t.Fatalf("upsert: %v", err) + } + if strings.Contains(strings.ReplaceAll(string(out), "\r\n", ""), "\n") { + t.Fatalf("mixed line endings in a CRLF record:\n%q", out) + } +} + +func TestReadRecordEvidenceMissingAndFenced(t *testing.T) { + if _, err := ReadRecordEvidence([]byte(esRecord())); !errors.Is(err, evidence.ErrMissing) { + t.Fatalf("absent section err = %v, want ErrMissing", err) + } +} + +func TestReadRecordEvidenceIgnoresFencedHeading(t *testing.T) { + src := esRecord() + "\n```\n## Build evidence\n\n" + evidence.Render(esGreen(t)) + "\n```\n" + if _, err := ReadRecordEvidence([]byte(src)); !errors.Is(err, evidence.ErrMissing) { + t.Fatalf("fenced heading read as the section: err = %v", err) + } + if v := VerifyRecordEvidence([]byte(src), esHead); v != evidence.VerdictMissing { + t.Fatalf("verify fenced = %q, want missing", v) + } +} + +func TestVerifyRecordEvidenceVerdicts(t *testing.T) { + out, _ := UpsertRecordEvidence([]byte(esRecord()), esGreen(t)) + if v := VerifyRecordEvidence(out, esHead); v != evidence.VerdictVerified { + t.Fatalf("exact head = %q", v) + } + if v := VerifyRecordEvidence(out, strings.Repeat("f", 40)); v != evidence.VerdictStale { + t.Fatalf("other head = %q", v) + } + dup := string(out) + "\n## Build evidence\n\nx\n" + if v := VerifyRecordEvidence([]byte(dup), esHead); v != evidence.VerdictMalformed { + t.Fatalf("duplicated section = %q, want malformed", v) + } +} + +func TestRecordEvidenceFacts(t *testing.T) { + out, _ := UpsertRecordEvidence([]byte(esRecord()), esGreen(t)) + h, cmd, green := recordEvidenceFacts(out) + if h != esHead || cmd != "go test ./..." || !green { + t.Fatalf("facts = %q %q %v", h, cmd, green) + } + if h, cmd, green := recordEvidenceFacts([]byte(esRecord())); h != "" || cmd != "" || green { + t.Fatalf("absent facts = %q %q %v", h, cmd, green) + } +} diff --git a/internal/domain/entities.go b/internal/domain/entities.go index 5fa6cd653..a204163cc 100644 --- a/internal/domain/entities.go +++ b/internal/domain/entities.go @@ -106,6 +106,7 @@ type ChangeSpec struct { HasAutoGroomBlocked bool // "## Auto-groom blocked" present HasFinalizeBlocked bool HasPublishDeferred bool + HasBuildEvidence bool // "## Build evidence" body section present } // Change is an immutable change manifest. Every caller-owned slice is copied @@ -222,6 +223,12 @@ func (c Change) ArchiveDate() OptionalTime { return c.spec.ArchiveDate } // HasRunHalted reports whether the body carries a "## Run halted" section. func (c Change) HasRunHalted() bool { return c.spec.HasRunHalted } +// HasBuildEvidence reports whether the body carries the operation-owned +// "## Build evidence" section — the durable build-evidence record that +// change.mark-implemented first writes. Its presence also marks a change built +// by the metadata-branch artifact flow (render's legacy-link rule keys on it). +func (c Change) HasBuildEvidence() bool { return c.spec.HasBuildEvidence } + // HasAutoGroomBlocked reports whether the body carries an // "## Auto-groom blocked" section. func (c Change) HasAutoGroomBlocked() bool { return c.spec.HasAutoGroomBlocked } diff --git a/internal/repository/decode.go b/internal/repository/decode.go index 9e81e9712..9e8cea6a4 100644 --- a/internal/repository/decode.go +++ b/internal/repository/decode.go @@ -438,6 +438,7 @@ const ( markerAutoGroomBlocked = "## Auto-groom blocked" markerFinalizeBlocked = "## Finalize blocked" markerPublishDeferred = "## Publish deferred" + markerBuildEvidence = "## Build evidence" ) // hasHeading reports whether text carries heading as a whole line, matched @@ -578,6 +579,7 @@ func decodeChange(in InputDocument) (domain.Change, []domain.Finding) { spec.HasAutoGroomBlocked = hasHeading(text, markerAutoGroomBlocked) spec.HasFinalizeBlocked = hasHeading(text, markerFinalizeBlocked) spec.HasPublishDeferred = hasHeading(text, markerPublishDeferred) + spec.HasBuildEvidence = hasHeading(text, markerBuildEvidence) return domain.NewChange(spec), d.findings } diff --git a/internal/repository/decode_test.go b/internal/repository/decode_test.go index 4f080a497..fee81331b 100644 --- a/internal/repository/decode_test.go +++ b/internal/repository/decode_test.go @@ -420,6 +420,30 @@ func TestDecodeChangePresenceMarkers(t *testing.T) { } } +// TestDecodeChangeBuildEvidenceMarker — the "## Build evidence" section counts +// only as a whole bare heading line outside fenced code. +func TestDecodeChangeBuildEvidenceMarker(t *testing.T) { + tests := []struct { + name string + body string + want bool + }{ + {"bare heading", "## Build evidence\n\nblock\n", true}, + {"CRLF bare heading", "## Build evidence\r\n", true}, + {"fenced heading does not count", "```\n## Build evidence\n```\n", false}, + {"annotated heading does not count", "## Build evidence — note\n", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + source := "---\nid: 1\nslug: s\n---\n\n" + tc.body + change, _ := decodeChange(input(t, KindChange, LocationActive, "docs/changes/active/0001-s.md", source)) + if got := change.HasBuildEvidence(); got != tc.want { + t.Errorf("HasBuildEvidence = %v, want %v", got, tc.want) + } + }) + } +} + // TestDecodeChangeMarkerInFrontmatterNotScanned — only the body is scanned. func TestDecodeChangeMarkerInFrontmatterNotScanned(t *testing.T) { const source = `--- From e17c8953bdaf59cf99c89e79468ddc10e36765bb Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 23:11:36 -0400 Subject: [PATCH 03/28] feat(mark-implemented): record the verified build evidence in the change record --- internal/app/change_implemented.go | 21 ++++++++++-- .../change_implemented_integration_test.go | 21 ++++++++++++ internal/app/change_implemented_test.go | 33 +++++++++++++++++-- 3 files changed, 70 insertions(+), 5 deletions(-) diff --git a/internal/app/change_implemented.go b/internal/app/change_implemented.go index 87b4051fe..1c13e18f4 100644 --- a/internal/app/change_implemented.go +++ b/internal/app/change_implemented.go @@ -26,7 +26,8 @@ import ( // adapter (the ready PR), and the reparsed build-evidence bytes, then applies the // landed domain.MarkImplemented action through one atomic, exact-revision // transaction that records the PR reference, status, updated date, artifact -// block, inline board, and an audit receipt. It does NOT clear the claim, delete +// block, the verified build evidence (the "## Build evidence" section), inline +// board, and an audit receipt. It does NOT clear the claim, delete // a branch or workspace, merge the PR, archive the change, or close descendants // (those are 0316 effects). // @@ -169,6 +170,11 @@ func ChangeMarkImplemented(ctx context.Context, deps PlanningDeps, wdeps Workspa return implementedRefusal(ResultInvalidState, ReasonImplementedEvidenceUnverified, "the reparsed evidence does not verify (green or skipped) against the supplied head ("+string(verdict)+")", req.ID) } + rec, err := evidence.Extract(req.EvidenceRecord) + if err != nil { + // Unreachable after a verified verdict, but fail closed rather than trust it. + return implementedRefusal(ResultInvalidState, ReasonImplementedEvidenceUnverified, err.Error(), req.ID) + } // Pin authoritative context, check the board surface, discover the repository. pin, err := deps.Reader.PinContext(ctx, repoDir) @@ -321,6 +327,7 @@ func ChangeMarkImplemented(ctx context.Context, deps PlanningDeps, wdeps Workspa inline: inline, link: linkContextOf(pin), changesDir: eff.ChangesDir.Value, + evidence: rec, } // The run mutation fence (change 0375 Task 11) covers this implemented // transition VIA the engine's AdmissionHook — the transition is one @@ -451,8 +458,9 @@ func verifyImplementedResults(ctx context.Context, deps PlanningDeps, repo gitcl // re-runs domain.MarkImplemented against the attempt's own fresh change (so a // concurrent edit that unreconciled or unlinked the record refuses inside the // transaction as well as at the pre-transaction reprobe), upserts the owned -// fields (status, pr, plan, updated), re-renders the artifact block, and — when -// inline is enabled — the board. It never touches claimed_at or branch: the claim +// fields (status, pr, plan, updated), re-renders the artifact block, writes the +// verified evidence record as the record's "## Build evidence" section, and — +// when inline is enabled — the board. It never touches claimed_at or branch: the claim // survives the transition (0316 owns claim release). type changeImplementedOp struct { changeID int @@ -462,6 +470,7 @@ type changeImplementedOp struct { inline bool link render.LinkContext changesDir string + evidence evidence.Record } func (o changeImplementedOp) Key() transaction.OperationKey { @@ -536,6 +545,12 @@ func (o changeImplementedOp) Plan(ctx context.Context, st transaction.AttemptSta if err != nil { return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("mark-implemented: writing artifact block: %w", err) } + // The implemented transition records the evidence it just verified as the + // record's durable "## Build evidence" section, in this same commit. + finalBytes, err = UpsertRecordEvidence(finalBytes, o.evidence) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("mark-implemented: recording build evidence: %w", err) + } files := []transaction.FileMutation{ {Path: gitcli.RepoPath(c.Path()), Kind: transaction.MutationReplace, Bytes: finalBytes}, diff --git a/internal/app/change_implemented_integration_test.go b/internal/app/change_implemented_integration_test.go index 64ec6f75f..3375a6a9a 100644 --- a/internal/app/change_implemented_integration_test.go +++ b/internal/app/change_implemented_integration_test.go @@ -8,6 +8,7 @@ package app import ( "context" + "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/githubcli" "testing" ) @@ -34,6 +35,26 @@ func TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence(t *testing.T) if res.Result != ResultApplied { t.Fatalf("result = %q, want applied — skipped evidence at the exact head must certify implemented (findings %v)", res.Result, res.Findings) } + + // Through the production engine, the committed record carries the skipped + // evidence it verified as its "## Build evidence" section. + recPath := groomPath(3, miSlug) + real := newWorkingRepo(t, map[string]string{recPath: miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false)}) + realHead := miAdvanceHead(t, real) + if res := miRealRunWith(t, real, recPath, realHead, prSkippedEvidenceBytes(t, realHead)); res.Result != ResultApplied { + t.Fatalf("real-engine result = %q, want applied (findings %v)", res.Result, res.Findings) + } + recordBytes, ok := originFile(t, real.origin, "docket", recPath) + if !ok { + t.Fatalf("implemented record %s absent on origin docket", recPath) + } + gotEv, err := ReadRecordEvidence([]byte(recordBytes)) + if err != nil { + t.Fatalf("implemented record carries no readable build evidence: %v\n%s", err, recordBytes) + } + if gotEv.Result != evidence.ResultSkipped || gotEv.Reason != evidence.ReasonBuildGateOff || gotEv.Head != realHead { + t.Fatalf("recorded evidence = %+v, want skipped/%s at %s", gotEv, evidence.ReasonBuildGateOff, realHead) + } } func TestIntegrationRecordOpsMarkImplementedUnrelatedInvalidRecordProgress(t *testing.T) { diff --git a/internal/app/change_implemented_test.go b/internal/app/change_implemented_test.go index 374d50677..403b09081 100644 --- a/internal/app/change_implemented_test.go +++ b/internal/app/change_implemented_test.go @@ -3,6 +3,7 @@ package app import ( "context" "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/render" @@ -11,6 +12,7 @@ import ( "github.com/danielhanold/docket/internal/workspace" "strings" "testing" + "time" ) // miRevision is the exact record revision the happy fixtures pin. @@ -36,9 +38,11 @@ func miRecord(id int, slug, plan, results string, reconciled, trivial bool) stri } // baseImplementedOp builds a mark-implemented transaction op for the plan-closure -// tests, wiring the inline board when the surfaces request it. +// tests, wiring the inline board when the surfaces request it. It carries +// miOpEvidence, the verified record the transition writes into the record. func baseImplementedOp(surfaces []string, id int, pr string) changeImplementedOp { return changeImplementedOp{ + evidence: miOpEvidence(), changeID: id, pr: pr, eff: planningTestConfig(surfaces), @@ -49,6 +53,16 @@ func baseImplementedOp(surfaces []string, id int, pr string) changeImplementedOp } } +// miOpEvidence is the green build-evidence record the plan-closure fixtures hand +// the op, as ChangeMarkImplemented extracts it from the verified --evidence bytes. +func miOpEvidence() evidence.Record { + rec, err := evidence.NewRecord("go test ./...", "0123456789abcdef0123456789abcdef01234567", time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) + if err != nil { + panic(err) + } + return rec +} + // implementedPlanFor runs the mark-implemented op's Plan closure over a fake tree // (mirrors claimPlanFor) so a test can inspect the patched record bytes directly. func implementedPlanFor(t *testing.T, files map[string]string, op changeImplementedOp) (transaction.MutationPlan, transaction.OperationResult) { @@ -107,6 +121,15 @@ func TestMarkImplementedApplies(t *testing.T) { if strings.Contains(rec, "updated: 2026-08-02") { t.Errorf("updated date not refreshed:\n%s", rec) } + // The transition records the evidence it verified as the record's + // "## Build evidence" section, in this same commit. + gotEv, err := ReadRecordEvidence([]byte(rec)) + if err != nil { + t.Fatalf("implemented record carries no readable build evidence: %v\n%s", err, rec) + } + if want := miOpEvidence(); gotEv != want { + t.Fatalf("recorded evidence = %+v, want %+v", gotEv, want) + } } // --- reprobe fixture -------------------------------------------------------- @@ -252,6 +275,12 @@ func firstStatusFindingCode(findings []StatusFinding) string { // miRealRun runs mark-implemented through the production planning seams over // repo, with every reprobe condition satisfied for head. func miRealRun(t *testing.T, repo *gitRepo, recPath, head string) ChangeLifecycleResult { + t.Helper() + return miRealRunWith(t, repo, recPath, head, prEvidenceBytes(t, head)) +} + +// miRealRunWith is miRealRun with the --evidence bytes supplied by the caller. +func miRealRunWith(t *testing.T, repo *gitRepo, recPath, head string, ev []byte) ChangeLifecycleResult { t.Helper() node := planningDepsFor(t, repo.invocation) wdeps := WorkspaceDeps{Service: &fakeWorkspaceService{ @@ -260,7 +289,7 @@ func miRealRun(t *testing.T, repo *gitRepo, recPath, head string) ChangeLifecycl gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo(), probePRs: []githubcli.PullRequest{happyPR(head)}}} req := MarkImplementedRequest{ ID: 3, Revision: blobRevisionAt(t, repo.origin, "docket", recPath), Head: head, - PR: prRepo().Spec() + "#42", EvidenceRecord: prEvidenceBytes(t, head), + PR: prRepo().Spec() + "#42", EvidenceRecord: ev, } return ChangeMarkImplemented(context.Background(), node.deps, wdeps, gdeps, node.dir, req) } From b3419bbc9646829da90eac3f594075ffe7280a6c Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 5 Oct 2026 23:39:02 -0400 Subject: [PATCH 04/28] feat(finalize): publish and recertify record build evidence in the change record --- internal/app/evidence_recertify.go | 78 ++++++++-- .../evidence_recertify_integration_test.go | 13 ++ internal/app/evidence_section.go | 144 ++++++++++++++++++ internal/app/evidence_section_test.go | 64 ++++++++ internal/app/finalize_publish.go | 89 +++++++++-- internal/app/finalize_publish_helpers_test.go | 22 +++ .../app/finalize_publish_integration_test.go | 7 + internal/app/finalize_rebase_test.go | 14 +- .../app/finalize_state_integration_test.go | 82 +++++++++- internal/cli/evidence.go | 6 +- internal/cli/finalize.go | 14 +- 11 files changed, 490 insertions(+), 43 deletions(-) diff --git a/internal/app/evidence_recertify.go b/internal/app/evidence_recertify.go index 1156b995d..9fb685681 100644 --- a/internal/app/evidence_recertify.go +++ b/internal/app/evidence_recertify.go @@ -16,10 +16,12 @@ import ( // This file is the `evidence recertify` operation (change 0415): for an // implemented change whose open PR received a PUBLISHED follow-up commit, it // reruns the BUILD gate at the current feature head, records and verifies -// canonical build evidence, and refreshes ONLY the existing PR's build-evidence -// block. The change stays implemented throughout; the operation performs no -// Docket metadata mutation, no push, no rebase, no merge, and no automatic -// repair. It composes only landed services: the workspace context/target +// canonical build evidence, refreshes ONLY the existing PR's build-evidence +// block, and records the evidence in the change record's "## Build evidence" +// section. The change stays implemented throughout; its only Docket metadata +// mutation is that section's exact-revision write (recordBuildEvidence), and it +// performs no push, no rebase, no merge, and no automatic repair. It composes +// only landed services: the workspace context/target // resolution, the build-owned production local gate (NewBuildLocalGate), the // build-owned evidence record path, and finalize publish's loss-preserving PR // evidence-block edit (evidence.Upsert + finalizePublishEnsurer) WITHOUT its @@ -31,12 +33,13 @@ const OperationEvidenceRecertify = "evidence.recertify" // The closed recertify outcomes. const ( - // RecertifyOutcomeGreen: the build gate passed and the PR's evidence block - // was converged onto the exact current head. + // RecertifyOutcomeGreen: the build gate passed, the PR's evidence block was + // converged onto the exact current head, and the change record records it. RecertifyOutcomeGreen = "green" // RecertifyOutcomeSkipped: build.gate is off; truthful skipped evidence was - // minted and verified. The PR block is untouched — evidence.Upsert is - // green-only by design, and this operation preserves evidence rendering. + // minted, verified, and recorded in the change record. The PR block is + // untouched — evidence.Upsert is green-only by design, and this operation + // preserves evidence rendering. RecertifyOutcomeSkipped = "skipped" ) @@ -67,6 +70,12 @@ const ( ReasonRecertifyEditorUnavailable = "pr-editor-unavailable" ReasonRecertifyEditContended = "pr-edit-contended" ReasonRecertifyEditUnknown = "pr-edit-unknown" + // ReasonRecertifyRecordContended: the change record moved between the read + // and the build-evidence write; the record is untouched — rerun recertify. + ReasonRecertifyRecordContended = "record-evidence-contended" + // ReasonRecertifyRecordFailed: the change record's build-evidence write did + // not apply for any other reason; never reported as completion. + ReasonRecertifyRecordFailed = "record-evidence-failed" ) // EvidenceRecertifyRequest is the closed request for `evidence recertify`. @@ -251,7 +260,8 @@ func EvidenceRecertify(ctx context.Context, deps FinalizeDeps, wdeps WorkspaceDe // Truthful skipped evidence at the verified current head; no run, and no // PR edit — evidence.Upsert is green-only by design (see // TestIntegrationFinalizeOpsPRPublishAcceptsSkippedEvidenceAtExactHead's note), and this - // operation preserves evidence rendering. + // operation preserves evidence rendering. The change record's section is + // the skipped record's durable home. evd := EvidenceRecord(ctx, deps.Planning, wdeps, repoDir, EvidenceRecordRequest{ID: facts.id, Head: facts.head}) if evd.Result != ResultApplied || evd.Block == "" { return recertifyRefusal(evd.Result, evd.Reason, evd.Message, facts.id) @@ -260,12 +270,17 @@ func EvidenceRecertify(ctx context.Context, deps FinalizeDeps, wdeps WorkspaceDe return recertifyRefusal(ResultInvalidState, ReasonRecertifyEvidenceUnverified, "the skipped evidence record did not verify against the current head ("+string(v)+")", facts.id) } - return EvidenceRecertifyResult{ - Envelope: NewEnvelope(OperationEvidenceRecertify, ResultApplied), + skipped, err := evidence.Extract([]byte(evd.Block)) + if err != nil { + // Unreachable after a skipped verdict, but fail closed rather than trust it. + return recertifyRefusal(ResultInvalidState, ReasonRecertifyEvidenceUnverified, err.Error(), facts.id) + } + return recertifyRecordEvidence(ctx, deps, repoDir, skipped, EvidenceRecertifyResult{ + Envelope: NewEnvelope(OperationEvidenceRecertify, ResultNoOp), ID: facts.id, Head: facts.head, Outcome: RecertifyOutcomeSkipped, Number: facts.pr.Number, Reference: fmt.Sprintf("%s#%d", facts.repo.Spec(), facts.pr.Number), URL: facts.pr.URL, - Message: "build.gate is off; truthful skipped evidence was recorded and verified (the PR evidence block is untouched — skipped evidence is never woven into a PR body)", - } + Message: "build.gate is off; truthful skipped evidence was verified and recorded in the change record (the PR evidence block is untouched — skipped evidence is never woven into a PR body)", + }) } if facts.build.TestCommand.Value == "" { return recertifyRefusal(ResultUnsupportedConfig, ReasonEvidenceUnconfiguredGate, @@ -337,7 +352,9 @@ func runRecertifyGate(ctx context.Context, deps FinalizeDeps, repoDir string, fa // cannot inherit the pass), re-pins the build command against the evidence, and // then loss-preservingly replaces ONLY the PR's build-evidence block, exactly // as FinalizePublish does — without its rebase receipt, PublishRewrite, or -// merge path. A failed or uncertain edit is never reported as completion. +// merge path — and, once the PR has converged, records the evidence in the +// change record. A failed or uncertain edit or record write is never reported +// as completion. func publishRecertifiedEvidence(ctx context.Context, deps FinalizeDeps, repoDir string, first recertifyFacts, block string) EvidenceRecertifyResult { if len(block) > maxAuthoredMarkdownBytes { return recertifyRefusal(ResultInvalidInput, ReasonRecertifyEvidenceUnverified, @@ -405,12 +422,12 @@ func publishRecertifiedEvidence(ctx context.Context, deps FinalizeDeps, repoDir switch eres.Disposition { case githubcli.EnsureCreated, githubcli.EnsureUpdated: base.Envelope = NewEnvelope(OperationEvidenceRecertify, ResultApplied) - return base + return recertifyRecordEvidence(ctx, deps, repoDir, rec, base) case githubcli.EnsureAdopted, githubcli.EnsureUnchanged: // The PR already carried this exact evidence — an idempotent replay. base.Envelope = NewEnvelope(OperationEvidenceRecertify, ResultNoOp) base.Message = "the pull request already carries verified evidence for this head" - return base + return recertifyRecordEvidence(ctx, deps, repoDir, rec, base) case githubcli.EnsureContended: return recertifyRefusal(ResultContended, ReasonRecertifyEditContended, "the pull request diverged under the update; rerun recertify", first.id) @@ -423,6 +440,35 @@ func publishRecertifiedEvidence(ctx context.Context, deps FinalizeDeps, repoDir } } +// recertifyRecordEvidence records rec in the change record's "## Build evidence" +// section after base (an applied or no-op result) has converged. A write that +// applied makes a no-op base applied; a no-op write keeps base; a record that +// moved under the write is contended (untouched); any other outcome is a +// failure — never reported as completion. +func recertifyRecordEvidence(ctx context.Context, deps FinalizeDeps, repoDir string, rec evidence.Record, base EvidenceRecertifyResult) EvidenceRecertifyResult { + out := recordBuildEvidence(ctx, deps.Planning, repoDir, OperationEvidenceRecertify, base.ID, rec) + switch out.Result { + case ResultApplied: + if base.Result == ResultNoOp { + base.Envelope = NewEnvelope(OperationEvidenceRecertify, ResultApplied) + if base.Outcome == RecertifyOutcomeGreen { + base.Message = "the pull request already carried verified evidence for this head; the change record now records it" + } + } + return base + case ResultNoOp: + return base + case ResultContended: + r := recertifyRefusal(ResultContended, ReasonRecertifyRecordContended, out.Message, base.ID) + r.Head = base.Head + return r + default: + r := recertifyRefusal(out.Result, ReasonRecertifyRecordFailed, out.Message, base.ID) + r.Head = base.Head + return r + } +} + // mapRecertifyEnsureFailure folds a githubcli EnsureFailed error onto the // protocol taxonomy (mirrors mapPublishEnsureFailure's kind mapping; the // failure's kind is the stable reason and its detail is bounded/redacted). diff --git a/internal/app/evidence_recertify_integration_test.go b/internal/app/evidence_recertify_integration_test.go index 555d2b709..4ab8042b6 100644 --- a/internal/app/evidence_recertify_integration_test.go +++ b/internal/app/evidence_recertify_integration_test.go @@ -201,6 +201,14 @@ func TestIntegrationEvidenceEvidenceRecertifyHappyPath(t *testing.T) { if gh.ensLast.ExpectedHead != f.head || gh.ensLast.ExpectedRevision == "" { t.Fatalf("PR edit was not pinned to head+revision: %+v", gh.ensLast) } + // The recertified record is durable in the change record's evidence section. + want, err := evidence.Extract([]byte(greenBlockFor(t, f.head))) + if err != nil { + t.Fatalf("evidence.Extract: %v", err) + } + if got := f.remoteRecordEvidence(t); got != want { + t.Fatalf("recorded evidence = %+v, want the recertified record %+v", got, want) + } } // TestIntegrationEvidenceEvidenceRecertifyAdvancesOneDriveAcrossWaiting: WAITING is nonterminal — @@ -280,6 +288,11 @@ func TestIntegrationEvidenceEvidenceRecertifyGateOffRecordsSkipped(t *testing.T) if gh.ensNext != 0 { t.Fatalf("a skipped recertify edited the PR block") } + // The skipped record's durable home is the change record's evidence section. + got := f.remoteRecordEvidence(t) + if got.Result != evidence.ResultSkipped || got.Reason != evidence.ReasonBuildGateOff || got.Head != f.head { + t.Fatalf("recorded evidence = %+v, want skipped/%s at %s", got, evidence.ReasonBuildGateOff, f.head) + } } // TestIntegrationEvidenceEvidenceRecertifyRefusesUnconfiguredGate: a local build gate with no diff --git a/internal/app/evidence_section.go b/internal/app/evidence_section.go index d6a7711bb..f78207bde 100644 --- a/internal/app/evidence_section.go +++ b/internal/app/evidence_section.go @@ -1,12 +1,21 @@ package app import ( + "bytes" + "context" + "encoding/json" "fmt" + "path" "strings" + "github.com/danielhanold/docket/internal/config" "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/render" + "github.com/danielhanold/docket/internal/reposetup" + "github.com/danielhanold/docket/internal/repository/transaction" ) // This file owns the change record's operation-owned "## Build evidence" @@ -104,3 +113,138 @@ func recordEvidenceFacts(record []byte) (head, command string, green bool) { } return rec.Head, rec.Command, rec.Result == evidence.ResultGreen } + +// recordEvidenceOutcome is the folded result of one record-evidence write. +type recordEvidenceOutcome struct { + Result Result // ResultApplied, ResultNoOp, ResultContended, or a failure class + Reason string + Message string + Revision string // the applied metadata commit, when applied +} + +// recordBuildEvidence writes rec into change id's "## Build evidence" section in +// one exact-revision metadata transaction keyed on the record blob it reads: a +// record that moves between this read and the transaction is contended and left +// untouched, never overwritten. An unchanged section is a clean no-op (no +// commit), so a replay of the same record converges without a second write. +func recordBuildEvidence(ctx context.Context, deps PlanningDeps, repoDir, opKey string, id int, rec evidence.Record) recordEvidenceOutcome { + pin, err := deps.Reader.PinContext(ctx, repoDir) + if err != nil { + result, reason := classifyStatusError(ctx, err) + return recordEvidenceOutcome{Result: result, Reason: reason, Message: err.Error()} + } + eff := pin.Config.Effective + inline, err := resolveBoardSurface(eff) + if err != nil { + if pe, ok := asPlanningError(err); ok { + return recordEvidenceOutcome{Result: pe.Result, Reason: pe.Reason, Message: pe.Message} + } + return recordEvidenceOutcome{Result: ResultInternalError, Reason: ReasonStatusInternalError, Message: err.Error()} + } + _, recPath, revision, refusal := resolveImplementedChange(ctx, deps, pin, eff, id) + if refusal != nil { + out := recordEvidenceOutcome{Result: refusal.Result} + if len(refusal.Findings) > 0 { + out.Reason, out.Message = refusal.Findings[0].Code, refusal.Findings[0].Message + } + return out + } + repo, err := deps.Client.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: repoDir}) + if err != nil { + result, reason := classifyStatusError(ctx, classifyGitFailure(err)) + return recordEvidenceOutcome{Result: result, Reason: reason, Message: err.Error()} + } + res, execErr := deps.Engine.Execute(ctx, transaction.Request{ + Repository: repo, + Remote: originRemote, + TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Expected: []transaction.EntityExpectation{{ + Path: gitcli.RepoPath(recPath), + Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(revision)}, + }}, + Loader: newPlanningLoader(eff), + Scope: changeScope(id, recPath, false), + Operation: recordEvidenceOp{opKey: opKey, changeID: id, rec: rec, eff: eff, inline: inline, changesDir: eff.ChangesDir.Value}, + }) + result, _ := mapOutcome(res, execErr, ResultInvalidState) + out := recordEvidenceOutcome{Result: result} + switch result { + case ResultApplied: + out.Revision = string(res.AppliedCommit) + case ResultNoOp: + case ResultContended: + out.Message = fmt.Sprintf("change %04d's record moved under the build-evidence write; re-read context and retry", id) + default: + if len(res.Findings) > 0 { + out.Reason = res.Findings[0].Code + out.Message = res.Findings[0].Detail["message"] + } + if out.Message == "" && execErr != nil { + out.Message = execErr.Error() + } + if out.Message == "" { + out.Message = fmt.Sprintf("the build-evidence write for change %04d did not apply (%s)", id, res.Disposition) + } + } + return out +} + +// recordEvidenceOp is the SemanticOperation recordBuildEvidence drives. It edits +// only the change record's "## Build evidence" section. No board-visible field +// changes, so the inline board re-render (every change-record mutator renders +// it) declares no board mutation unless the committed board was already stale. +// An unchanged section is the engine's clean no-op path. +type recordEvidenceOp struct { + opKey string + changeID int + rec evidence.Record + eff config.Effective + inline bool + changesDir string +} + +func (o recordEvidenceOp) Key() transaction.OperationKey { return transaction.OperationKey(o.opKey) } + +func (o recordEvidenceOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { + c, out := st.State.Snapshot.Change(domain.ChangeID(o.changeID)) + if out != domain.LookupFound { + return refuseLifecycle(FindingCode(ReasonImplementedUnknownChange), fmt.Sprintf("change %04d is not a single record in the current corpus", o.changeID)) + } + src, ok := st.State.Sources[c.Path()] + if !ok { + return refuseLifecycle(FCPathMismatch, fmt.Sprintf("no record source loaded at %q for change %04d", c.Path(), o.changeID)) + } + updated, err := UpsertRecordEvidence(src, o.rec) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + // Receipt fields are alphabetical: the engine's receipt validator requires + // the sorted compact form. + receipt, err := json.Marshal(struct { + Head string `json:"head"` + ID int `json:"id"` + Op string `json:"op"` + }{Head: o.rec.Head, ID: o.changeID, Op: o.opKey}) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + var files []transaction.FileMutation + if !bytes.Equal(updated, src) { + files = append(files, transaction.FileMutation{Path: gitcli.RepoPath(c.Path()), Kind: transaction.MutationReplace, Bytes: updated}) + } + if o.inline { + candidate, err := buildGroomCandidate(o.eff, st.State.Documents, c.Path(), updated) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + boardPath := path.Join(o.changesDir, "BOARD.md") + if err := includeBoard(ctx, st.Tree, boardPath, candidate, boardUnrenderable(st.State, o.changesDir), boardPresentation(o.eff), &files); err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("record evidence: %w", err) + } + } + return transaction.MutationPlan{ + Files: files, + CommitSubject: fmt.Sprintf("change %04d build evidence recorded", o.changeID), + Receipt: receipt, + }, transaction.OperationResult{}, nil +} diff --git a/internal/app/evidence_section_test.go b/internal/app/evidence_section_test.go index b9786b244..374e9bab3 100644 --- a/internal/app/evidence_section_test.go +++ b/internal/app/evidence_section_test.go @@ -1,12 +1,15 @@ package app import ( + "context" "errors" "strings" "testing" "time" + "github.com/danielhanold/docket/internal/config" "github.com/danielhanold/docket/internal/evidence" + "github.com/danielhanold/docket/internal/repository/transaction" ) const esHead = "0123456789abcdef0123456789abcdef01234567" @@ -110,3 +113,64 @@ func TestRecordEvidenceFacts(t *testing.T) { t.Fatalf("absent facts = %q %q %v", h, cmd, green) } } + +// TestRecordBuildEvidenceIsIdempotent drives recordEvidenceOp's Plan closure over +// a fake tree: the first write replaces the change record with the section +// appended (and renders the absent inline board), and replaying the same record +// over the written bytes declares no files — the engine's clean no-op path, +// never a second commit. +func TestRecordBuildEvidenceIsIdempotent(t *testing.T) { + recPath := groomPath(3, "widget") + files := map[string]string{recPath: lifecycleChange(3, "widget", "in-progress")} + eff := planningTestConfig([]string{"inline"}) + op := recordEvidenceOp{opKey: OperationFinalizePublish, changeID: 3, rec: esGreen(t), eff: eff, inline: true, changesDir: "docs/changes"} + + first, opRes := recordEvidencePlanFor(t, eff, files, op) + if opRes.Refused { + t.Fatalf("first write refused: %v", opRes.Findings) + } + if got := planPaths(first); len(got) != 2 { + t.Fatalf("first write files = %v, want the change record and the absent board", got) + } + got, err := ReadRecordEvidence([]byte(lifecycleRecordBytes(t, first, recPath))) + if err != nil || got != op.rec { + t.Fatalf("written section = %+v, %v; want %+v", got, err, op.rec) + } + if len(first.Receipt) == 0 || first.CommitSubject == "" { + t.Fatalf("first write carries no receipt/subject: %+v", first) + } + + files[recPath] = lifecycleRecordBytes(t, first, recPath) + files["docs/changes/BOARD.md"] = lifecycleRecordBytes(t, first, "docs/changes/BOARD.md") + second, opRes := recordEvidencePlanFor(t, eff, files, op) + if opRes.Refused { + t.Fatalf("replay refused: %v", opRes.Findings) + } + if len(second.Files) != 0 { + t.Fatalf("replay declared %v, want no files (a clean no-op)", planPaths(second)) + } + + // An unknown change is a state refusal, never a write. + missing := op + missing.changeID = 9 + if _, opRes := recordEvidencePlanFor(t, eff, files, missing); !opRes.Refused { + t.Fatalf("an unknown change was not refused") + } +} + +func recordEvidencePlanFor(t *testing.T, eff config.Effective, files map[string]string, op recordEvidenceOp) (transaction.MutationPlan, transaction.OperationResult) { + t.Helper() + tree := newFakeTree(files) + before, err := newPlanningLoader(eff).Load(context.Background(), tree) + if err != nil { + t.Fatalf("loader.Load: %v", err) + } + if before.Report.HasErrors() { + t.Fatalf("before-state has errors: %v", before.Report.Findings()) + } + plan, opRes, err := op.Plan(context.Background(), transaction.AttemptState{Base: tree.Revision(), State: before, Tree: tree}) + if err != nil { + t.Fatalf("Plan: %v", err) + } + return plan, opRes +} diff --git a/internal/app/finalize_publish.go b/internal/app/finalize_publish.go index 0fd97455d..763f1c18b 100644 --- a/internal/app/finalize_publish.go +++ b/internal/app/finalize_publish.go @@ -17,7 +17,8 @@ import ( // This file is the `finalize publish` operation: the narrow, receipt-scoped // publication of a rebased (rewritten) feature head onto its remote feature ref, // followed by the loss-preserving update of the Docket build-evidence block on -// the existing pull request. It owns the ORDER and the closed-outcome mapping; +// the existing pull request and the record of that evidence in the change +// record's "## Build evidence" section. It owns the ORDER and the closed-outcome mapping; // every mechanic it composes is landed: the receipt-scoped force-with-lease push // lives in internal/workspace (PublishRewrite, Task 5), the evidence-block // replacement lives in internal/evidence (Upsert, the same writer `pr publish` @@ -41,15 +42,21 @@ import ( // requested head; // 5. the current PR body's build-evidence block is loss-preservingly replaced // with the exact current-head green record (every authored byte, the title, -// and every other block preserved); and +// and every other block preserved); // 6. EnsurePullRequest converges the PR onto that body under the exact expected -// head and revision, never creating a second PR. +// head and revision, never creating a second PR (a skipped record is never +// woven into a PR body, so steps 5 and 6 are skipped for it); and +// 7. only a converged PR proceeds to write the record into the change record's +// "## Build evidence" section (recordBuildEvidence) in one exact-revision +// metadata transaction keyed on the record blob it reads — a record that +// moved under the write is contended and left untouched. // // Crash replay is ordinary. A crash after the push but before the PR update is a // no-op rewrite (the remote already holds the head) that resumes the PR update. A -// crash after both is a no-op rewrite plus an already-equal PR body — a full -// no-op. An unknown probe never authorizes a second mutation or a merge-enabling -// success. +// crash after the PR update but before the record write resumes only that write. +// A crash after all three is a no-op rewrite, an already-equal PR body, and an +// unchanged record section — a full no-op. An unknown probe never authorizes a +// second mutation or a merge-enabling success. // OperationFinalizePublish is the operation key `finalize publish` records in its // result envelope. @@ -57,11 +64,14 @@ const OperationFinalizePublish = "finalize.publish" // The closed set of `finalize publish` dispositions. const ( - // PublishDispPublished: the rewrite reached the remote (or was already there) - // AND the PR build-evidence block was converged to the current head. + // PublishDispPublished: the rewrite reached the remote (or was already there), + // the PR build-evidence block was converged to the current head, and the + // change record's build-evidence section records it — at least one of them + // doing work this run. PublishDispPublished = "published" - // PublishDispNoop: the remote already held the head and the PR already carried - // the exact current-head evidence — a full idempotent replay. + // PublishDispNoop: the remote already held the head, and the PR and the change + // record already carried the exact current-head evidence — a full idempotent + // replay. PublishDispNoop = "noop" // PublishDispContended: a lost race the caller resolves by re-reading context — // a diverged remote, or a PR at an unexpected head/revision. Nothing was forced. @@ -123,6 +133,13 @@ const ( // ReasonPublishEnsurerUnavailable: the wired GitHub seam does not provide the // PR create-or-edit face; an internal wiring error. ReasonPublishEnsurerUnavailable = "pr-editor-unavailable" + // ReasonPublishRecordContended: the change record moved between the publish's + // read and its build-evidence write; the record is untouched — re-read and + // retry. + ReasonPublishRecordContended = "record-evidence-contended" + // ReasonPublishRecordFailed: the change record's build-evidence write did not + // apply for any other reason; retained, never a merge-enabling success. + ReasonPublishRecordFailed = "record-evidence-failed" ) // FinalizePublishRequest is the closed request for `finalize publish`. ID names @@ -208,8 +225,9 @@ type finalizePublishEnsurer interface { } // FinalizePublish publishes a rewritten feature head onto its remote ref under -// the owned receipt's exact lease and converges the PR build-evidence block onto -// the exact current head, idempotently and in that order. It never forces past +// the owned receipt's exact lease, converges the PR build-evidence block onto +// the exact current head, and records that evidence in the change record, +// idempotently and in that order. It never forces past // the recorded lease, never creates a second PR, and treats every unknown probe // as retain — no second mutation and no merge-enabling success. func FinalizePublish(ctx context.Context, deps FinalizeDeps, repoDir string, req FinalizePublishRequest) FinalizePublishResult { @@ -358,6 +376,17 @@ func FinalizePublish(ctx context.Context, deps FinalizeDeps, repoDir string, req }) } + // A skipped (build.gate: off) record is never woven into a PR body — the PR + // evidence block is green-only — so the change record's section is its only + // home: the PR is left untouched and the record write alone decides. + if rec.Result == evidence.ResultSkipped { + prRes := newPublishResult(ResultNoOp, publishSuccess(id, req.Head, rout, repo, pr, PublishDispNoop)) + if rout == workspace.RewritePublished { + prRes = newPublishResult(ResultApplied, publishSuccess(id, req.Head, rout, repo, pr, PublishDispPublished)) + } + return publishRecordEvidence(ctx, deps, repoDir, id, rec, prRes) + } + // Replace ONLY the build-evidence block in the current PR body — every authored // byte, the title, and every other block are preserved. newBody, err := evidence.Upsert([]byte(pr.Body), rec) @@ -382,7 +411,41 @@ func FinalizePublish(ctx context.Context, deps FinalizeDeps, repoDir string, req if eerr != nil { return mapPublishEnsureFailure(id, req.Head, string(rout), eerr) } - return publishResultFromEnsure(id, req.Head, rout, repo, eres) + return publishRecordEvidence(ctx, deps, repoDir, id, rec, publishResultFromEnsure(id, req.Head, rout, repo, eres)) +} + +// publishRecordEvidence runs the last step of the order: only a converged PR (a +// published or noop result) proceeds to record rec in the change record's +// "## Build evidence" section through one exact-revision metadata transaction. +// A record write that applied after a noop PR edit makes the publication +// `published`; a no-op write keeps the PR result; a record that moved under the +// write is contended (untouched, re-read and retry); any other outcome is +// unknown — never a merge-enabling success. +func publishRecordEvidence(ctx context.Context, deps FinalizeDeps, repoDir string, id int, rec evidence.Record, prRes FinalizePublishResult) FinalizePublishResult { + if prRes.Result != ResultApplied && prRes.Result != ResultNoOp { + return prRes + } + out := recordBuildEvidence(ctx, deps.Planning, repoDir, OperationFinalizePublish, id, rec) + switch out.Result { + case ResultApplied: + if prRes.Result == ResultNoOp { + prRes.Envelope = NewEnvelope(OperationFinalizePublish, ResultApplied) + prRes.Disposition = PublishDispPublished + } + return prRes + case ResultNoOp: + return prRes + case ResultContended: + return newPublishResult(ResultContended, FinalizePublishResult{ + ID: id, Disposition: PublishDispContended, Head: prRes.Head, Number: prRes.Number, Rewrite: prRes.Rewrite, + Reason: ReasonPublishRecordContended, Message: out.Message, + }) + default: + return newPublishResult(out.Result, FinalizePublishResult{ + ID: id, Disposition: PublishDispUnknown, Head: prRes.Head, Number: prRes.Number, Rewrite: prRes.Rewrite, + Reason: ReasonPublishRecordFailed, Message: out.Message, + }) + } } // translateWorkspaceRefusalToPublish maps a workspace-shaped pre-delegation diff --git a/internal/app/finalize_publish_helpers_test.go b/internal/app/finalize_publish_helpers_test.go index a72eb60c8..a60781be4 100644 --- a/internal/app/finalize_publish_helpers_test.go +++ b/internal/app/finalize_publish_helpers_test.go @@ -185,4 +185,26 @@ func (f *publishFixture) openPRForPublish(head, body string) githubcli.PullReque } } +// remoteRecordBytes reads the fixture change's record at the metadata remote tip. +func (f *rebaseFixture) remoteRecordBytes(t *testing.T) string { + t.Helper() + body, ok := originFile(t, f.repo.origin, f.branch, groomPath(f.id, f.slug)) + if !ok { + t.Fatalf("change record %s absent on origin %s", groomPath(f.id, f.slug), f.branch) + } + return body +} + +// remoteRecordEvidence reads the "## Build evidence" section of the fixture +// change's record at the metadata remote tip — the durable evidence home. +func (f *rebaseFixture) remoteRecordEvidence(t *testing.T) evidence.Record { + t.Helper() + body := f.remoteRecordBytes(t) + rec, err := ReadRecordEvidence([]byte(body)) + if err != nil { + t.Fatalf("the change record at the metadata remote tip carries no readable build evidence: %v\n%s", err, body) + } + return rec +} + // --- TestFinalizePublishOrder --------------------------------------------- diff --git a/internal/app/finalize_publish_integration_test.go b/internal/app/finalize_publish_integration_test.go index f09cde1f6..161080077 100644 --- a/internal/app/finalize_publish_integration_test.go +++ b/internal/app/finalize_publish_integration_test.go @@ -55,6 +55,13 @@ func TestIntegrationFinalizeOpsFinalizePublishAcceptsSkippedEvidence(t *testing. if res.Reason == ReasonPublishEvidenceUnverified { t.Fatalf("skipped evidence at the exact head was refused at the evidence condition: %q", res.Message) } + if res.Result != ResultApplied || res.Disposition != PublishDispPublished { + t.Fatalf("skipped publish = %q disp %q (reason %q msg %q), want applied/published", res.Result, res.Disposition, res.Reason, res.Message) + } + // The skipped record is durable in the change record's evidence section. + if got := f.remoteRecordEvidence(t); got != skipped { + t.Fatalf("recorded evidence = %+v, want the published skipped record %+v", got, skipped) + } } // TestIntegrationFinalizeOpsFinalizePublishAfterCheckpointResume proves the reuse path end to end: diff --git a/internal/app/finalize_rebase_test.go b/internal/app/finalize_rebase_test.go index 1d5ba1937..3c831b4d3 100644 --- a/internal/app/finalize_rebase_test.go +++ b/internal/app/finalize_rebase_test.go @@ -181,7 +181,7 @@ func setupRebaseFixtureWithNode(t *testing.T, m planRepoMode, status string, nod requireRealGit(t) id, slug := rebaseFixtureID, rebaseFixtureSlug recPath := groomPath(id, slug) - repo := buildConfiguredRepo(t, m, recPath, lifecycleChange(id, slug, status)) + repo := buildConfiguredRepo(t, m, recPath, rebaseFixtureRecord(id, slug, status)) node := nodeFor(t, repo.invocation) svc, err := workspace.NewService(node.deps.Client) @@ -233,6 +233,18 @@ func setupRebaseFixtureWithNode(t *testing.T, m planRepoMode, status string, nod } } +// rebaseFixtureRecord is the fixture's change record. An implemented record +// carries the plan and PR an implemented transition always records, so a +// metadata transaction that writes it (the build-evidence record write) sees a +// coherent record rather than refusing on a pre-existing incoherence. +func rebaseFixtureRecord(id int, slug, status string) string { + rec := lifecycleChange(id, slug, status) + if status == "implemented" { + rec = strings.Replace(rec, "\nplan:\n", "\nplan: 'docs/superpowers/plans/2026-08-01-"+slug+".md'\npr: 'https://github.com/acme/widgets/pull/1'\n", 1) + } + return rec +} + // finalizeDeps assembles the FinalizeDeps a rebase test drives: the real planning // seams and workspace service, the fake GitHub, and the fake gate. func (f *rebaseFixture) finalizeDeps(gh FinalizeGitHub, gate FinalizeGate) FinalizeDeps { diff --git a/internal/app/finalize_state_integration_test.go b/internal/app/finalize_state_integration_test.go index 601efb760..7019478eb 100644 --- a/internal/app/finalize_state_integration_test.go +++ b/internal/app/finalize_state_integration_test.go @@ -7,6 +7,7 @@ import ( "errors" "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/repository/transaction" "github.com/danielhanold/docket/internal/workspace" "strings" "testing" @@ -368,10 +369,11 @@ func TestIntegrationFinalizeStateNoForeignWrites(t *testing.T) { } } -// TestFinalizePublishCrashReplay proves the two replay faces. A crash after the -// push but before the PR update is a no-op rewrite (the remote already holds the -// rewritten head) that still resumes the PR update. A crash after both is a full -// no-op. +// TestFinalizePublishCrashReplay proves the replay faces. A crash after the push +// but before the PR update is a no-op rewrite (the remote already holds the +// rewritten head) that still resumes the PR update. A crash after the PR update +// resumes only the record write; a crash after all three is a full no-op with +// no new metadata commit. func TestIntegrationFinalizeStatePublishCrashReplay(t *testing.T) { requireRealGit(t) main := planRepoModes()[0] @@ -401,7 +403,7 @@ func TestIntegrationFinalizeStatePublishCrashReplay(t *testing.T) { } }) - t.Run("after-both-is-full-noop", func(t *testing.T) { + t.Run("after-all-is-full-noop", func(t *testing.T) { f := setupPublishFixture(t, main) runGit(t, f.wp, "push", "--force", "-q", "origin", "HEAD:refs/heads/feat/"+f.slug) evRec, evBytes := recFor(t, f.rewritten) @@ -411,6 +413,17 @@ func TestIntegrationFinalizeStatePublishCrashReplay(t *testing.T) { t.Fatalf("evidence.Upsert: %v", err) } gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.openPRForPublish(f.rewritten, string(converged))} + // A crash after the record write: a first publish over the already-pushed + // head and already-converged PR does only the record write (published). + prime := FinalizePublish(context.Background(), f.publishDeps(gh), f.repo.invocation, + FinalizePublishRequest{ID: f.id, Attempt: f.attempt, Head: f.rewritten, EvidenceRecord: evBytes}) + if prime.Result != ResultApplied || prime.Disposition != PublishDispPublished { + t.Fatalf("record-only publish = %q disp %q (reason %q), want applied/published", prime.Result, prime.Disposition, prime.Reason) + } + if got := f.remoteRecordEvidence(t); got != evRec { + t.Fatalf("recorded evidence = %+v, want %+v", got, evRec) + } + metaTip := originTip(t, f.repo.origin, f.branch) res := FinalizePublish(context.Background(), f.publishDeps(gh), f.repo.invocation, FinalizePublishRequest{ID: f.id, Attempt: f.attempt, Head: f.rewritten, EvidenceRecord: evBytes}) @@ -418,6 +431,10 @@ func TestIntegrationFinalizeStatePublishCrashReplay(t *testing.T) { if res.Result != ResultNoOp || res.Disposition != PublishDispNoop { t.Fatalf("full replay = %q disp %q (reason %q), want no-op/noop", res.Result, res.Disposition, res.Reason) } + // The record write was a clean no-op: no new metadata commit. + if tip := originTip(t, f.repo.origin, f.branch); tip != metaTip { + t.Errorf("a full replay committed to the metadata branch: tip %s, want %s", tip, metaTip) + } if res.Rewrite != "noop" { t.Errorf("rewrite outcome = %q, want noop", res.Rewrite) } @@ -495,10 +512,65 @@ func TestIntegrationFinalizeStatePublishOrder(t *testing.T) { if res.Number != 7 || !strings.HasSuffix(res.Reference, "#7") { t.Errorf("result PR identity = number %d ref %q, want #7", res.Number, res.Reference) } + // The published record is durable in the change record's evidence + // section at the metadata remote tip. + if got := f.remoteRecordEvidence(t); got != evRec { + t.Errorf("recorded evidence = %+v, want the published record %+v", got, evRec) + } }) } } +// recordWriteInterposer delegates to the real engine, running before exactly +// once ahead of the first record-evidence transaction — the window between the +// publish's own corpus read and its exact-revision transaction. +type recordWriteInterposer struct { + inner interface { + Execute(ctx context.Context, req transaction.Request) (transaction.Result, error) + } + before func() + fired bool +} + +func (e *recordWriteInterposer) Execute(ctx context.Context, req transaction.Request) (transaction.Result, error) { + if _, ok := req.Operation.(recordEvidenceOp); ok && !e.fired { + e.fired = true + e.before() + } + return e.inner.Execute(ctx, req) +} + +// TestIntegrationFinalizeStatePublishRecordEvidenceContended proves the record +// write is a CAS on the record blob the publish read: a concurrent writer that +// moves the change record between that read and the transaction makes the +// publish contended, and the concurrent writer's record is left untouched. +func TestIntegrationFinalizeStatePublishRecordEvidenceContended(t *testing.T) { + requireRealGit(t) + f := setupPublishFixture(t, planRepoModes()[0]) + _, evBytes := recFor(t, f.rewritten) + gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.openPRForPublish(f.rewritten, authoredPRBody(t, f.origHead))} + + var advanced string + deps := f.publishDeps(gh) + deps.Planning.Engine = &recordWriteInterposer{inner: f.deps.Engine, before: func() { + advanced = f.remoteRecordBytes(t) + "\nA concurrent writer's prose.\n" + f.repo.writerAdvance(t, f.branch, map[string]string{groomPath(f.id, f.slug): advanced}) + }} + + res := FinalizePublish(context.Background(), deps, f.repo.invocation, + FinalizePublishRequest{ID: f.id, Attempt: f.attempt, Head: f.rewritten, EvidenceRecord: evBytes}) + + if res.Result != ResultContended || res.Disposition != PublishDispContended || res.Reason != ReasonPublishRecordContended { + t.Fatalf("publish = %q disp %q reason %q (msg %q), want contended/contended/%s", res.Result, res.Disposition, res.Reason, res.Message, ReasonPublishRecordContended) + } + if advanced == "" { + t.Fatalf("the record write was never attempted") + } + if got := f.remoteRecordBytes(t); got != advanced { + t.Fatalf("the contended publish touched the concurrent writer's record:\n%s", got) + } +} + // TestFinalizePublishRefusesForeignAttempt proves an attempt token that does not // match the owned receipt is refused before any push: the remote is untouched and // no PR edit is issued. diff --git a/internal/cli/evidence.go b/internal/cli/evidence.go index 380e29d53..c51d60126 100644 --- a/internal/cli/evidence.go +++ b/internal/cli/evidence.go @@ -93,8 +93,10 @@ func newEvidenceCommand(setResult func(app.OperationResult)) *cobra.Command { Short: "Rerun the build gate for an implemented change and refresh its open PR's evidence block in place", Args: cobra.NoArgs, // external-write: the operation edits the existing pull request's - // build-evidence block (and only that block) on GitHub. - Annotations: capability("evidence.recertify", EffectExternalWrite), + // build-evidence block (and only that block) on GitHub; metadata-write: + // one exact-revision metadata transaction records the evidence in the + // change record's "## Build evidence" section. + Annotations: capability("evidence.recertify", EffectExternalWrite, EffectMetadataWrite), RunE: func(c *cobra.Command, _ []string) error { repoDir, err := resolveRepoDir(c) if err != nil { diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index 5ab8931cb..87231a556 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -506,8 +506,9 @@ func newFinalizeResolverReserveSubcommand(setResult func(app.OperationResult)) * } // newFinalizePublishSubcommand builds `finalize publish`: it publishes a rewritten -// feature head onto its remote ref under the owned rebase receipt's exact lease and -// converges the PR build-evidence block onto that head. The scalar identity (id, +// feature head onto its remote ref under the owned rebase receipt's exact lease, +// converges the PR build-evidence block onto that head, and records the evidence +// in the change record. The scalar identity (id, // attempt token, expected head) rides on flags; the canonical evidence bytes ride // in --evidence (a request file or stdin), never a shell-escaped flag. func newFinalizePublishSubcommand(setResult func(app.OperationResult)) *cobra.Command { @@ -515,10 +516,11 @@ func newFinalizePublishSubcommand(setResult func(app.OperationResult)) *cobra.Co Use: "publish", Short: "Publish a rebased feature head under its receipt lease and update the PR build-evidence block", Args: cobra.NoArgs, - // external-write only: force-with-lease pushes the rewritten feature head - // (remote ref) and converges the PR evidence block on GitHub; it opens no - // metadata transaction. - Annotations: capability("finalize.publish", EffectExternalWrite), + // external-write: force-with-lease pushes the rewritten feature head + // (remote ref) and converges the PR evidence block on GitHub; + // metadata-write: one exact-revision metadata transaction records the + // evidence in the change record's "## Build evidence" section. + Annotations: capability("finalize.publish", EffectExternalWrite, EffectMetadataWrite), RunE: func(c *cobra.Command, _ []string) error { repoDir, err := resolveRepoDir(c) if err != nil { From 45d42dc48b150b38d253b1b280835b8a32b8cd40 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 00:40:06 -0400 Subject: [PATCH 05/28] feat(finalize): merge gate, clear-block, rebase skip, and run verify read the record's build evidence --- .../app_concurrency_race_integration_test.go | 6 +- internal/app/change_integration_test.go | 68 +++++++-------- internal/app/finalize_block.go | 19 +++-- .../app/finalize_block_integration_test.go | 3 +- internal/app/finalize_merge.go | 18 ++-- .../app/finalize_merge_integration_test.go | 64 ++++++++++---- internal/app/finalize_merge_test.go | 24 +++++- internal/app/finalize_publish_helpers_test.go | 75 +++++++++++++++++ internal/app/finalize_rebase.go | 53 +++++------- .../app/finalize_rebase_integration_test.go | 83 +++++++++++++------ internal/app/finalize_rebase_test.go | 40 ++++++--- .../app/finalize_state_integration_test.go | 29 +++++-- .../app/named_isolation_integration_test.go | 7 +- internal/app/run_verify.go | 29 +++++-- internal/app/run_verify_integration_test.go | 71 +++++++++++++--- internal/app/run_verify_test.go | 21 ++++- .../app/runtracker_fence_integration_test.go | 8 +- .../runtracker_ownership_integration_test.go | 11 +-- .../app/runtracker_verdict_helpers_test.go | 4 +- .../runtracker_verdict_integration_test.go | 56 ++++++------- internal/app/workspace_ops.go | 11 ++- 21 files changed, 486 insertions(+), 214 deletions(-) diff --git a/internal/app/app_concurrency_race_integration_test.go b/internal/app/app_concurrency_race_integration_test.go index 51cf7ffd1..4d660c5c0 100644 --- a/internal/app/app_concurrency_race_integration_test.go +++ b/internal/app/app_concurrency_race_integration_test.go @@ -364,7 +364,7 @@ func TestRaceIntegrationAppConcurrencyPlanningSameEntityRevisionOneAppliesOneCon // consuming afterward — double-grants and reddens here. func TestRaceIntegrationAppConcurrencyRunVerdictConcurrentRetryGrantsOnce(t *testing.T) { f := newRunVerifyFixture(t, true) - ev := string(prEvidenceBytes(t, f.head)) + ev := prEvidenceBytes(t, f.head) // Resume-verified shape (AttributedID set, no claim binding): ownership resolves // immediately for all concurrent calls, so the only resource they contend on is // the on-disk retry CAS — exactly what this test guards (change 0407). Limit 3 @@ -387,8 +387,8 @@ func TestRaceIntegrationAppConcurrencyRunVerdictConcurrentRetryGrantsOnce(t *tes for i := 0; i < n; i++ { wg.Add(1) deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), - rvPR(f.head, ev), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), ev), + rvPR(f.head, prBodyNoEvidence), ) go func(idx int, deps PlanningDeps, wdeps WorkspaceDeps, gdeps GitHubDeps) { defer wg.Done() diff --git a/internal/app/change_integration_test.go b/internal/app/change_integration_test.go index c0c6c43b0..e9853d17b 100644 --- a/internal/app/change_integration_test.go +++ b/internal/app/change_integration_test.go @@ -1376,7 +1376,7 @@ func TestIntegrationChangeAuthoringOuterBudgetEndToEnd(t *testing.T) { // (3) Drive successive quiescent run-incomplete verdicts (no tracked // drive, so each falls through to the retry CAS). - deps, wdeps, gdeps := f.deps(runTrackerIncompleteRecord(), rvPR(f.head, string(prEvidenceBytes(t, f.head)))) + deps, wdeps, gdeps := f.deps(rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), rvPR(f.head, prBodyNoEvidence)) wdeps.Continuation = &fakeContinuationSeam{candidates: nil} for i := 1; i <= tc.wantRetries; i++ { @@ -3776,8 +3776,8 @@ func TestIntegrationChangeRuntimeRunVerdictObserveHintsMixedVerdicts(t *testing. func TestIntegrationChangeRuntimeRunVerdictObserveIncompleteWritesNothing(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerdictObserve(context.Background(), deps, wdeps, gdeps, f.repo.invocation, []string{"3"}) @@ -3850,8 +3850,8 @@ func TestIntegrationChangeRuntimeRunVerdictObserveSyncFailureUnavailable(t *test func TestIntegrationChangeRuntimeRunVerdictRestartDurability(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) // Simulate the starting process: mint and forget (nothing carried in memory). The // resume-verified shape (AttributedID set, no claim binding) is the durable state @@ -3876,8 +3876,8 @@ func TestIntegrationChangeRuntimeRunVerdictRestartDurability(t *testing.T) { func TestIntegrationChangeRuntimeRunVerdictRunComplete(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributed(t, f.repo.invocation, 3) @@ -3926,8 +3926,8 @@ func TestIntegrationChangeRuntimeRunVerdictRunHalted(t *testing.T) { func TestIntegrationChangeRuntimeRunVerdictRunIncompleteRetryThenStop(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributed(t, f.repo.invocation, 3) @@ -4007,8 +4007,8 @@ func TestIntegrationChangeRuntimeRunVerdictRunWaiting(t *testing.T) { func TestIntegrationChangeRuntimeRunVerdictTwoKeysIsolated(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) keyA := runTrackerMintAttributed(t, f.repo.invocation, 3) keyB := runTrackerMintAttributed(t, f.repo.invocation, 3) @@ -4046,8 +4046,8 @@ func TestIntegrationChangeRuntimeRunVerdictUnknownVerdictFailsClosed(t *testing. func TestIntegrationChangeRuntimeRunVerifyComplete(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) if res.Verdict != VerdictRunComplete { @@ -4070,8 +4070,8 @@ func TestIntegrationChangeRuntimeRunVerifyComplete(t *testing.T) { func TestIntegrationChangeRuntimeRunVerifyCompletePrecedesStaleHandoff(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Waiting = fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true} @@ -4088,7 +4088,7 @@ func TestIntegrationChangeRuntimeRunVerifyCompletePrecedesStaleHandoff(t *testin // satisfies all of them. func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *testing.T) { pub := newRunVerifyFixture(t, true) - ev := string(prEvidenceBytes(t, pub.head)) + ev := prEvidenceBytes(t, pub.head) recordedPR := rvRecordedPR() ghostPlan := "docs/superpowers/plans/2026-08-17-ghost.md" ghostResults := "docs/changes/results/0003-ghost.md" @@ -4101,32 +4101,32 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test }{ { name: "missing plan link", - record: rvRecord("", rvResultsPath, recordedPR, "feat/"+rvSlug), - pr: rvPR(pub.head, ev), + record: rvWithEvidence(t, rvRecord("", rvResultsPath, recordedPR, "feat/"+rvSlug), ev), + pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunPlanUnlinked, }, { name: "plan file gone at recorded path", - record: rvRecord(ghostPlan, rvResultsPath, recordedPR, "feat/"+rvSlug), - pr: rvPR(pub.head, ev), + record: rvWithEvidence(t, rvRecord(ghostPlan, rvResultsPath, recordedPR, "feat/"+rvSlug), ev), + pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunPlanMissing, }, { name: "stale evidence names another head", - record: rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), - pr: rvPR(pub.head, string(prEvidenceBytes(t, prOtherHead))), + record: rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), prEvidenceBytes(t, prOtherHead)), + pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunEvidenceUnverified, }, { name: "PR names another head", - record: rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), - pr: rvPR(prOtherHead, ev), + record: rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), ev), + pr: rvPR(prOtherHead, prBodyNoEvidence), want: ReasonRunPRUnverified, }, { name: "results identity broken", - record: rvRecord(rvPlanPath, ghostResults, recordedPR, "feat/"+rvSlug), - pr: rvPR(pub.head, ev), + record: rvWithEvidence(t, rvRecord(rvPlanPath, ghostResults, recordedPR, "feat/"+rvSlug), ev), + pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunResultsIdentity, }, { @@ -4137,8 +4137,8 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test // the branch reconstructed from the slug, the remote probe would find the // published head and this condition would wrongly pass. name: "recorded branch honored — its remote head is absent", - record: rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/other"), - pr: rvPR(pub.head, ev), + record: rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/other"), ev), + pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunRemoteHeadMismatch, }, } @@ -4164,8 +4164,8 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test t.Run("feature head differs from remote", func(t *testing.T) { unpub := newRunVerifyFixture(t, false) deps, wdeps, gdeps := unpub.deps( - rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), - rvPR(unpub.head, string(prEvidenceBytes(t, unpub.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), prEvidenceBytes(t, unpub.head)), + rvPR(unpub.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, unpub.repo.invocation, RunVerifyRequest{ID: 3}) if res.Verdict != VerdictRunIncomplete { @@ -4181,7 +4181,7 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test // verdict — it carries no verdict and exits non-zero. func TestIntegrationChangeRuntimeRunVerifyOperationalError(t *testing.T) { f := newRunVerifyFixture(t, true) - deps, wdeps, gdeps := f.deps(rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), rvPR(f.head, string(prEvidenceBytes(t, f.head)))) + deps, wdeps, gdeps := f.deps(rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), rvPR(f.head, prBodyNoEvidence)) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 999}) if res.Verdict != "" { t.Errorf("operational error carried a verdict %q", res.Verdict) @@ -4198,7 +4198,7 @@ func TestIntegrationChangeRuntimeRunVerifyOperationalError(t *testing.T) { // is unparseable. The verified PR is number 42 (rvPR). func TestIntegrationChangeRuntimeRunVerifyPRIdentityForms(t *testing.T) { f := newRunVerifyFixture(t, true) - ev := string(prEvidenceBytes(t, f.head)) + ev := prEvidenceBytes(t, f.head) cases := []struct { name string @@ -4214,8 +4214,8 @@ func TestIntegrationChangeRuntimeRunVerifyPRIdentityForms(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, tc.recorded, "feat/"+rvSlug), - rvPR(f.head, ev), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, tc.recorded, "feat/"+rvSlug), ev), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) reasons := unmetReasons(res) diff --git a/internal/app/finalize_block.go b/internal/app/finalize_block.go index 7f083d301..cda0fc3f7 100644 --- a/internal/app/finalize_block.go +++ b/internal/app/finalize_block.go @@ -130,8 +130,8 @@ const ( ReasonClearRemoteHeadMismatch = "remote-head-mismatch" // ReasonClearPRNotOpen: not exactly one open PR for the feature head. ReasonClearPRNotOpen = "pr-not-open" - // ReasonClearEvidenceUnverified: the PR body evidence does not verify green - // for the exact current head (and the gate is on). + // ReasonClearEvidenceUnverified: the change record's build evidence does not + // verify green for the exact current head (and the gate is on). ReasonClearEvidenceUnverified = "evidence-unverified" ) @@ -372,6 +372,15 @@ func FinalizeClearBlock(ctx context.Context, deps FinalizeDeps, repoDir string, return *refusal } recPath := c.Path() + // The record's bytes from this one corpus read carry the build evidence the + // gate reprobe reads below. + var record []byte + for _, b := range blobs { + if b.Path == recPath { + record = b.Data + break + } + } // Reprobe the four removal conditions against fresh live facts before any // mutation. Each unresolved external probe is unknown (retain); each cleanly @@ -438,7 +447,7 @@ func FinalizeClearBlock(ctx context.Context, deps FinalizeDeps, repoDir string, ReasonClearRemoteHeadMismatch, "the remote feature ref names a commit other than the expected head; the marker stays", req.ID) } - // Matching open PR at the exact head, and (unless the gate is off) green body + // Matching open PR at the exact head, and (unless the gate is off) green record // evidence for that head. featureBranch := strings.TrimPrefix(string(target.FeatureRef), branchRefPrefix) prs, err := deps.GitHub.FindOpenPullRequestsByHead(ctx, ghRepo, featureBranch) @@ -452,10 +461,10 @@ func FinalizeClearBlock(ctx context.Context, deps FinalizeDeps, repoDir string, ReasonClearPRNotOpen, "there is not exactly one matching open pull request for the feature head; the marker stays", req.ID) } if eff.Finalize.Gate.Value != "off" { - evHead, _, evGreen := prBodyEvidence(prs[0]) + evHead, _, evGreen := recordEvidenceFacts(record) if !evGreen || evHead != req.Head { return blockRefusal(OperationFinalizeClearBlock, ResultBlocked, BlockDispRefused, - ReasonClearEvidenceUnverified, "the pull-request body evidence does not verify green for the exact current head; the marker stays", req.ID) + ReasonClearEvidenceUnverified, "the change record's build evidence does not verify green for the exact current head; the marker stays", req.ID) } } diff --git a/internal/app/finalize_block_integration_test.go b/internal/app/finalize_block_integration_test.go index 6458a05f6..35ac1dd36 100644 --- a/internal/app/finalize_block_integration_test.go +++ b/internal/app/finalize_block_integration_test.go @@ -28,9 +28,10 @@ func TestIntegrationFinalizeOpsFinalizeBlockUnrelatedInvalidRecordProgress(t *te func TestIntegrationFinalizeOpsFinalizeClearBlockUnrelatedInvalidRecordProgress(t *testing.T) { f := setupBlockedFixture(t, planRepoModeDocket()) + f.seedGreenRecordEvidence(t, f.head) f.repo.writerAdvance(t, f.branch, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, prBodyNoEvidence)}}} got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, ClearBlockRequest{ID: f.id, Revision: f.revision, Head: f.head, PRNumber: 1}) diff --git a/internal/app/finalize_merge.go b/internal/app/finalize_merge.go index c920469a2..9f274bdeb 100644 --- a/internal/app/finalize_merge.go +++ b/internal/app/finalize_merge.go @@ -312,10 +312,13 @@ type mergeContext struct { snap domain.Snapshot change domain.Change revision string - base domain.EffectiveBase - target workspace.Target - repo gitcli.Repository - eff config.Effective + // record is the change record's bytes from the same corpus read that yields + // revision: the merge gate reads the build evidence from this one copy. + record []byte + base domain.EffectiveBase + target workspace.Target + repo gitcli.Repository + eff config.Effective } // loadMergeContext performs the fresh reload every merge decision reads from, @@ -381,9 +384,10 @@ func loadMergeContext(ctx context.Context, deps FinalizeDeps, repoDir string, id } revision := "" + var record []byte for _, b := range blobs { if b.Path == c.Path() { - revision = b.Revision + revision, record = b.Revision, b.Data break } } @@ -420,7 +424,7 @@ func loadMergeContext(ctx context.Context, deps FinalizeDeps, repoDir string, id } return &mergeContext{ - snap: snap, change: c, revision: revision, + snap: snap, change: c, revision: revision, record: record, base: base, target: target, repo: repo, eff: eff, }, nil } @@ -519,7 +523,7 @@ func FinalizeMerge(ctx context.Context, deps FinalizeDeps, repoDir string, req F }) } - evHead, _, evGreen := prBodyEvidence(pr) + evHead, _, evGreen := recordEvidenceFacts(mc.record) conj := mergeConditions(mergeConditionInputs{ status: mc.change.Status(), canonicalPRNumber: canonicalN, diff --git a/internal/app/finalize_merge_integration_test.go b/internal/app/finalize_merge_integration_test.go index eb3a76c5f..d4a1f92c9 100644 --- a/internal/app/finalize_merge_integration_test.go +++ b/internal/app/finalize_merge_integration_test.go @@ -217,7 +217,7 @@ func TestIntegrationFinalizeMergeConditionsRechecked(t *testing.T) { f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) gh := f.baselineFake(t) gh.openByHead["feat/"+f.slug] = []githubcli.PullRequest{func() githubcli.PullRequest { - pr := f.parentPR(f.head, greenEvidenceFor(t, f.head)) + pr := f.parentPR(f.head, prBodyNoEvidence) pr.Number = 9 // not the canonical #7 return pr }()} @@ -256,7 +256,28 @@ func TestIntegrationFinalizeMergeConditionsRechecked(t *testing.T) { t.Parallel() f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) gh := f.baselineFake(t) - gh.openByHead["feat/"+f.slug][0].Body = "" // no green evidence; gate is local + f.clearEvidence(t) // the record carries no build evidence; gate is local + res := FinalizeMerge(context.Background(), f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) + assertMergeRefusal(t, res, gh, "gate-unsatisfied") + }) + + t.Run("gate-stale-record-evidence", func(t *testing.T) { + t.Parallel() + f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) + gh := f.baselineFake(t) + f.seedGreenEvidence(t, f.baseTip) // green, but for a head other than the PR head + res := FinalizeMerge(context.Background(), f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) + assertMergeRefusal(t, res, gh, "gate-unsatisfied") + }) + + // No compatibility read of PR-body evidence: a green block for the exact head + // in the PR description, with no record section, leaves the gate unsatisfied. + t.Run("gate-evidence-only-in-pr-body", func(t *testing.T) { + t.Parallel() + f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) + gh := f.baselineFake(t) + f.clearEvidence(t) + gh.openByHead["feat/"+f.slug][0].Body = greenEvidenceFor(t, f.head) res := FinalizeMerge(context.Background(), f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) assertMergeRefusal(t, res, gh, "gate-unsatisfied") }) @@ -390,7 +411,7 @@ func TestIntegrationFinalizeMergeExplicitIDOverrides(t *testing.T) { t.Parallel() f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) gh := f.baselineFake(t) - gh.openByHead["feat/"+f.slug][0].Body = "" + f.clearEvidence(t) res := FinalizeMerge(context.Background(), f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) assertMergeRefusal(t, res, gh, "gate-unsatisfied") }) @@ -624,13 +645,20 @@ func (f *fakeMergeCarryGitHub) FindComment(context.Context, githubcli.Repository panic("FindComment: carry-gated merge must not call this") } -// carryFake builds a carry-gated fake whose parent PR (#7) carries prBody (green -// evidence, or "" when the gate is off) and passes every non-carry condition; -// tests seed the child #8 reprobe and the merge outcome onto it. -func (f *mergeFixture) carryFake(prBody string) *fakeMergeCarryGitHub { +// carryFake builds a carry-gated fake whose parent PR (#7) passes every +// non-carry condition; tests seed the child #8 reprobe and the merge outcome onto +// it. green seeds record evidence certifying the current f.head (refreshing +// f.revision); !green removes it (only gate-off then satisfies the gate). +func (f *mergeFixture) carryFake(t *testing.T, green bool) *fakeMergeCarryGitHub { + t.Helper() + if green { + f.seedGreenEvidence(t, f.head) + } else { + f.clearEvidence(t) + } return &fakeMergeCarryGitHub{ repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.parentPR(f.head, prBody)}}, + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.parentPR(f.head, prBodyNoEvidence)}}, merged: map[int]closeoutProbe{}, } } @@ -656,7 +684,7 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { if _, err := tryGit(f.repo.invocation, "cat-file", "-e", dropped); err != nil { t.Fatalf("the child merge object must exist to pin a drop: %v", err) } - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)} // A real merge outcome would otherwise land: the witness is that the gate // refuses BEFORE any MergePullRequest, leaving zero merge calls. @@ -685,7 +713,7 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { // companion that proves the zero-call assertion above is not vacuous. f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) mergeCommit := f.mergeFeatureIntoBase(t) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.mergeOutcome = githubcli.MergeMerged gh.mergeFacts = mergedFactsFor(f.head, "main", mergeCommit) res := FinalizeMerge(ctx, f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) @@ -703,12 +731,12 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { seedRebaseCarryChild(t, f.rebaseFixture) dropped := carryDroppedCommit(t, f.rebaseFixture, map[string]string{"catalog.yaml": "G\n"}) // finalize.gate: off — the gate condition is satisfied by config, not by - // green PR evidence, so the merge reaches the proof through the gate-off + // green record evidence, so the merge reaches the proof through the gate-off // route. The proof still runs and refuses: gate mode cannot disable it. f.repo.writerAdvance(t, "main", map[string]string{ ".docket.yml": "integration_branch: main\nbuild:\n test_command: 'go test ./...'\nfinalize:\n gate: \"off\"\n test_command: 'go test ./...'\n", }) - gh := f.carryFake("") // NO green evidence: only gate-off lets the gate condition pass + gh := f.carryFake(t, false) // NO green evidence: only gate-off lets the gate condition pass gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)} gh.mergeOutcome = githubcli.MergeMerged res := FinalizeMerge(ctx, f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) @@ -727,7 +755,7 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) seedRebaseCarryChild(t, f.rebaseFixture) dropped := carryDroppedCommit(t, f.rebaseFixture, map[string]string{"catalog.yaml": "D\n"}) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)} gh.mergeOutcome = githubcli.MergeMerged res := FinalizeMerge(ctx, f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) @@ -748,7 +776,7 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { f := setupMergeFixtureWithNode(t, m, parallelPlanningNode) seedRebaseCarryChild(t, f.rebaseFixture) dropped := carryDroppedCommit(t, f.rebaseFixture, map[string]string{"catalog.yaml": "H\n"}) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)} gh.mergeOutcome = githubcli.MergeMerged other := strings.Repeat("b", 40) @@ -773,7 +801,7 @@ func TestIntegrationFinalizeMergeCarryPreservation(t *testing.T) { mergeCommit := f.mergeFeatureIntoBase(t) seedRebaseCarryChild(t, f.rebaseFixture) dropped := carryDroppedCommit(t, f.rebaseFixture, map[string]string{"catalog.yaml": "A\n"}) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[7] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "main", mergeCommit)} gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)} res := FinalizeMerge(ctx, f.mergeDeps(gh), f.repo.invocation, mergeReq(f, f.head, true, false)) @@ -861,7 +889,7 @@ func TestIntegrationFinalizeMergeCarryTransitive(t *testing.T) { if _, err := tryGit(f.repo.invocation, "cat-file", "-e", bDropped); err != nil { t.Fatalf("the grandchild merge object must exist to pin a drop: %v", err) } - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", aMerge)} gh.merged[9] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/gadget", bDropped)} gh.mergeOutcome = githubcli.MergeMerged @@ -902,7 +930,7 @@ func TestIntegrationFinalizeMergeCarryTransitive(t *testing.T) { bMerge := f.commitOntoFeature(t, map[string]string{"gizmo.yaml": "B\n"}) f.commitOntoFeature(t, map[string]string{"feature-more.txt": "more\n"}) // both carried commits are now strict ancestors mergeCommit := f.mergeFeatureIntoBase(t) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) gh.merged[8] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", aMerge)} gh.merged[9] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/gadget", bMerge)} gh.mergeOutcome = githubcli.MergeMerged @@ -928,7 +956,7 @@ func TestIntegrationFinalizeMergeCarryTransitive(t *testing.T) { seedMergeOpenIntermediate(t, f.rebaseFixture) // id 6 (gadget), in-progress seedMergeCarryGrandchild(t, f.rebaseFixture) // id 7 (gizmo), stacked-merged into id 6 mergeCommit := f.mergeFeatureIntoBase(t) - gh := f.carryFake(greenEvidenceFor(t, f.head)) + gh := f.carryFake(t, true) // D's PR #9 reprobes merged into C's branch, but D is never reached (C is open), // so its dropped content never gates the merge. gh.merged[9] = closeoutProbe{outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/gadget", strings.Repeat("c", 40))} diff --git a/internal/app/finalize_merge_test.go b/internal/app/finalize_merge_test.go index 2bc45a291..9537b5429 100644 --- a/internal/app/finalize_merge_test.go +++ b/internal/app/finalize_merge_test.go @@ -180,7 +180,7 @@ func (f *mergeFixture) mergeDeps(gh FinalizeGitHub) FinalizeDeps { } // parentPR is the canonical open PR for the parent feature head: number 7, -// non-draft, targeting main, carrying green evidence for the given head. +// non-draft, targeting main, with the given description. func (f *mergeFixture) parentPR(head string, body string) githubcli.PullRequest { return githubcli.PullRequest{ Number: mergeCanonicalPRNumber, URL: "https://example.test/pr/7", State: githubcli.StateOpen, @@ -190,15 +190,33 @@ func (f *mergeFixture) parentPR(head string, body string) githubcli.PullRequest } // baselineFake returns a fake whose parent PR passes every condition: open, -// non-draft, number 7, at the fixture head, base main, green evidence. +// non-draft, number 7, at the fixture head, base main. It also seeds green +// build evidence for the fixture head into the parent record's +// "## Build evidence" section (refreshing f.revision) — the gate condition reads +// the record, never the PR description, which carries no evidence block. func (f *mergeFixture) baselineFake(t *testing.T) *fakeMergeGitHub { t.Helper() + f.seedGreenEvidence(t, f.head) return &fakeMergeGitHub{ repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.parentPR(f.head, greenEvidenceFor(t, f.head))}}, + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.parentPR(f.head, prBodyNoEvidence)}}, } } +// seedGreenEvidence seeds green record evidence certifying head into the parent +// record and keeps the merge request's pinned revision current. +func (f *mergeFixture) seedGreenEvidence(t *testing.T, head string) { + t.Helper() + f.revision = f.rebaseFixture.seedGreenRecordEvidence(t, head) +} + +// clearEvidence removes the parent record's build-evidence section and keeps +// the merge request's pinned revision current. +func (f *mergeFixture) clearEvidence(t *testing.T) { + t.Helper() + f.revision = f.rebaseFixture.clearRecordEvidence(t) +} + // mergeFeatureIntoBase creates a real merge commit on origin's integration // branch (main) that carries the feature head, and returns its object id — the // authoritative merge commit the reachability proof must find reachable. diff --git a/internal/app/finalize_publish_helpers_test.go b/internal/app/finalize_publish_helpers_test.go index a60781be4..b2b32abc7 100644 --- a/internal/app/finalize_publish_helpers_test.go +++ b/internal/app/finalize_publish_helpers_test.go @@ -195,6 +195,81 @@ func (f *rebaseFixture) remoteRecordBytes(t *testing.T) string { return body } +// writeRemoteRecord replaces the fixture change's record at the metadata remote +// tip with body (syncing the writer clone to that tip first, so a docket +// operation's earlier metadata commit never makes the push non-fast-forward) +// and refreshes f.revision — the blob revision every gated read pins — which it +// also returns. +func (f *rebaseFixture) writeRemoteRecord(t *testing.T, body string) string { + t.Helper() + runGit(t, f.repo.writer, "fetch", "-q", "origin", f.branch) + runGit(t, f.repo.writer, "checkout", "-q", "-B", f.branch, "FETCH_HEAD") + f.repo.writerAdvance(t, f.branch, map[string]string{groomPath(f.id, f.slug): body}) + f.revision = blobRevisionAt(t, f.repo.origin, f.branch, groomPath(f.id, f.slug)) + return f.revision +} + +// seedRecordEvidence makes rec the "## Build evidence" section of the fixture +// change's record at the metadata remote tip — the durable evidence every +// finalize gate and run verify reads — and returns the refreshed revision. A +// record that already carries exactly rec is left untouched. +func (f *rebaseFixture) seedRecordEvidence(t *testing.T, rec evidence.Record) string { + t.Helper() + body := f.remoteRecordBytes(t) + out, err := UpsertRecordEvidence([]byte(body), rec) + if err != nil { + t.Fatalf("UpsertRecordEvidence: %v", err) + } + if string(out) == body { + f.revision = blobRevisionAt(t, f.repo.origin, f.branch, groomPath(f.id, f.slug)) + return f.revision + } + return f.writeRemoteRecord(t, string(out)) +} + +// seedGreenRecordEvidence seeds green record evidence certifying head +// (greenEvidenceRecord) and returns the refreshed revision. +func (f *rebaseFixture) seedGreenRecordEvidence(t *testing.T, head string) string { + t.Helper() + return f.seedRecordEvidence(t, greenEvidenceRecord(t, head)) +} + +// clearRecordEvidence removes the "## Build evidence" section from the fixture +// change's record at the metadata remote tip and returns the refreshed revision. +// A record that carries no section is left untouched (its live revision is +// returned). +func (f *rebaseFixture) clearRecordEvidence(t *testing.T) string { + t.Helper() + body := f.remoteRecordBytes(t) + if _, present, err := recordEvidenceSection([]byte(body)); err != nil || !present { + if err != nil { + t.Fatalf("record evidence section: %v", err) + } + f.revision = blobRevisionAt(t, f.repo.origin, f.branch, groomPath(f.id, f.slug)) + return f.revision + } + return f.writeRemoteRecord(t, withoutRecordEvidence(t, body)) +} + +// withoutRecordEvidence strips the trailing "## Build evidence" section +// UpsertRecordEvidence appends. It fails the test when the section is absent or +// is followed by another H2, so a fixture never silently keeps the evidence. +func withoutRecordEvidence(t *testing.T, record string) string { + t.Helper() + i := strings.Index(record, "\n"+buildEvidenceHeading+"\n") + if i < 0 { + t.Fatalf("record carries no %q section to remove:\n%s", buildEvidenceHeading, record) + } + if strings.Contains(record[i+1+len(buildEvidenceHeading):], "\n## ") { + t.Fatalf("the %q section is not the record's last section:\n%s", buildEvidenceHeading, record) + } + out := record[:i] + if !strings.HasSuffix(out, "\n") { + out += "\n" + } + return out +} + // remoteRecordEvidence reads the "## Build evidence" section of the fixture // change's record at the metadata remote tip — the durable evidence home. func (f *rebaseFixture) remoteRecordEvidence(t *testing.T) evidence.Record { diff --git a/internal/app/finalize_rebase.go b/internal/app/finalize_rebase.go index 796580db8..b314af978 100644 --- a/internal/app/finalize_rebase.go +++ b/internal/app/finalize_rebase.go @@ -47,8 +47,8 @@ import ( // that set refuses. Report bodies are never echoed into a result (Global // Constraints: returned diagnostics redact report bodies) — only bounded, // safe tokens (disposition, counts, the attempt) travel out. -// - The local suite is skipped ONLY when the rebase was a no-op and the PR body -// carries green evidence for the EXACT current head AND the recorded command is +// - The local suite is skipped ONLY when the rebase was a no-op and the change +// record's build-evidence section carries green evidence for the EXACT current head AND the recorded command is // byte-equal to the currently resolved finalize.test_command (gateDecision). // Skipped (build-gate-off) build evidence never waives finalize; a differing or // empty command runs the suite. Any other evidence head runs the full suite; @@ -494,7 +494,7 @@ type FinalizeGate interface { // --------------------------------------------------------------------------- // gateDecision decides whether the local suite may be skipped after a completed -// rebase. It skips ONLY when the rebase was a no-op AND the PR body carries GREEN +// rebase. It skips ONLY when the rebase was a no-op AND the record carries GREEN // evidence for the EXACT current head AND the recorded command is byte-equal to // the currently resolved finalize.test_command (with both non-empty); the permit // names that head. Differing commands are different assertions even at the same @@ -609,11 +609,14 @@ type rebaseContext struct { repo gitcli.Repository change domain.Change revision string - base domain.EffectiveBase - target workspace.Target - insp workspace.Inspection - wsDir string - metaDir string + // record is the change record's bytes from the workspace context's one + // corpus read; the record-evidence skip reads it. + record []byte + base domain.EffectiveBase + target workspace.Target + insp workspace.Inspection + wsDir string + metaDir string // snap is the corpus snapshot the context was resolved from; the carried- // descendant preservation gate reads the live stacked_on graph from it. snap domain.Snapshot @@ -642,6 +645,7 @@ func loadRebaseContext(ctx context.Context, deps FinalizeDeps, repoDir string, o repo: wc.repo, change: wc.change, revision: wc.revision, + record: wc.record, base: wc.base, target: target, insp: insp, @@ -835,8 +839,8 @@ func FinalizeRebase(ctx context.Context, deps FinalizeDeps, repoDir string, req } // rebaseMapOptions carries the caller's policy for mapping a begun rebase onto -// the gate composition. evidenceSkip permits the PR-body evidence skip (the fresh -// FinalizeRebase path only; recovery never bypasses the suite on PR evidence). +// the gate composition. evidenceSkip permits the record-evidence skip (the fresh +// FinalizeRebase path only; recovery never bypasses the suite on record evidence). // forceRetest suppresses the mechanically-unchanged no-op shortcut so a base // refresh always retests even when Git reports no textual change (Task 3). type rebaseMapOptions struct { @@ -1011,7 +1015,7 @@ func recoverFromReceipt(ctx context.Context, deps FinalizeDeps, repoDir string, // The receipt's pair may be set (a prior WAITING to resume) or empty (a crash // before WAITING, or a cleared terminal); composeLocalGate derives the // continuation from rec, so both are correct as-is. Recovery never bypasses the - // suite on PR-body evidence (spec §4): allowEvidenceSkip is false. + // suite on record evidence (spec §4): allowEvidenceSkip is false. return composeLocalGate(ctx, deps, repoDir, op, rc, pr, rec, localHead, noop, false) } @@ -1631,7 +1635,7 @@ func requireCarriedPreserved(ctx context.Context, deps FinalizeDeps, repoDir, op } // composeLocalGate decides skip-or-run after a completed rebase and maps the gate -// outcome. It skips only on a no-op rebase with exact-head green PR evidence whose +// outcome. It skips only on a no-op rebase with exact-head green record evidence whose // recorded command is byte-equal to the resolved finalize.test_command; // otherwise it runs the full suite through the gate seam. A passed run carries the // evidence block; a failed run is repair work (failed); a halt is retained @@ -1656,12 +1660,12 @@ func composeLocalGate(ctx context.Context, deps FinalizeDeps, repoDir, op string } currentHead := strings.ToLower(string(head)) - evidenceHead, evidenceCommand, evidenceGreen := prBodyEvidence(pr) + evidenceHead, evidenceCommand, evidenceGreen := recordEvidenceFacts(rc.record) resolvedCommand, resolvedGatePolicy := resolvedFinalizeGateConfig(ctx, deps, repoDir) skip, permit := false, "" - // The PR-body evidence skip is consulted ONLY on the fresh path + // The record-evidence skip is consulted ONLY on the fresh path // (allowEvidenceSkip): receipt-based completion recovery never bypasses the - // suite on PR-body evidence (spec §4), and a recorded live continuation + // suite on record evidence (spec §4), and a recorded live continuation // (DriveID set) must be advanced to a terminal, never skipped past. if cont.DriveID == "" && allowEvidenceSkip { skip, permit = gateDecision(noop, evidenceHead, currentHead, evidenceGreen, evidenceCommand, resolvedCommand) @@ -1782,7 +1786,7 @@ func composeLocalGate(ctx context.Context, deps FinalizeDeps, repoDir, op string // terminal), and — for a real rewrite whose PR identity is known and whose gate // configuration resolved — the completed-gate publish checkpoint is recorded so // a denied publish can resume without re-running the suite (change 0408). A -// no-op rebase records no checkpoint (its skip waiver is the PR-body evidence +// no-op rebase records no checkpoint (its skip waiver is the record evidence // gateDecision already honors). Best-effort like clearGateContinuation: the // gate outcome is already mapped, so a write failure is appended to the result // message and never changes the disposition — the resume simply re-runs the @@ -1833,23 +1837,6 @@ func clearGateContinuation(ctx context.Context, deps FinalizeDeps, rc *rebaseCon } } -// prBodyEvidence extracts the exact-head evidence facts from a PR body: the -// certified head, the recorded command, and whether the record is GREEN (result -// == evidence.ResultGreen). A skipped (build-gate-off) record parses but is not -// green and carries no command, so it can never waive finalize's local gate -// through gateDecision. A body with no block, or one that does not parse, is not -// green. -func prBodyEvidence(pr githubcli.PullRequest) (evidenceHead, evidenceCommand string, green bool) { - if pr.Body == "" { - return "", "", false - } - rec, err := evidence.Extract([]byte(pr.Body)) - if err != nil { - return "", "", false - } - return rec.Head, rec.Command, rec.Result == evidence.ResultGreen -} - // resolvedFinalizeGateConfig re-reads the authoritative finalize gate // configuration the same way processFinalizeGate.buildDriveService pins it, // returning the resolved finalize.test_command and finalize.gate policy. diff --git a/internal/app/finalize_rebase_integration_test.go b/internal/app/finalize_rebase_integration_test.go index 659191fb6..531a351bf 100644 --- a/internal/app/finalize_rebase_integration_test.go +++ b/internal/app/finalize_rebase_integration_test.go @@ -174,7 +174,8 @@ func TestIntegrationFinalizeRebaseGateOutcomes(t *testing.T) { t.Run("skip-on-noop-exact-green", func(t *testing.T) { f := setupRebaseFixture(t, main) // base unmoved: the rebase is a no-op. - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}} + f.seedGreenRecordEvidence(t, f.head) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}} gate := &fakeGate{} res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head}) @@ -189,6 +190,33 @@ func TestIntegrationFinalizeRebaseGateOutcomes(t *testing.T) { } }) + // The skip reads the change record only: a no-op whose green exact-head + // evidence sits solely in the PR description, or whose record evidence + // certifies another head, runs the suite. + for _, tc := range []struct { + name string + seed func(f *rebaseFixture) + body func(f *rebaseFixture) string + }{ + {"noop-pr-body-evidence-only-runs", func(*rebaseFixture) {}, func(f *rebaseFixture) string { return greenEvidenceFor(t, f.head) }}, + {"noop-stale-record-evidence-runs", func(f *rebaseFixture) { f.seedGreenRecordEvidence(t, f.baseTip) }, func(*rebaseFixture) string { return prBodyNoEvidence }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := setupRebaseFixture(t, main) // base unmoved: the rebase is a no-op. + tc.seed(f) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, tc.body(f))}} + gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}} + res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, + FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head}) + if res.Gate == nil || res.Gate.Compose != gateComposeRan { + t.Fatalf("noop without exact-head record evidence = disp %q gate %+v (reason %q), want the suite run", res.Disposition, res.Gate, res.Reason) + } + if gate.calls != 1 { + t.Errorf("the suite ran %d time(s); want 1", gate.calls) + } + }) + } + t.Run("passed-produces-evidence", func(t *testing.T) { f := setupRebaseFixture(t, main) f.advanceBase(t) @@ -463,7 +491,7 @@ func TestIntegrationFinalizeRebaseGateWaiting(t *testing.T) { t.Run("recorded-live-continuation-overrides-the-evidence-skip", func(t *testing.T) { // A pair recorded by a WAITING slice means a drive is LIVE for this - // attempt; goal 3 forbids leaving it dangling. Even if the PR body now + // attempt; goal 3 forbids leaving it dangling. Even if the change record now // carries exact-head green evidence (which would skip on a first call), // the re-entry must ADVANCE the recorded drive, not skip past it — // otherwise the pair encodes a state nothing transitions out of @@ -475,15 +503,16 @@ func TestIntegrationFinalizeRebaseGateWaiting(t *testing.T) { {Outcome: FinalizeGateWaiting, Continuation: cont}, {Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}, }} - // First call: no PR evidence -> the suite runs -> WAITING records the pair. + // First call: no record evidence -> the suite runs -> WAITING records the pair. ghNone := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} req := FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head} ctx := context.Background() if r := FinalizeRebase(ctx, f.finalizeDeps(ghNone, gate), f.repo.invocation, req); r.Disposition != RebaseDispWaiting { t.Fatalf("first = %q, want waiting", r.Disposition) } - // Second call: the PR body NOW carries exact-head green evidence. - ghGreen := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}} + // Second call: the change record NOW carries exact-head green evidence. + req.Revision = f.seedGreenRecordEvidence(t, f.head) + ghGreen := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}} second := FinalizeRebase(ctx, f.finalizeDeps(ghGreen, gate), f.repo.invocation, req) if second.Gate == nil || second.Gate.Compose != "ran" { t.Fatalf("re-entry with a recorded live drive skipped: gate %+v", second.Gate) @@ -1046,7 +1075,7 @@ func TestIntegrationFinalizeRebaseGatePassedRecordsPublishCheckpoint(t *testing. t.Run("noop-rebase-records-nothing", func(t *testing.T) { f := setupRebaseFixture(t, main) // No advanceBase: the feature already sits on the base; the gate still runs - // (no PR evidence waives it) but the pass is for a no-op rebase. + // (no record evidence waives it) but the pass is for a no-op rebase. gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, "")}} gate := &headEvidenceGate{t: t} res := FinalizeRebase(context.Background(), f.finalizeDeps(gh, gate), f.repo.invocation, @@ -1347,9 +1376,10 @@ func TestIntegrationFinalizeRebaseRecoveryCarryPreservation(t *testing.T) { // The child's merge-result is an ancestor of the feature head (a real object // preserved by ancestry), so the proof passes and the operation proceeds to a // normal rebase outcome — never a carry refusal. + f.seedGreenRecordEvidence(t, f.head) gh := &fakeRebaseCarryGitHub{ repo: retargetRepo(), - prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}, + prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}, merged: map[int]closeoutProbe{8: {outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", f.baseTip)}}, } res := FinalizeRebase(ctx, f.finalizeDeps(gh, &fakeGate{}), f.repo.invocation, @@ -1428,13 +1458,14 @@ func TestIntegrationFinalizeRebaseRecoveryCarryPreservation(t *testing.T) { if err := f.svc.WriteRebaseReceipt(ctx, f.metaDir, rec); err != nil { t.Fatalf("write receipt: %v", err) } - // Exact-head GREEN evidence on the PR body would otherwise skip the local - // suite; the carry proof runs before and independently of that decision, so - // the refusal is the carry reason, never a skipped success. + // Exact-head GREEN evidence in the change record would otherwise skip the + // local suite; the carry proof runs before and independently of that + // decision, so the refusal is the carry reason, never a skipped success. + f.seedGreenRecordEvidence(t, f.head) gate := &fakeGate{} gh := &fakeRebaseCarryGitHub{ repo: retargetRepo(), - prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}, + prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}, merged: map[int]closeoutProbe{8: {outcome: githubcli.MergeAlreadyMerged, facts: mergedFactsFor(f.head, "feat/widget", dropped)}}, } res := FinalizeRebase(ctx, f.finalizeDeps(gh, gate), f.repo.invocation, @@ -1553,34 +1584,35 @@ func TestIntegrationFinalizeRebaseRecoveryPreStartResumeForeignHeadRetained(t *t // TestIntegrationFinalizeRebaseRecoveryNoEvidenceSkip proves receipt-based // completion recovery with no valid checkpoint runs the gate (spec §4): a fresh -// invocation may skip on exact-head green PR evidence (existing behavior), but a +// invocation may skip on exact-head green record evidence (existing behavior), but a // REPLAY after response loss must run the gate because a receipt now exists and no // checkpoint was recorded. func TestIntegrationFinalizeRebaseRecoveryNoEvidenceSkip(t *testing.T) { requireRealGit(t) f := setupRebaseFixture(t, planRepoModes()[0]) - // Base NOT advanced: a fresh call is a mechanical no-op whose PR body carries - // green evidence for the exact current head and command. - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}} + // Base NOT advanced: a fresh call is a mechanical no-op whose change record + // carries green evidence for the exact current head and command. + f.seedGreenRecordEvidence(t, f.head) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}} gate := &fakeGate{result: LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenEvidenceFor(t, f.head), RunDir: "/run/x"}} deps := f.finalizeDeps(gh, gate) first := FinalizeRebase(context.Background(), deps, f.repo.invocation, FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head}) if first.Gate == nil || first.Gate.Compose != gateComposeSkipped { - t.Fatalf("first call = %+v, want a fresh skip on exact-head green PR evidence", first.Gate) + t.Fatalf("first call = %+v, want a fresh skip on exact-head green record evidence", first.Gate) } if gate.calls != 0 { t.Fatalf("the suite ran on the fresh no-op skip; want 0 calls, got %d", gate.calls) } // Replay after a lost response: a receipt now exists and no checkpoint was - // recorded, so recovery must RUN the gate — PR-body evidence cannot bypass the + // recorded, so recovery must RUN the gate — record evidence cannot bypass the // retest on recovery. second := FinalizeRebase(context.Background(), deps, f.repo.invocation, FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head}) if second.Gate == nil || second.Gate.Compose != gateComposeRan { - t.Fatalf("replay recovery = %+v, want compose ran (no PR-evidence skip on recovery)", second.Gate) + t.Fatalf("replay recovery = %+v, want compose ran (no record-evidence skip on recovery)", second.Gate) } if gate.calls != 1 { t.Errorf("recovery ran the gate %d time(s); want exactly 1", gate.calls) @@ -1981,7 +2013,7 @@ func TestIntegrationFinalizeRebaseForwardRefreshInterruptions(t *testing.T) { // acceptance 5 and spec §4: when the effective base advances to a commit the // completed rewrite ALREADY contains, the forward refresh is a mechanically // unchanged rebase (Git rewrites nothing), yet the full suite still re-runs on -// the refreshed head and records a checkpoint against the NEW base — PR-body +// the refreshed head and records a checkpoint against the NEW base — record // evidence never waives that retest. A response-lost replay then reuses the // freshly recorded checkpoint instead of re-running: change 0438 opened the // checkpoint-reuse gate to the mechanically unchanged refresh (a valid @@ -1991,10 +2023,11 @@ func TestIntegrationFinalizeRebaseForwardRefreshUnchangedRetests(t *testing.T) { requireRealGit(t) f := setupRebaseFixture(t, planRepoModes()[0]) f.advanceBase(t) // origin/main -> B1, off the feature base - // The PR carries green PR-body evidence for the feature head — the kind of + // The change record carries green evidence for the feature head — the kind of // record that waives the suite on the FRESH no-op path — so the refresh's // forced retest is proven never to be waived by it. - gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, greenEvidenceFor(t, f.head))}} + f.seedGreenRecordEvidence(t, f.head) + gh := &fakeRebaseGitHub{repo: retargetRepo(), prs: []githubcli.PullRequest{f.prForHead(f.head, prBodyNoEvidence)}} gate := &headEvidenceGate{t: t} deps := f.finalizeDeps(gh, gate) @@ -2014,8 +2047,8 @@ func TestIntegrationFinalizeRebaseForwardRefreshUnchangedRetests(t *testing.T) { // Second finalize.rebase: the base moved, so this forward-refreshes. Git rewrites // nothing (the head is already based on the new base), but the suite STILL runs - // (spec §4) and records a checkpoint against the new base — even though the PR - // body carries green evidence. + // (spec §4) and records a checkpoint against the new base — even though the + // change record carries green evidence. second := FinalizeRebase(context.Background(), deps, f.repo.invocation, FinalizeRebaseRequest{ID: f.id, Revision: f.revision, Head: f.head}) if second.Result != ResultApplied || second.Disposition != RebaseDispRebased { @@ -2025,7 +2058,7 @@ func TestIntegrationFinalizeRebaseForwardRefreshUnchangedRetests(t *testing.T) { t.Fatalf("refresh gate = %+v, want compose ran (a base refresh always retests, even mechanically unchanged)", second.Gate) } if gate.calls != 2 { - t.Fatalf("gate calls after the refresh = %d, want 2 (the refresh re-ran the suite despite PR-body evidence)", gate.calls) + t.Fatalf("gate calls after the refresh = %d, want 2 (the refresh re-ran the suite despite record evidence)", gate.calls) } if f.localHead() != rewritten { t.Fatalf("the mechanically-unchanged refresh moved the head: %q -> %q", rewritten, f.localHead()) @@ -2484,7 +2517,7 @@ func TestIntegrationFinalizeRebasePublishedRefreshInterruption(t *testing.T) { out.Disposition, out.Attempt, out.Reason, crashed.Attempt) } if out.Gate == nil || out.Gate.Compose != gateComposeRan { - t.Fatalf("gate = %+v, want compose ran (recovery never skips on PR evidence)", out.Gate) + t.Fatalf("gate = %+v, want compose ran (recovery never skips on record evidence)", out.Gate) } callsAfter := gate.calls // Valid replay of the identical invocation: the completed rewrite's fresh diff --git a/internal/app/finalize_rebase_test.go b/internal/app/finalize_rebase_test.go index 3c831b4d3..1f1dd2450 100644 --- a/internal/app/finalize_rebase_test.go +++ b/internal/app/finalize_rebase_test.go @@ -313,17 +313,29 @@ func (f *rebaseFixture) advanceBase(t *testing.T) string { return f.repo.writerAdvance(t, "main", map[string]string{name: "downstream base work\n"}) } -// greenEvidenceFor renders a green build-evidence block certifying head, for -// embedding in a PR body. +// greenEvidenceFor renders a green build-evidence block certifying head inside +// surrounding prose (a gate's evidence output, a checkpoint, or a PR body). func greenEvidenceFor(t *testing.T, head string) string { + t.Helper() + return "Authored prose.\n\n" + evidence.Render(greenEvidenceRecord(t, head)) + "\nMore prose.\n" +} + +// greenEvidenceRecord is the green build-evidence record greenEvidenceFor +// renders: certifying head for the "go test ./..." command. +func greenEvidenceRecord(t *testing.T, head string) evidence.Record { t.Helper() rec, err := evidence.NewRecord("go test ./...", head, time.Date(2026, 8, 18, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("evidence.NewRecord: %v", err) } - return "Authored prose.\n\n" + evidence.Render(rec) + "\nMore prose.\n" + return rec } +// prBodyNoEvidence is an authored PR description carrying no build-evidence +// block: the durable build evidence lives in the change record's +// "## Build evidence" section, never in the PR body. +const prBodyNoEvidence = "Authored prose.\n" + // --- TestGateDecision ----------------------------------------------------- func TestGateDecision(t *testing.T) { @@ -393,17 +405,21 @@ func TestGateDecisionRequiresCommandByteEquality(t *testing.T) { } } -// TestPRBodyEvidenceReportsCommandAndGreenFlag proves prBodyEvidence surfaces -// the recorded command and reports green ONLY for a green record — a skipped -// (build-gate-off) block is not green and carries no command, so it can never -// waive finalize's local gate through gateDecision. -func TestPRBodyEvidenceReportsCommandAndGreenFlag(t *testing.T) { +// TestRecordEvidenceFactsReportsCommandAndGreenFlag proves recordEvidenceFacts +// surfaces the recorded command and reports green ONLY for a green record — a +// skipped (build-gate-off) section is not green and carries no command, so it +// can never waive finalize's local gate through gateDecision. +func TestRecordEvidenceFactsReportsCommandAndGreenFlag(t *testing.T) { head := strings.Repeat("ab", 20) green, err := evidence.NewRecord("go test ./...", head, time.Now()) if err != nil { t.Fatalf("NewRecord: %v", err) } - h, cmd, isGreen := prBodyEvidence(githubcli.PullRequest{Body: evidence.Render(green)}) + greenRecord, err := UpsertRecordEvidence([]byte(esRecord()), green) + if err != nil { + t.Fatalf("UpsertRecordEvidence(green): %v", err) + } + h, cmd, isGreen := recordEvidenceFacts(greenRecord) if !isGreen || cmd != "go test ./..." || h != head { t.Errorf("green record: head/cmd/green = %q/%q/%v, want %q/%q/true", h, cmd, isGreen, head, "go test ./...") } @@ -411,7 +427,11 @@ func TestPRBodyEvidenceReportsCommandAndGreenFlag(t *testing.T) { if err != nil { t.Fatalf("NewSkippedRecord: %v", err) } - sh, scmd, sGreen := prBodyEvidence(githubcli.PullRequest{Body: evidence.Render(skipped)}) + skippedRecord, err := UpsertRecordEvidence([]byte(esRecord()), skipped) + if err != nil { + t.Fatalf("UpsertRecordEvidence(skipped): %v", err) + } + sh, scmd, sGreen := recordEvidenceFacts(skippedRecord) if sGreen { t.Errorf("skipped record reported green; skipped evidence never waives finalize") } diff --git a/internal/app/finalize_state_integration_test.go b/internal/app/finalize_state_integration_test.go index 7019478eb..ebf78be3f 100644 --- a/internal/app/finalize_state_integration_test.go +++ b/internal/app/finalize_state_integration_test.go @@ -149,8 +149,9 @@ func TestIntegrationFinalizeStateClearBlockReprobes(t *testing.T) { // Full-condition success removes the marker. t.Run("all-hold-clears", func(t *testing.T) { f := setupBlockedFixture(t, m) + f.seedGreenRecordEvidence(t, f.head) gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, prBodyNoEvidence)}}} got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, ClearBlockRequest{ID: f.id, Revision: f.revision, Head: f.head, PRNumber: 1}) if got.Result != ResultApplied || got.Disposition != BlockDispCleared { @@ -165,8 +166,9 @@ func TestIntegrationFinalizeStateClearBlockReprobes(t *testing.T) { // Wrong expected head: refuse, marker stays. t.Run("head-mismatch-refuses", func(t *testing.T) { f := setupBlockedFixture(t, m) + f.seedGreenRecordEvidence(t, f.head) gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, prBodyNoEvidence)}}} got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, ClearBlockRequest{ID: f.id, Revision: f.revision, Head: strings.Repeat("b", 40), PRNumber: 1}) if got.Reason != ReasonClearHeadMismatch { @@ -189,11 +191,25 @@ func TestIntegrationFinalizeStateClearBlockReprobes(t *testing.T) { } }) - // Stale (non-green-for-head) evidence with the gate on: refuse. + // Stale (non-green-for-head) record evidence with the gate on: refuse. t.Run("stale-evidence-refuses", func(t *testing.T) { + f := setupBlockedFixture(t, m) + f.seedGreenRecordEvidence(t, f.baseTip) // green, but for another head + gh := &fakeBlockGitHub{repo: retargetRepo(), + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, prBodyNoEvidence)}}} + got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, + ClearBlockRequest{ID: f.id, Revision: f.revision, Head: f.head, PRNumber: 1}) + if got.Reason != ReasonClearEvidenceUnverified { + t.Fatalf("reason=%q, want %q", got.Reason, ReasonClearEvidenceUnverified) + } + }) + + // Green evidence only in the PR description, none in the record: refuse + // (no compatibility read of PR-body evidence). + t.Run("pr-body-evidence-only-refuses", func(t *testing.T) { f := setupBlockedFixture(t, m) gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, "")}}} + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} got := FinalizeClearBlock(context.Background(), FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc}, f.repo.invocation, ClearBlockRequest{ID: f.id, Revision: f.revision, Head: f.head, PRNumber: 1}) if got.Reason != ReasonClearEvidenceUnverified { @@ -887,11 +903,12 @@ func TestIntegrationFinalizeStateBlockAndClearNoOps(t *testing.T) { // 3. Clear-block on a record with NO marker: a real no-op once the four // removal conditions hold (exact head, published remote ref at head, one - // matching open PR, green body evidence). + // matching open PR, green record evidence). t.Run("absent-marker-clear-block-is-a-no-op", func(t *testing.T) { f := setupRebaseFixtureStatus(t, m, "in-progress") + f.seedGreenRecordEvidence(t, f.head) gh := &fakeBlockGitHub{repo: retargetRepo(), - openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, greenEvidenceFor(t, f.head))}}} + openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {f.prForHead(f.head, prBodyNoEvidence)}}} deps := FinalizeDeps{Planning: f.deps, GitHub: gh, Workspace: f.svc} before := originTip(t, f.repo.origin, f.branch) got := FinalizeClearBlock(context.Background(), deps, f.repo.invocation, diff --git a/internal/app/named_isolation_integration_test.go b/internal/app/named_isolation_integration_test.go index 7c4bc7e57..4ef0f5e34 100644 --- a/internal/app/named_isolation_integration_test.go +++ b/internal/app/named_isolation_integration_test.go @@ -376,7 +376,8 @@ func TestIntegrationNamedFinalizeFlowIsolation(t *testing.T) { block.Result, block.Disposition, block.Reason, block.Message, block.Findings) } - pr := f.prForHead(f.head, greenEvidenceFor(t, f.head)) + f.seedGreenRecordEvidence(t, f.head) + pr := f.prForHead(f.head, prBodyNoEvidence) pr.Number = closeoutPR clearGH := &fakeBlockGitHub{repo: retargetRepo(), openByHead: map[string][]githubcli.PullRequest{"feat/" + f.slug: {pr}}} clear := FinalizeClearBlock(ctx, FinalizeDeps{Planning: f.deps, GitHub: clearGH, Workspace: f.svc}, f.repo.invocation, ClearBlockRequest{ @@ -506,9 +507,9 @@ func TestIntegrationNamedFinalizeFlowIsolation(t *testing.T) { mainTip := originTip(t, f.repo.origin, "main") // Every other merge condition holds: the one open canonical PR at the - // exact head, targeting main, carrying green evidence — so only the + // exact head, targeting main, and green record evidence — so only the // relevant-validation guard stands between B and the GitHub merge. - pr := f.prForHead(f.head, greenEvidenceFor(t, f.head)) + pr := f.prForHead(f.head, prBodyNoEvidence) pr.Number = closeoutPR gh := &fakeMergeGitHub{ repo: retargetRepo(), diff --git a/internal/app/run_verify.go b/internal/app/run_verify.go index 0e9ad1ded..5236ddd29 100644 --- a/internal/app/run_verify.go +++ b/internal/app/run_verify.go @@ -77,8 +77,9 @@ const ( // ReasonRunRemoteHeadMismatch: the remote feature ref is absent or names a // commit other than the current local feature head. ReasonRunRemoteHeadMismatch = "remote-head-mismatch" - // ReasonRunEvidenceUnverified: the durable build evidence (the PR body) does not - // verify against the current feature head — missing, malformed, or stale. + // ReasonRunEvidenceUnverified: the durable build evidence (the change record's + // ## Build evidence section) does not verify against the current feature head — + // missing, malformed, or stale. ReasonRunEvidenceUnverified = "evidence-unverified" // ReasonRunPRUnverified: there is not exactly one open PR for the feature branch // naming the current head, targeting the resolved base, and equal to the @@ -302,6 +303,15 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } recordedPR := strings.TrimSpace(c.PR().Value) + // The record's bytes from this one corpus read carry the durable build + // evidence verified against the feature head below. + var record []byte + for _, b := range blobs { + if b.Path == c.Path() { + record = b.Data + break + } + } var unmet []RunVerifyCondition add := func(reason, observed string) { unmet = append(unmet, RunVerifyCondition{Reason: reason, Observed: observed}) @@ -401,9 +411,8 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep add(ReasonRunRemoteHeadMismatch, string(rref.Commit)) } - // PR identity and evidence: exactly one open PR for the feature branch, naming - // the head, targeting the base, and naming the recorded PR number; the PR body - // is the durable evidence store, verified against the head. + // PR identity: exactly one open PR for the feature branch, naming the head, + // targeting the base, and naming the recorded PR number. ghRepo, err := gdeps.Service.DiscoverRepository(ctx, repoDir) if err != nil { return runOperationalRefusal(ResultExternalFailed, ReasonRunRepoUnresolved, err.Error(), req.ID) @@ -414,7 +423,6 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } if len(prs) != 1 { add(ReasonRunPRUnverified, fmt.Sprintf("%d open pull requests for the feature branch", len(prs))) - add(ReasonRunEvidenceUnverified, "no unique pull-request body to read evidence from") } else { pr := prs[0] // Identity is by parsed PR number within the already-resolved repository @@ -431,9 +439,12 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep case !recordedOK || recordedNum != pr.Number: add(ReasonRunPRUnverified, "the open PR is not the one recorded on the change") } - if v := evidence.Verify([]byte(pr.Body), head); v != evidence.VerdictVerified && v != evidence.VerdictSkipped { - add(ReasonRunEvidenceUnverified, string(v)) - } + } + + // Evidence: the change record's build-evidence section, verified against the + // head once, independent of the pull-request probe. + if v := VerifyRecordEvidence(record, head); v != evidence.VerdictVerified && v != evidence.VerdictSkipped { + add(ReasonRunEvidenceUnverified, string(v)) } // Completed-run postconditions take precedence over a stale local handoff: a diff --git a/internal/app/run_verify_integration_test.go b/internal/app/run_verify_integration_test.go index 6f799c2ac..246267f2d 100644 --- a/internal/app/run_verify_integration_test.go +++ b/internal/app/run_verify_integration_test.go @@ -20,8 +20,8 @@ import ( func TestIntegrationEvidenceRunVerifyMissingResultsIsUnmetCondition(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, "", rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, "", rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) if res.Verdict != VerdictRunIncomplete { @@ -39,8 +39,8 @@ func TestIntegrationEvidenceRunVerifyMissingResultsIsUnmetCondition(t *testing.T func TestIntegrationEvidenceRunVerifyInvalidResultsContentIsUnmetCondition(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsInvalidPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsInvalidPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) if res.Verdict != VerdictRunIncomplete { @@ -68,8 +68,8 @@ func TestIntegrationEvidenceRunVerifyInvalidResultsContentIsUnmetCondition(t *te func TestIntegrationEvidenceRunVerifyWaitingSurvivesMissingResults(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, "", "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, "", "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Waiting = fakeWaitingReader{receipt: rvAgreeingReceipt(f.head), found: true} res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) @@ -79,18 +79,63 @@ func TestIntegrationEvidenceRunVerifyWaitingSurvivesMissingResults(t *testing.T) } // TestIntegrationEvidenceRunVerifyAcceptsSkippedEvidenceAtExactHead: a build.gate: off repository's -// PR carries a truthful skipped (build-gate-off) block at the exact feature head. -// run verify's evidence postcondition accepts VerdictSkipped exactly as -// VerdictVerified, so the run is complete. Mirrors TestIntegrationChangeRuntimeRunVerifyComplete -// with a skipped PR-body block substituted. +// change record carries a truthful skipped (build-gate-off) block at the exact +// feature head. run verify's evidence postcondition accepts VerdictSkipped exactly +// as VerdictVerified, so the run is complete. Mirrors +// TestIntegrationChangeRuntimeRunVerifyComplete with a skipped record block substituted. func TestIntegrationEvidenceRunVerifyAcceptsSkippedEvidenceAtExactHead(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prSkippedEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prSkippedEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) if res.Verdict != VerdictRunComplete { - t.Fatalf("verdict = %q, want %q — a skipped PR-body block at the exact head verifies (unmet %v)", res.Verdict, VerdictRunComplete, unmetReasons(res)) + t.Fatalf("verdict = %q, want %q — a skipped record block at the exact head verifies (unmet %v)", res.Verdict, VerdictRunComplete, unmetReasons(res)) + } +} + +// TestIntegrationEvidenceRunVerifyEvidenceReadsRecordNotPR: run verify reads the +// durable build evidence from the change record's "## Build evidence" section +// only. A green block for the exact head in the PR description, with no record +// section, is run-incomplete with exactly evidence-unverified — there is no +// compatibility read of PR-body evidence. +func TestIntegrationEvidenceRunVerifyEvidenceReadsRecordNotPR(t *testing.T) { + f := newRunVerifyFixture(t, true) + deps, wdeps, gdeps := f.deps( + rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), + rvPR(f.head, string(prEvidenceBytes(t, f.head))), + ) + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if res.Verdict != VerdictRunIncomplete { + t.Fatalf("verdict = %q, want %q (PR-body evidence must not certify; unmet %v)", res.Verdict, VerdictRunIncomplete, unmetReasons(res)) + } + if got := unmetReasons(res); len(got) != 1 || got[0] != ReasonRunEvidenceUnverified { + t.Fatalf("unmet = %v, want exactly [%s]", got, ReasonRunEvidenceUnverified) + } +} + +// TestIntegrationEvidenceRunVerifyEvidenceIndependentOfPRCount: with no open PR +// the record's evidence is still evaluated on its own — a verified record +// section adds no evidence-unverified condition beside pr-unverified, and a +// missing one adds exactly one. +func TestIntegrationEvidenceRunVerifyEvidenceIndependentOfPRCount(t *testing.T) { + f := newRunVerifyFixture(t, true) + for _, tc := range []struct { + name string + record []byte + want []string + }{ + {"verified record", rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), []string{ReasonRunPRUnverified}}, + {"no record evidence", rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), []string{ReasonRunEvidenceUnverified, ReasonRunPRUnverified}}, + } { + t.Run(tc.name, func(t *testing.T) { + deps, wdeps, _ := f.deps(tc.record, rvPR(f.head, prBodyNoEvidence)) + gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo()}} + res := RunVerify(context.Background(), deps, wdeps, gdeps, f.repo.invocation, RunVerifyRequest{ID: 3}) + if got := unmetReasons(res); strings.Join(got, ",") != strings.Join(tc.want, ",") { + t.Fatalf("unmet = %v, want %v", got, tc.want) + } + }) } } diff --git a/internal/app/run_verify_test.go b/internal/app/run_verify_test.go index 26f5929c1..ac64357a7 100644 --- a/internal/app/run_verify_test.go +++ b/internal/app/run_verify_test.go @@ -3,6 +3,7 @@ package app import ( "context" "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/gatedrive" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" @@ -63,6 +64,22 @@ func rvRecord(plan, results, pr, branch string) []byte { return []byte(src) } +// rvWithEvidence returns record with the build-evidence block block (as +// prEvidenceBytes / prSkippedEvidenceBytes render it) as its "## Build evidence" +// section — the durable evidence run verify reads. +func rvWithEvidence(t *testing.T, record, block []byte) []byte { + t.Helper() + rec, err := evidence.Extract(block) + if err != nil { + t.Fatalf("evidence.Extract: %v", err) + } + out, err := UpsertRecordEvidence(record, rec) + if err != nil { + t.Fatalf("UpsertRecordEvidence: %v", err) + } + return out +} + // rvPR is the single open PR the fake adapter reports for the feature branch. func rvPR(head, body string) githubcli.PullRequest { return githubcli.PullRequest{ @@ -222,8 +239,8 @@ func rvAgreeingReceipt(head string) WaitingReceipt { func rvWaitingDeps(t *testing.T, f *rvFixture, reader WaitingReceiptReader) (PlanningDeps, WorkspaceDeps, GitHubDeps) { t.Helper() deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Waiting = reader return deps, wdeps, gdeps diff --git a/internal/app/runtracker_fence_integration_test.go b/internal/app/runtracker_fence_integration_test.go index 8d41298d6..bd24a529a 100644 --- a/internal/app/runtracker_fence_integration_test.go +++ b/internal/app/runtracker_fence_integration_test.go @@ -398,8 +398,8 @@ func TestIntegrationRunFenceFreshRunClaimBindsRunWorktreeSoFenceActs(t *testing. func TestIntegrationRunFenceVerdictUnconfirmedRecoveryBindsRunWorktreeSoFenceActs(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) repo := f.repo.invocation key := runTrackerMintStarted(t, repo, nil, 1, "ha") @@ -499,8 +499,8 @@ func TestIntegrationRunFenceVerdictUnconfirmedRecoveryBindsRunWorktreeSoFenceAct func TestIntegrationRunFenceVerdictSoleProofAdoptionBindsRunWorktreeSoFenceActs(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) repo := f.repo.invocation key := runTrackerMintStarted(t, repo, nil, 1, "ha") diff --git a/internal/app/runtracker_ownership_integration_test.go b/internal/app/runtracker_ownership_integration_test.go index f0747484c..97fc53539 100644 --- a/internal/app/runtracker_ownership_integration_test.go +++ b/internal/app/runtracker_ownership_integration_test.go @@ -84,12 +84,12 @@ func mxDeps(t *testing.T, f *rvFixture, id int, slug string, record []byte) (Pla t.Helper() reader := &fakeReader{ pin: f.pin, - corpus: []StatusBlob{mxBlob(id, slug, record)}, + corpus: []StatusBlob{mxBlob(id, slug, rvWithEvidence(t, record, prEvidenceBytes(t, f.head)))}, facts: domain.NewBranchFacts(nil), } deps := PlanningDeps{Client: f.client, Reader: reader, Clock: testClock()} wdeps := WorkspaceDeps{Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}} - gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo(), probePRs: []githubcli.PullRequest{rvPR(f.head, string(prEvidenceBytes(t, f.head)))}}} + gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo(), probePRs: []githubcli.PullRequest{rvPR(f.head, prBodyNoEvidence)}}} return deps, wdeps, gdeps } @@ -225,12 +225,13 @@ func TestIntegrationRunFenceUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}, ClaimProofs: &fakeProofScanner{proofs: proofs}, } - gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo(), probePRs: []githubcli.PullRequest{rvPR(f.head, string(prEvidenceBytes(t, f.head)))}}} + gdeps := GitHubDeps{Service: &fakeGitHub{repo: prRepo(), probePRs: []githubcli.PullRequest{rvPR(f.head, prBodyNoEvidence)}}} return RunVerdict(context.Background(), deps, wdeps, gdeps, f.repo.invocation, key) } baseProof := ClaimProof{RequestID: "claim-3-v", ChangeID: 3, RunContextHash: "ha", Revision: "rA"} - baseCorpus := []StatusBlob{mxBlob(3, "widget", mxImplementedRecord(3, "widget"))} + evidenced := rvWithEvidence(t, mxImplementedRecord(3, "widget"), prEvidenceBytes(t, f.head)) + baseCorpus := []StatusBlob{mxBlob(3, "widget", evidenced)} res1 := verdict([]ClaimProof{baseProof}, baseCorpus) if got, want := res1.HumanText(), "run-done "+key+" run-complete 3"; got != want { @@ -240,7 +241,7 @@ func TestIntegrationRunFenceUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { // Refresh id 3's claimed_at and priority (a real refresh-claim / groom edit // leaves the change complete), and add two sibling in-progress claims — exactly // the churn the pre-0407 before-set/run inference keyed on. - refreshed := string(mxImplementedRecord(3, "widget")) + refreshed := string(evidenced) refreshed = strings.Replace(refreshed, "claimed_at: 2026-08-02T00:00:00Z", "claimed_at: 2026-09-05T00:00:00Z", 1) refreshed = strings.Replace(refreshed, "priority: medium", "priority: high", 1) churnedCorpus := []StatusBlob{ diff --git a/internal/app/runtracker_verdict_helpers_test.go b/internal/app/runtracker_verdict_helpers_test.go index 270f47376..8531475ad 100644 --- a/internal/app/runtracker_verdict_helpers_test.go +++ b/internal/app/runtracker_verdict_helpers_test.go @@ -124,8 +124,8 @@ func newVerdictCompletionFixture(t *testing.T) verdictCompletionFixture { t.Helper() f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) repo := f.repo.invocation common, err := runTrackerGitCommonDir(repo) diff --git a/internal/app/runtracker_verdict_integration_test.go b/internal/app/runtracker_verdict_integration_test.go index 96e497178..5b36332de 100644 --- a/internal/app/runtracker_verdict_integration_test.go +++ b/internal/app/runtracker_verdict_integration_test.go @@ -291,8 +291,8 @@ func TestIntegrationRunVerdictVerdictIncompleteRespectsAttemptLimit(t *testing.T t.Run(fmt.Sprintf("limit-%d", tc.limit), func(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributedLimit(t, f.repo.invocation, 3, tc.limit) @@ -334,8 +334,8 @@ func TestIntegrationRunVerdictVerdictIncompleteRespectsAttemptLimit(t *testing.T func TestIntegrationRunVerdictVerdictIncompleteRepeatObservationDoesNotDoubleGrant(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributedLimit(t, f.repo.invocation, 3, 2) @@ -362,8 +362,8 @@ func TestIntegrationRunVerdictVerdictIncompleteRepeatObservationDoesNotDoubleGra func TestIntegrationRunVerdictVerdictIncompleteNoGrantLeavesRetryMirrorUnused(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributedLimit(t, f.repo.invocation, 3, 1) @@ -520,8 +520,8 @@ func TestIntegrationRunVerdictVerdictIncompleteWithTrackedDriveContinuesWithoutR func TestIntegrationRunVerdictVerdictIncompleteQuiescentStillRetriesOnce(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Continuation = &fakeContinuationSeam{candidates: nil} // zero candidates key := runTrackerMintAttributedScoped(t, f.repo.invocation, "scope-1", "pcap-1", "ctxhash-1", 3) @@ -549,8 +549,8 @@ func TestIntegrationRunVerdictVerdictIncompleteQuiescentStillRetriesOnce(t *test func TestIntegrationRunVerdictVerdictAmbiguousDrivesStops(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Continuation = &fakeContinuationSeam{candidates: []string{"a", "b"}} key := runTrackerMintAttributedScoped(t, f.repo.invocation, "scope-1", "pcap-1", "ctxhash-1", 3) @@ -575,8 +575,8 @@ func TestIntegrationRunVerdictVerdictAmbiguousDrivesStops(t *testing.T) { func TestIntegrationRunVerdictVerdictTakeoverHaltStops(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) wdeps.Continuation = &fakeContinuationSeam{ candidates: []string{"d0opaque"}, @@ -633,8 +633,8 @@ func TestIntegrationRunVerdictVerdictContinueNeverAuthorizesNewClaim(t *testing. func TestIntegrationRunVerdictVerdictFreshRunBindsScopeChange(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvInProgressRecord(rvPlanPath, rvResultsPath, "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) seam := &fakeContinuationSeam{} // no candidates: ownership adopts the sole proof, // then the run-incomplete path finds zero tracked drives and takes the ordinary @@ -696,8 +696,8 @@ func TestIntegrationRunVerdictVerdictContinuationDoesNotRebindScope(t *testing.T func TestIntegrationRunVerdictVerdictObservePathStillCannotContinue(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) res := RunVerdictObserve(context.Background(), deps, wdeps, gdeps, f.repo.invocation, []string{"3"}) line := res.HumanText() @@ -728,8 +728,8 @@ func TestIntegrationRunVerdictVerdictObservePathStillCannotContinue(t *testing.T func TestIntegrationRunVerdictVerdictConfirmedBindingResolvesBoundChange(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintStarted(t, f.repo.invocation, nil, 1, "ha") if err := ReserveRunTrackerClaim(f.repo.invocation, key, 3, "claim-3-v"); err != nil { @@ -786,8 +786,8 @@ func TestIntegrationRunVerdictVerdictNoBindingNoProofIsNoAttributableClaim(t *te func TestIntegrationRunVerdictVerdictUnconfirmedReservationRecoversFromExactReceipt(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintStarted(t, f.repo.invocation, nil, 1, "ha") if err := ReserveRunTrackerClaim(f.repo.invocation, key, 3, "claim-3-v"); err != nil { @@ -864,8 +864,8 @@ func TestIntegrationRunVerdictVerdictAbsentBindingAdoptsSoleProof(t *testing.T) t.Run("sole proof adopted", func(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintStarted(t, f.repo.invocation, nil, 1, "ha") wdeps.ClaimProofs = &fakeProofScanner{proofs: []ClaimProof{ @@ -1001,8 +1001,8 @@ func TestIntegrationRunVerdictVerdictCorruptBindingFailsClosed(t *testing.T) { func TestIntegrationRunVerdictVerdictResumeBindingSkipsContinuity(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintAttributed(t, f.repo.invocation, 3) wdeps.ClaimProofs = &fakeProofScanner{proofs: []ClaimProof{ @@ -1077,8 +1077,8 @@ func TestIntegrationRunVerdictVerdictRunCompleteClosesOutRunOwnership(t *testing func TestIntegrationRunVerdictVerdictRunCompleteWithoutRunUnchanged(t *testing.T) { f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, rvRecordedPR(), "feat/"+rvSlug), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) key := runTrackerMintStarted(t, f.repo.invocation, nil, 1, "ha") if err := ReserveRunTrackerClaim(f.repo.invocation, key, 3, "claim-3-v"); err != nil { @@ -1366,8 +1366,8 @@ func TestIntegrationRunVerdictNeverLaunchedDriveEarnsRetry(t *testing.T) { requireProcessSupervisorHere(t) f := newRunVerifyFixture(t, true) deps, wdeps, gdeps := f.deps( - runTrackerIncompleteRecord(), - rvPR(f.head, string(prEvidenceBytes(t, f.head))), + rvWithEvidence(t, runTrackerIncompleteRecord(), prEvidenceBytes(t, f.head)), + rvPR(f.head, prBodyNoEvidence), ) repo := f.repo.invocation common, err := runTrackerGitCommonDir(repo) diff --git a/internal/app/workspace_ops.go b/internal/app/workspace_ops.go index c44baccb2..e017b218c 100644 --- a/internal/app/workspace_ops.go +++ b/internal/app/workspace_ops.go @@ -167,8 +167,11 @@ func newWorkspaceResult(opKey string, result Result, out WorkspaceOpResult) Work type workspaceContext struct { change domain.Change revision string - base domain.EffectiveBase - repo gitcli.Repository + // record is the change record's bytes from the same corpus read that yields + // revision, so a caller decides and acts on one copy of the record. + record []byte + base domain.EffectiveBase + repo gitcli.Repository // snap is the authoritative corpus snapshot the context was resolved from. The // carried-descendant preservation gate (proveCarriedOnHead) reads the live // stacked_on graph from it, so a caller need not rebuild the snapshot. @@ -218,9 +221,10 @@ func loadWorkspaceContext(ctx context.Context, deps PlanningDeps, repoDir string } revision := "" + var record []byte for _, b := range blobs { if b.Path == c.Path() { - revision = b.Revision + revision, record = b.Revision, b.Data break } } @@ -242,6 +246,7 @@ func loadWorkspaceContext(ctx context.Context, deps PlanningDeps, repoDir string return workspaceContext{ change: c, revision: revision, + record: record, base: domain.ResolveEffectiveBase(snap, c, facts), repo: repo, snap: snap, From b409cffeb24b2f86ac64e656ae1e17ba1a2a8cbb Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Tue, 6 Oct 2026 00:55:02 -0400 Subject: [PATCH 06/28] feat(pr): build evidence no longer rides the PR description pr publish keeps verifying --evidence against the head but weaves only the backlink block into the PR body (assemblePRBody drops its record parameter); an authored build-evidence block is preserved as authored bytes. finalize publish and evidence recertify no longer edit the PR: after the PR reprobe the change record's "## Build evidence" write is the publication (publishResultFromRecord replaces publishResultFromEnsure). Removed finalizePublishEnsurer, mapPublishEnsureFailure, mapRecertifyEnsureFailure, and the reasons body-assembly-failed (publish/recertify), pr-editor-unavailable, pr-edit-contended, and pr-edit-unknown. evidence.recertify is metadata-write only. Test changes: - EvidenceRecertifyEditContended -> RecordContended (the record moved under the write). - EvidenceRecertifyEditFailureThenRetry -> RecordFailureThenRetry (a failed record write, then convergence). - Deleted EvidenceRecertifyEditUnrecognizedDisposition: it only exercised the PR-edit disposition mapping's default arm, which no longer exists. - Deleted the dispositionEnsureGitHub and flakyEnsureGitHub fakes and fakePublishGitHub.lastEnsuredBody (no PR edit left to drive). --- internal/app/change_integration_test.go | 98 ++++---- internal/app/evidence_recertify.go | 144 +++-------- .../evidence_recertify_integration_test.go | 171 ++++++------- internal/app/finalize_e2e_test.go | 10 +- internal/app/finalize_git_test.go | 26 +- internal/app/finalize_publish.go | 235 +++++------------- internal/app/finalize_publish_helpers_test.go | 22 +- .../app/finalize_publish_integration_test.go | 39 +-- internal/app/finalize_rebase.go | 6 +- .../app/finalize_state_integration_test.go | 84 +++---- internal/app/pr_backlink_repoint.go | 4 +- internal/app/pr_publish.go | 50 ++-- internal/app/pr_publish_integration_test.go | 21 +- .../app/runtracker_fence_integration_test.go | 2 +- internal/cli/evidence.go | 13 +- internal/cli/finalize.go | 9 +- internal/cli/pr.go | 9 +- 17 files changed, 347 insertions(+), 596 deletions(-) diff --git a/internal/app/change_integration_test.go b/internal/app/change_integration_test.go index e9853d17b..3ee1bc6f1 100644 --- a/internal/app/change_integration_test.go +++ b/internal/app/change_integration_test.go @@ -2274,63 +2274,71 @@ func TestIntegrationChangeRuntimePRPublishAgreementChecks(t *testing.T) { func TestIntegrationChangeRuntimePRPublishBodyAssembly(t *testing.T) { repoDir := newWorkingRepo(t, nil).invocation reader := prReader(t) - - // Authored prose already carrying a STALE build-evidence block. Publishing - // must preserve the prose, replace the evidence block deterministically, and - // insert the backlink exactly once. - staleRec, _ := evidence.NewRecord("old command", prOtherHead, time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) - authored := "Authored prose before.\n\n" + evidence.Render(staleRec) + "\n\nAuthored prose after.\n" - - gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} - deps := workspaceDepsFor(t, reader) - res := PRPublish(context.Background(), deps, WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, - repoDir, PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: authored, EvidenceRecord: prEvidenceBytes(t, prHead)}) - if res.Result != ResultApplied { - t.Fatalf("result = %q, want applied (reason %q)", res.Result, res.Reason) - } - if len(gh.ensureCalls) != 1 { - t.Fatalf("EnsurePullRequest called %d times, want 1", len(gh.ensureCalls)) - } - gotBody := gh.ensureCalls[0].Body - - // Independent golden: parse authored, insert the backlink at the top, upsert - // the fresh evidence — the exact deterministic composition the operation owns. change := prSnapshotChange(t, reader, 7) backlink, err := render.BacklinkContent(change, render.LinkContext{MetadataBranch: "main"}) if err != nil { t.Fatalf("BacklinkContent: %v", err) } - doc, err := document.Parse([]byte(authored)) - if err != nil { - t.Fatalf("parse authored: %v", err) - } - var ps document.PatchSet - ps.InsertBlock("backlink", "generated — do not hand-edit", backlinkInterior(backlink), document.AtDocumentStart) - withBacklink, err := doc.Apply(ps) - if err != nil { - t.Fatalf("insert backlink: %v", err) - } - freshRec, _ := evidence.NewRecord("go test ./...", prHead, time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)) - wantBody, err := evidence.Upsert(withBacklink, freshRec) - if err != nil { - t.Fatalf("upsert evidence: %v", err) - } - if gotBody != string(wantBody) { - t.Fatalf("assembled body mismatch:\n got: %q\nwant: %q", gotBody, string(wantBody)) + // withBacklink is the independent golden: parse authored and insert the + // backlink at the top — the only managed block `pr publish` owns. Build + // evidence never rides the PR description; its home is the change record. + withBacklink := func(authored string) string { + t.Helper() + doc, err := document.Parse([]byte(authored)) + if err != nil { + t.Fatalf("parse authored: %v", err) + } + var ps document.PatchSet + ps.InsertBlock("backlink", "generated — do not hand-edit", backlinkInterior(backlink), document.AtDocumentStart) + out, err := doc.Apply(ps) + if err != nil { + t.Fatalf("insert backlink: %v", err) + } + return string(out) + } + publish := func(authored string) string { + t.Helper() + gh := &fakeGitHub{repo: prRepo(), ensureRes: githubcli.EnsureResult{Disposition: githubcli.EnsureCreated, PR: prMatchPR("verified")}} + deps := workspaceDepsFor(t, reader) + res := PRPublish(context.Background(), deps, WorkspaceDeps{Service: readyService(prHead)}, GitHubDeps{Service: gh}, + repoDir, PRPublishRequest{ID: 7, Head: prHead, Title: "Add widget", Body: authored, EvidenceRecord: prEvidenceBytes(t, prHead)}) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied (reason %q)", res.Result, res.Reason) + } + if len(gh.ensureCalls) != 1 { + t.Fatalf("EnsurePullRequest called %d times, want 1", len(gh.ensureCalls)) + } + return gh.ensureCalls[0].Body } - // Spot invariants that the full-byte golden also encodes. - if !strings.Contains(gotBody, "Authored prose before.") || !strings.Contains(gotBody, "Authored prose after.") { - t.Errorf("authored prose not preserved: %q", gotBody) + // Plain authored prose: the body is the prose plus the backlink block, and it + // carries no build-evidence block at all. + plain := "Authored prose before.\n\nAuthored prose after.\n" + gotBody := publish(plain) + if want := withBacklink(plain); gotBody != want { + t.Fatalf("assembled body mismatch:\n got: %q\nwant: %q", gotBody, want) + } + if _, err := evidence.Extract([]byte(gotBody)); !errors.Is(err, evidence.ErrMissing) { + t.Fatalf("evidence.Extract(body) err = %v, want evidence.ErrMissing (no evidence rides the PR body)", err) } if strings.Count(gotBody, "\n" + + "> ↩ **[Change 0031 — A change](https://example.invalid/blob/docket/docs/changes/active/0031-copyme.md)**\n" + + "\n\n" + + "# Copy me: design\n\n" + body +} + +// commitSpecRecord is change 31 in-progress, its spec: field set to specPath +// ("" leaves it empty — a trivial change). +func commitSpecRecord(specPath string) string { + rec := lifecycleChange(31, "copyme", "in-progress") + if specPath != "" { + rec = strings.Replace(rec, "spec:\n", "spec: "+specPath+"\n", 1) + } + return rec +} + +// commitSpecEnv is one prepared change-31 workspace over a real docket-mode repo. +type commitSpecEnv struct { + repo *gitRepo + deps PlanningDeps + wdeps WorkspaceDeps + workspace string + base string +} + +// newCommitSpecEnv builds a docket-mode repo (mainFiles on main, records on +// docket), prepares change 31's workspace, and returns it. +func newCommitSpecEnv(t *testing.T, mainFiles, docketRecords map[string]string) commitSpecEnv { + t.Helper() + repo := newDocketModeRepo(t, mainFiles, docketRecords) + recPath := groomPath(31, "copyme") + revision := blobRevisionAt(t, repo.origin, "docket", recPath) + deps := planningDepsFor(t, repo.invocation).deps + svc, err := workspace.NewService(deps.Client) + if err != nil { + t.Fatalf("workspace.NewService: %v", err) + } + wdeps := WorkspaceDeps{Service: svc} + prep := WorkspacePrepare(context.Background(), deps, wdeps, repo.invocation, WorkspaceIDRequest{ID: 31, Revision: revision}) + if prep.Result != ResultApplied { + t.Fatalf("prepare = %q (reason %q msg %q)", prep.Result, prep.Reason, prep.Message) + } + return commitSpecEnv{repo: repo, deps: deps, wdeps: wdeps, workspace: prep.Path, base: prep.BaseCommit} +} + +func (e commitSpecEnv) commitSpec(t *testing.T) WorkspaceOpResult { + t.Helper() + return WorkspaceCommitSpec(context.Background(), e.deps, e.wdeps, e.repo.invocation, WorkspaceIDRequest{ID: 31}) +} + +func (e commitSpecEnv) head(t *testing.T) string { + t.Helper() + return runGit(t, e.workspace, "rev-parse", "HEAD") +} + +// commitsSinceBase is the workspace's commit list since the prepared base, +// oldest first. +func (e commitSpecEnv) commitsSinceBase(t *testing.T) []string { + t.Helper() + out := runGit(t, e.workspace, "rev-list", "--reverse", e.base+"..HEAD") + if out == "" { + return nil + } + return strings.Split(out, "\n") +} + +// assertClean fails unless the workspace has no tracked, staged, or untracked change. +func (e commitSpecEnv) assertClean(t *testing.T) { + t.Helper() + if st := runGit(t, e.workspace, "status", "--porcelain", "--untracked-files=all"); st != "" { + t.Fatalf("workspace not clean after commit-spec:\n%s", st) + } +} + +// expectedSpecCopy is the copy specCopyBytes renders for change 31 from the +// metadata spec bytes. +func expectedSpecCopy(t *testing.T, metadataSpec string) string { + t.Helper() + snap := snapshotOf(t, []StatusBlob{{ + 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)) + if err != nil { + t.Fatalf("specCopyBytes: %v", err) + } + return string(want) +} + +// TestIntegrationWorkflowLifecycleCommitSpecFirstCommit: the spec copy is the +// feature branch's first commit — exactly one commit, one path, the rendered +// copy's bytes, a clean worktree — and a second call is a no-op. +func TestIntegrationWorkflowLifecycleCommitSpecFirstCommit(t *testing.T) { + spec := commitSpecMetadataSpec("Body.\n") + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: spec, + }) + + res := env.commitSpec(t) + if res.Result != ResultApplied || res.Disposition != SpecCopyCommitted { + t.Fatalf("commit-spec = (%q, %q), want applied/committed (reason %q msg %q)", res.Result, res.Disposition, res.Reason, res.Message) + } + commits := env.commitsSinceBase(t) + if len(commits) != 1 { + t.Fatalf("commits since base = %v, want exactly one", commits) + } + if res.Head != commits[0] || env.head(t) != commits[0] { + t.Fatalf("reported head %q, workspace HEAD %q, want the new commit %q", res.Head, env.head(t), commits[0]) + } + if res.Path != commitSpecPath { + t.Errorf("result path = %q, want %q", res.Path, commitSpecPath) + } + if subj := runGit(t, env.workspace, "log", "-1", "--format=%s", commits[0]); subj != "Add design spec: A change" { + t.Errorf("subject = %q, want %q", subj, "Add design spec: A change") + } + if paths := runGit(t, env.workspace, "diff-tree", "--no-commit-id", "--name-only", "-r", commits[0]); paths != commitSpecPath { + t.Errorf("commit paths = %q, want only %q", paths, commitSpecPath) + } + got, err := os.ReadFile(filepath.Join(env.workspace, filepath.FromSlash(commitSpecPath))) + if err != nil { + t.Fatal(err) + } + if want := expectedSpecCopy(t, spec); string(got) != want { + t.Fatalf("spec copy bytes:\n%q\nwant\n%q", got, want) + } + if strings.Contains(string(got), "docket:backlink") { + t.Fatalf("the metadata backlink rode into the copy:\n%s", got) + } + env.assertClean(t) + + again := env.commitSpec(t) + if again.Result != ResultNoOp || again.Disposition != SpecCopyCurrent { + t.Fatalf("second commit-spec = (%q, %q), want no-op/current (reason %q)", again.Result, again.Disposition, again.Reason) + } + if env.head(t) != commits[0] || again.Head != commits[0] { + t.Fatalf("a current copy moved the head: %q (reported %q), want %q", env.head(t), again.Head, commits[0]) + } +} + +// TestIntegrationWorkflowLifecycleCommitSpecRefreshesRevisedSpec: a spec revised +// on the metadata branch after the first copy refreshes it with a second commit. +func TestIntegrationWorkflowLifecycleCommitSpecRefreshesRevisedSpec(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: commitSpecMetadataSpec("Body.\n"), + }) + if res := env.commitSpec(t); res.Disposition != SpecCopyCommitted { + t.Fatalf("first commit-spec = (%q, %q), want committed (reason %q msg %q)", res.Result, res.Disposition, res.Reason, res.Message) + } + + revised := commitSpecMetadataSpec("Revised body.\n") + env.repo.writerAdvance(t, "docket", map[string]string{commitSpecPath: revised}) + + res := env.commitSpec(t) + if res.Result != ResultApplied || res.Disposition != SpecCopyRefreshed { + t.Fatalf("refresh = (%q, %q), want applied/refreshed (reason %q msg %q)", res.Result, res.Disposition, res.Reason, res.Message) + } + commits := env.commitsSinceBase(t) + if len(commits) != 2 || res.Head != commits[1] { + t.Fatalf("commits since base = %v (reported head %q), want two ending at the refresh", commits, res.Head) + } + if subj := runGit(t, env.workspace, "log", "-1", "--format=%s", commits[1]); subj != "Update design spec: A change" { + t.Errorf("subject = %q, want %q", subj, "Update design spec: A change") + } + got, err := os.ReadFile(filepath.Join(env.workspace, filepath.FromSlash(commitSpecPath))) + if err != nil { + t.Fatal(err) + } + if want := expectedSpecCopy(t, revised); string(got) != want { + t.Fatalf("refreshed copy:\n%q\nwant\n%q", got, want) + } + env.assertClean(t) +} + +// TestIntegrationWorkflowLifecycleCommitSpecTrivialNoSpec: a change with no +// spec has no copy — a no-op that commits nothing. +func TestIntegrationWorkflowLifecycleCommitSpecTrivialNoSpec(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(""), + }) + before := env.head(t) + res := env.commitSpec(t) + if res.Result != ResultNoOp || res.Disposition != SpecCopyNoSpec { + t.Fatalf("commit-spec = (%q, %q), want no-op/no-spec (reason %q)", res.Result, res.Disposition, res.Reason) + } + if env.head(t) != before { + t.Fatalf("a no-spec change moved the head") + } +} + +// TestIntegrationWorkflowLifecycleCommitSpecRefusesDirtyWorkspace: an untracked +// file makes the workspace not ready — refused, nothing committed, the file +// untouched. +func TestIntegrationWorkflowLifecycleCommitSpecRefusesDirtyWorkspace(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: commitSpecMetadataSpec("Body.\n"), + }) + before := env.head(t) + writeRepoFile(t, env.workspace, "scratch.txt", "local work\n") + + res := env.commitSpec(t) + if res.Result != ResultInvalidState || res.Reason != ReasonSpecCopyNotReady { + t.Fatalf("commit-spec = (%q, %q), want invalid-state/%s (msg %q)", res.Result, res.Reason, ReasonSpecCopyNotReady, res.Message) + } + if strings.Contains(res.Message, "scratch.txt") { + t.Errorf("refusal message names a dirty path: %q", res.Message) + } + if env.head(t) != before { + t.Fatalf("a refused commit-spec moved the head") + } + if got, err := os.ReadFile(filepath.Join(env.workspace, "scratch.txt")); err != nil || string(got) != "local work\n" { + t.Fatalf("untracked file disturbed: %q, %v", got, err) + } + if _, err := os.Stat(filepath.Join(env.workspace, filepath.FromSlash(commitSpecPath))); !os.IsNotExist(err) { + t.Fatalf("a refused commit-spec wrote the spec copy (stat err %v)", err) + } +} + +// TestIntegrationWorkflowLifecycleCommitSpecRefusesOccupiedIntegrationPath: the +// integration branch already holds different bytes at the spec path — refused, +// nothing committed. +func TestIntegrationWorkflowLifecycleCommitSpecRefusesOccupiedIntegrationPath(t *testing.T) { + env := newCommitSpecEnv(t, map[string]string{ + commitSpecPath: "# Someone else's file\n", + }, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: commitSpecMetadataSpec("Body.\n"), + }) + before := env.head(t) + res := env.commitSpec(t) + if res.Result != ResultInvalidState || res.Reason != ReasonSpecCopyPathOccupied { + t.Fatalf("commit-spec = (%q, %q), want invalid-state/%s (msg %q)", res.Result, res.Reason, ReasonSpecCopyPathOccupied, res.Message) + } + if env.head(t) != before { + t.Fatalf("a refused commit-spec moved the head") + } + env.assertClean(t) +} + +// TestIntegrationWorkflowLifecycleCommitSpecMissingSpecFile: spec: names a path +// absent on the metadata branch — refused spec-missing, nothing committed. +func TestIntegrationWorkflowLifecycleCommitSpecMissingSpecFile(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + }) + before := env.head(t) + res := env.commitSpec(t) + if res.Result != ResultInvalidState || res.Reason != ReasonSpecCopyMissing { + t.Fatalf("commit-spec = (%q, %q), want invalid-state/%s (msg %q)", res.Result, res.Reason, ReasonSpecCopyMissing, res.Message) + } + if env.head(t) != before { + t.Fatalf("a refused commit-spec moved the head") + } +} + +// TestIntegrationWorkflowLifecycleCommitSpecRefusesMalformedSpec: a metadata +// spec whose backlink markers do not parse is refused spec-malformed — never +// copied with a dangling marker. +func TestIntegrationWorkflowLifecycleCommitSpecRefusesMalformedSpec(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: "\n# Copy me\n", + }) + before := env.head(t) + res := env.commitSpec(t) + if res.Result != ResultInvalidState || res.Reason != ReasonSpecCopyMalformed { + t.Fatalf("commit-spec = (%q, %q), want invalid-state/%s (msg %q)", res.Result, res.Reason, ReasonSpecCopyMalformed, res.Message) + } + if env.head(t) != before { + t.Fatalf("a refused commit-spec moved the head") + } + env.assertClean(t) +} + +// staleInspectService reports a ready workspace at a fixed, stale head — the +// view of a caller that inspected before another commit landed on the branch. +type staleInspectService struct { + WorkspaceService + head gitcli.ObjectID +} + +func (s staleInspectService) Inspect(ctx context.Context, req workspace.InspectRequest) (workspace.Inspection, error) { + insp, err := s.WorkspaceService.Inspect(ctx, req) + insp.HeadCommit = s.head + return insp, err +} + +// TestIntegrationWorkflowLifecycleCommitSpecRefusesMovedBranch: a branch that +// moved past the inspected head is never overwritten — the in-place +// fast-forward's compare-and-swap refuses, the result is workspace-not-ready, +// and the branch, its newer commit, and the worktree are untouched. +func TestIntegrationWorkflowLifecycleCommitSpecRefusesMovedBranch(t *testing.T) { + env := newCommitSpecEnv(t, nil, map[string]string{ + groomPath(31, "copyme"): commitSpecRecord(commitSpecPath), + commitSpecPath: commitSpecMetadataSpec("Body.\n"), + }) + writeRepoFile(t, env.workspace, "feature.txt", "newer work\n") + runGit(t, env.workspace, "add", "feature.txt") + runGit(t, env.workspace, "commit", "-q", "-m", "newer work") + moved := env.head(t) + + stale := env + stale.wdeps = WorkspaceDeps{Service: staleInspectService{WorkspaceService: env.wdeps.Service, head: gitcli.ObjectID(env.base)}} + res := stale.commitSpec(t) + if res.Result != ResultInvalidState || res.Reason != ReasonSpecCopyNotReady { + t.Fatalf("commit-spec over a moved branch = (%q, %q), want invalid-state/%s (msg %q)", res.Result, res.Reason, ReasonSpecCopyNotReady, res.Message) + } + if env.head(t) != moved { + t.Fatalf("the moved branch was rewritten: HEAD %q, want %q", env.head(t), moved) + } + if _, err := os.Stat(filepath.Join(env.workspace, filepath.FromSlash(commitSpecPath))); !os.IsNotExist(err) { + t.Fatalf("a refused commit-spec wrote the spec copy (stat err %v)", err) + } + env.assertClean(t) +} diff --git a/internal/app/workspace_ops.go b/internal/app/workspace_ops.go index e017b218c..5935050cd 100644 --- a/internal/app/workspace_ops.go +++ b/internal/app/workspace_ops.go @@ -11,7 +11,8 @@ import ( ) // This file is the `workspace prepare`, `workspace inspect`, and -// `workspace publish` operations: the thin app-layer wiring that reloads the +// `workspace publish` operations (`workspace commit-spec` lives in +// workspace_spec.go): the thin app-layer wiring that reloads the // authoritative snapshot, resolves the change's effective base through the // domain, builds a validated workspace.Target, and delegates the Git mechanics // to the landed workspace.Service. The app layer decides NO base-selection or @@ -172,6 +173,10 @@ type workspaceContext struct { record []byte base domain.EffectiveBase repo gitcli.Repository + // pin is the authoritative context the corpus was read under, so a caller + // reads an artifact (the metadata spec, the integration branch's copy) from + // the same pinned tips the change was resolved from. + pin StatusPin // snap is the authoritative corpus snapshot the context was resolved from. The // carried-descendant preservation gate (proveCarriedOnHead) reads the live // stacked_on graph from it, so a caller need not rebuild the snapshot. @@ -249,6 +254,7 @@ func loadWorkspaceContext(ctx context.Context, deps PlanningDeps, repoDir string record: record, base: domain.ResolveEffectiveBase(snap, c, facts), repo: repo, + pin: pin, snap: snap, }, nil } @@ -463,8 +469,15 @@ func WorkspacePublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDep // accurate reason-aware message (via fenceRefusalReasonMessage), and no push. It // carries no credential — only the bounded reason token. func workspaceFenceRefusal(id int, ferr error) WorkspaceOpResult { + return workspaceFenceRefusalFor(OperationWorkspacePublish, id, ferr) +} + +// workspaceFenceRefusalFor is workspaceFenceRefusal for any workspace operation +// that admits through the run mutation fence (publish, commit-spec): the +// refusal is stamped with that operation's key. +func workspaceFenceRefusalFor(opKey string, id int, ferr error) WorkspaceOpResult { reason, message := fenceRefusalReasonMessage(ferr, "workspace") - return newWorkspaceResult(OperationWorkspacePublish, ResultBlocked, WorkspaceOpResult{ + return newWorkspaceResult(opKey, ResultBlocked, WorkspaceOpResult{ ID: id, Reason: reason, Message: message, diff --git a/internal/app/workspace_spec.go b/internal/app/workspace_spec.go new file mode 100644 index 000000000..562133e0d --- /dev/null +++ b/internal/app/workspace_spec.go @@ -0,0 +1,313 @@ +package app + +import ( + "bytes" + "context" + "fmt" + "strings" + + "github.com/danielhanold/docket/internal/document" + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/gitcli" + "github.com/danielhanold/docket/internal/workspace" +) + +// This file is the `workspace commit-spec` operation: it commits a copy of the +// 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 +// exact copy is a no-op — and a revised metadata spec refreshes the copy with a +// second commit. +// +// The commit is built from the feature head's own tree (never the worktree +// index), and the checked-out branch then moves to it by compare-and-swap from +// the inspected head (gitcli.FastForwardCheckedOutBranch). Nothing in the +// worktree is touched until the commit object exists, a commit made since the +// inspection refuses with nothing changed, and a refused tree update rolls the +// branch back — so a failed call leaves the workspace exactly as it found it. + +// OperationWorkspaceCommitSpec is the operation key commit-spec records in its +// envelope. +const OperationWorkspaceCommitSpec = "workspace.commit-spec" + +// The dispositions commit-spec reports in WorkspaceOpResult.Disposition. +const ( + // SpecCopyCommitted: the first copy was committed (applied). + SpecCopyCommitted = "committed" + // SpecCopyRefreshed: a revised spec replaced the copy (applied). + SpecCopyRefreshed = "refreshed" + // SpecCopyCurrent: the feature head already carries the exact copy (no-op). + SpecCopyCurrent = "current" + // SpecCopyNoSpec: the change links no spec (a trivial change), so there is + // no copy (no-op). + SpecCopyNoSpec = "no-spec" +) + +// The stable machine reasons commit-spec reports for its typed refusals. +const ( + // ReasonSpecCopyNotReady: the workspace is not in the ready (clean, owned) + // state, so nothing is written into it. + ReasonSpecCopyNotReady = "workspace-not-ready" + // ReasonSpecCopyMissing: the record's spec: field names no file on the + // metadata tip. + ReasonSpecCopyMissing = "spec-missing" + // ReasonSpecCopyPathOccupied: the integration branch already holds different + // bytes at the spec path, or the feature head holds something other than a + // regular file there — the copy would collide with content it does not own. + ReasonSpecCopyPathOccupied = "spec-path-occupied" + // ReasonSpecCopyMalformed: the metadata spec's managed markers do not parse, + // so no copy is built from it. + ReasonSpecCopyMalformed = "spec-malformed" +) + +// specCopyFileMode is the tree mode every spec copy is committed with. +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). +func specCopyChangeLine(c domain.Change) string { + return fmt.Sprintf("Change %04d — %s", int(c.ID()), c.Title()) +} + +// 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) { + doc, err := document.Parse(spec) + if err != nil { + return nil, fmt.Errorf("spec copy: %w", err) + } + le := doc.LineEnding() + if le == "" { + le = "\n" + } + body := spec + if _, ok := doc.Block(backlinkBlockName); ok { + var ps document.PatchSet + ps.RemoveBlock(backlinkBlockName) + if body, err = doc.Apply(ps); err != nil { + return nil, fmt.Errorf("spec copy: removing backlink: %w", err) + } + } + text := strings.TrimLeft(string(body), "\r\n") + line := specCopyChangeLine(c) + end, ok := firstTitleLineEnd(text) + if !ok { + if text == "" { + return []byte(line + le), nil + } + return []byte(line + le + le + text), nil + } + head, tail := text[:end], text[end:] + if !strings.HasSuffix(head, "\n") { + // The title is the spec's last, unterminated line. + head += le + } + sep := le + if tail != "" && !strings.HasPrefix(tail, "\n") && !strings.HasPrefix(tail, "\r\n") { + // The title runs straight into text: keep the change line its own + // paragraph rather than gluing it to the next line. + sep = le + le + } + return []byte(head + le + line + sep + tail), nil +} + +// firstTitleLineEnd returns the offset just past the line terminator (or EOF) of +// the first line of text that starts with "# " outside fenced code, and whether +// there is one. It scans with walkUnfencedLines, the same fence rules +// scanTopHeadings applies, so heading-shaped text inside a fence is content. +func firstTitleLineEnd(text string) (int, bool) { + end, found := 0, false + walkUnfencedLines([]byte(text), func(line []byte, _, lineEnd int) bool { + if bytes.HasPrefix(line, []byte("# ")) { + end, found = lineEnd, true + return false + } + return true + }) + return end, found +} + +// WorkspaceCommitSpec commits the change's spec copy onto its feature branch. +// It refuses unless the change is in-progress and its workspace is ready (clean +// and owned); a change with no spec is a no-op. The copy is built from the spec +// on the pinned metadata tip; an integration branch that already holds different +// bytes at the spec path refuses (the copy would collide on merge). The feature +// head is compared byte-for-byte: equal is a no-op, absent commits "Add design +// spec: ", different commits "Update design spec: <title>". +func WorkspaceCommitSpec(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req WorkspaceIDRequest) WorkspaceOpResult { + const op = OperationWorkspaceCommitSpec + wc, refusal := loadWorkspaceContext(ctx, deps, repoDir, req.ID, op) + if refusal != nil { + return *refusal + } + if wc.change.Status() != domain.StatusInProgress { + return newWorkspaceResult(op, ResultInvalidState, WorkspaceOpResult{ + ID: req.ID, + Reason: ReasonWorkspaceNotInProgress, + Message: fmt.Sprintf("change %04d is %q, not in-progress; the spec copy is committed only onto a claimed change's workspace", req.ID, wc.change.RawStatus()), + }) + } + + specPath := strings.TrimSpace(wc.change.Spec().Value) + if specPath == "" { + return newWorkspaceResult(op, ResultNoOp, WorkspaceOpResult{ + ID: req.ID, + Disposition: SpecCopyNoSpec, + Message: fmt.Sprintf("change %04d links no spec; there is no spec copy to commit", req.ID), + }) + } + + target, tRefusal := resolveWorkspaceTarget(op, wc) + if tRefusal != nil { + return *tRefusal + } + insp, err := wdeps.Service.Inspect(ctx, workspace.InspectRequest{Repository: wc.repo, Target: target}) + if err != nil { + return mapWorkspaceFailure(op, req.ID, err) + } + // refuse builds a typed refusal that carries the workspace facts read so far. + refuse := func(result Result, reason, msg string) WorkspaceOpResult { + return newWorkspaceResult(op, result, WorkspaceOpResult{ + ID: req.ID, + Path: specPath, + FeatureRef: string(target.FeatureRef), + Head: string(insp.HeadCommit), + State: string(insp.Kind), + Reason: reason, + Message: msg, + }) + } + if insp.Kind != workspace.StateReady { + // The state kind only — never the dirty paths themselves. + return refuse(ResultInvalidState, ReasonSpecCopyNotReady, + fmt.Sprintf("change %04d's workspace is %q, not ready; commit or discard local work before committing the spec copy", req.ID, insp.Kind)) + } + + // The copy is built from the spec on the pinned metadata tip. + art, err := deps.Reader.ReadArtifact(ctx, wc.pin, sourceMetadata, specPath) + if err != nil { + result, reason := classifyStatusError(ctx, err) + return refuse(result, reason, err.Error()) + } + if !art.Found { + 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) + if err != nil { + return refuse(ResultInvalidState, ReasonSpecCopyMalformed, + fmt.Sprintf("the metadata spec %s cannot be copied: %v", specPath, err)) + } + + // The integration branch must not already hold different bytes at the path: + // the PR would collide with content the change does not own. + integ, err := deps.Reader.ReadArtifact(ctx, wc.pin, sourceIntegration, specPath) + if err != nil { + result, reason := classifyStatusError(ctx, err) + return refuse(result, reason, err.Error()) + } + if integ.Found && !bytes.Equal(integ.Data, copyBytes) { + return refuse(ResultInvalidState, ReasonSpecCopyPathOccupied, + fmt.Sprintf("the integration branch already holds different content at %s; the spec copy would collide with it", specPath)) + } + + // Compare against the feature head itself. + src, err := deps.Client.OpenObjectSource(ctx, wc.repo, gitcli.Revision{Commit: insp.HeadCommit}) + if err != nil { + result, reason := classifyStatusError(ctx, classifyGitFailure(err)) + return refuse(result, reason, err.Error()) + } + blobs, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(specPath)}) + if err != nil { + result, reason := classifyStatusError(ctx, classifyGitFailure(err)) + return refuse(result, reason, err.Error()) + } + subject, disposition := "Add design spec: "+wc.change.Title(), SpecCopyCommitted + if len(blobs) == 1 && blobs[0].Found { + if !strings.HasPrefix(string(blobs[0].Blob.Mode), "100") { + return refuse(ResultInvalidState, ReasonSpecCopyPathOccupied, + fmt.Sprintf("the feature head holds a non-regular entry (mode %s) at %s; the spec copy is never written over it", blobs[0].Blob.Mode, specPath)) + } + if bytes.Equal(blobs[0].Blob.Bytes, copyBytes) { + return newWorkspaceResult(op, ResultNoOp, WorkspaceOpResult{ + ID: req.ID, + Path: specPath, + FeatureRef: string(target.FeatureRef), + BaseRef: string(target.BaseRef), + BaseCommit: string(insp.BaseCommit), + Head: string(insp.HeadCommit), + Disposition: SpecCopyCurrent, + }) + } + subject, disposition = "Update design spec: "+wc.change.Title(), SpecCopyRefreshed + } + + // Run mutation fence: a cancelled, superseded, or completing run that owns + // this worktree admits no new commit. A local write, so no publication + // identity is journaled. + done, ferr := admitWorkflowMutation(repoDir, op, nil) + if ferr != nil { + return workspaceFenceRefusalFor(op, req.ID, ferr) + } + + head, err := commitSpecCopy(ctx, deps.Client, wc.repo, insp, target, specPath, copyBytes, subject) + if err != nil { + result, reason := commitSpecFailure(ctx, err) + out := refuse(result, reason, fmt.Sprintf("committing the spec copy failed: %v", err)) + done(mutationJournalOutcome(out.Result)) + return out + } + out := newWorkspaceResult(op, ResultApplied, WorkspaceOpResult{ + ID: req.ID, + Path: specPath, + FeatureRef: string(target.FeatureRef), + BaseRef: string(target.BaseRef), + BaseCommit: string(insp.BaseCommit), + Head: string(head), + Disposition: disposition, + }) + done(mutationJournalOutcome(out.Result)) + return out +} + +// commitSpecFailure maps a commitSpecCopy failure onto the protocol taxonomy. A +// refusal from the in-place fast-forward (the branch moved past the inspected +// head, an unfinished Git operation, or a working-tree update refused over a +// local change) means the workspace stopped being ready after the inspection — +// nothing changed, so it is the same workspace-not-ready refusal. Anything else +// takes the ordinary Git-failure classification. +func commitSpecFailure(ctx context.Context, err error) (Result, string) { + if f, ok := gitcli.AsFailure(err); ok && f.Kind == gitcli.KindInvalidRepository { + return ResultInvalidState, ReasonSpecCopyNotReady + } + return classifyStatusError(ctx, classifyGitFailure(err)) +} + +// commitSpecCopy builds the one-path commit on top of the inspected feature head +// — from the head's tree in a private index, never the worktree index — and moves +// the checked-out feature branch to it in place, by compare-and-swap from the +// inspected head. It returns the new head. +func commitSpecCopy(ctx context.Context, client *gitcli.Client, repo gitcli.Repository, insp workspace.Inspection, target workspace.Target, specPath string, copyBytes []byte, subject string) (gitcli.ObjectID, error) { + tree, err := client.BuildTree(ctx, repo, insp.HeadCommit, []gitcli.TreeOp{{PutBlob: &gitcli.PutBlobOp{ + Path: gitcli.RepoPath(specPath), + Content: copyBytes, + Mode: specCopyFileMode, + }}}) + if err != nil { + return "", err + } + commit, err := client.CommitTree(ctx, repo, tree, []gitcli.ObjectID{insp.HeadCommit}, subject, nil) + if err != nil { + return "", err + } + if err := client.FastForwardCheckedOutBranch(ctx, insp.Path, target.FeatureRef, insp.HeadCommit, commit); err != nil { + return "", err + } + return commit, nil +} diff --git a/internal/app/workspace_spec_test.go b/internal/app/workspace_spec_test.go new file mode 100644 index 000000000..ff77747b7 --- /dev/null +++ b/internal/app/workspace_spec_test.go @@ -0,0 +1,104 @@ +package app + +import ( + "strings" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/repository" +) + +func specFixture() string { + return "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + + "> ↩ **[Change 0042 — Widget](https://example.invalid/blob/docket/docs/changes/active/0042-widget.md)**\n" + + "<!-- docket:backlink:end -->\n\n" + + "# Widget: design\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n" +} + +// specChange builds change 42 "Widget" through the same corpus path the +// operations use (snapshotOf / mustChange live in named_branch_facts_test.go). +func specChange(t *testing.T) domain.Change { + t.Helper() + rec := strings.Replace(lifecycleChange(42, "widget", "in-progress"), "title: 'A change'", "title: 'Widget'", 1) + snap := snapshotOf(t, []StatusBlob{{ + Kind: repository.KindChange, + Location: repository.LocationActive, + Path: groomPath(42, "widget"), + Revision: "blobspec0042", + Data: []byte(rec), + }}) + return mustChange(t, snap, 42) +} + +func TestSpecCopyBytesStripsBacklinkAndAddsChangeLine(t *testing.T) { + got, err := specCopyBytes([]byte(specFixture()), specChange(t)) + if err != nil { + t.Fatal(err) + } + want := "# Widget: design\n\nChange 0042 — Widget\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n" + if string(got) != want { + t.Fatalf("copy:\n%q\nwant\n%q", got, want) + } +} + +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)) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(got), "# Real title\n\nChange 0042 — Widget\n\ntext\n") { + t.Fatalf("change line not after the real title:\n%s", got) + } + if strings.Contains(string(got), "# not a title\n\nChange") { + t.Fatalf("change line placed inside the fence:\n%s", got) + } +} + +func TestSpecCopyBytesNoTitle(t *testing.T) { + got, err := specCopyBytes([]byte("Just prose.\n"), specChange(t)) + if err != nil { + t.Fatal(err) + } + if string(got) != "Change 0042 — Widget\n\nJust prose.\n" { + t.Fatalf("no-title copy = %q", got) + } +} + +// TestSpecCopyBytesTitleIsLastLine: a title with no line terminator (the +// 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)) + if err != nil { + t.Fatal(err) + } + if string(got) != "# Only a title\n\nChange 0042 — Widget\n" { + t.Fatalf("title-only copy = %q", got) + } +} + +func TestSpecCopyBytesKeepsCRLF(t *testing.T) { + src := strings.ReplaceAll(specFixture(), "\n", "\r\n") + got, err := specCopyBytes([]byte(src), specChange(t)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ReplaceAll(string(got), "\r\n", ""), "\n") { + t.Fatalf("mixed line endings:\n%q", got) + } + if strings.Contains(string(got), "docket:backlink") { + t.Fatalf("backlink survived:\n%q", got) + } + want := strings.ReplaceAll("# Widget: design\n\nChange 0042 — Widget\n\nBody paragraph.\n\n## Decisions\n\n1. One.\n", "\n", "\r\n") + if string(got) != want { + t.Fatalf("CRLF copy:\n%q\nwant\n%q", got, want) + } +} + +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 { + t.Fatal("a dangling backlink marker must refuse, never be copied") + } +} diff --git a/internal/cli/install.go b/internal/cli/install.go index e5445a20b..245f45945 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -60,6 +60,7 @@ var assetIndependent = map[string]bool{ "artifact backlink": true, "workspace": true, // the group itself; it reports a missing command "workspace prepare": true, + "workspace commit-spec": true, "workspace inspect": true, "workspace publish": true, "evidence": true, // the group itself; it reports a missing command diff --git a/internal/cli/workspace.go b/internal/cli/workspace.go index 219ab62d1..5c68d064d 100644 --- a/internal/cli/workspace.go +++ b/internal/cli/workspace.go @@ -59,6 +59,30 @@ func newWorkspaceCommand(setResult func(app.OperationResult)) *cobra.Command { _ = prepare.MarkFlagRequired("id") _ = prepare.MarkFlagRequired("revision") + commitSpec := &cobra.Command{ + Use: "commit-spec", + Short: "Commit the change's spec copy onto its feature branch (the branch's first commit)", + Args: cobra.NoArgs, + Annotations: capability("workspace.commit-spec", EffectLocalWrite), + RunE: func(c *cobra.Command, _ []string) error { + repoDir, err := resolveRepoDir(c) + if err != nil { + return err + } + id, _ := c.Flags().GetInt("id") + deps, wdeps, err := newWorkspaceDeps(repoDir) + if err != nil { + return err + } + setResult(app.WorkspaceCommitSpec(c.Context(), deps, wdeps, repoDir, + app.WorkspaceIDRequest{ID: id})) + return nil + }, + } + commitSpec.Flags().Int("id", 0, "change `id` whose spec copy to commit (required)") + commitSpec.Flags().String("repo-dir", "", "repository `dir` to operate on (default: current directory)") + _ = commitSpec.MarkFlagRequired("id") + inspect := &cobra.Command{ Use: "inspect", Short: "Classify a change's feature workspace state (read-only)", @@ -110,7 +134,7 @@ func newWorkspaceCommand(setResult func(app.OperationResult)) *cobra.Command { _ = publish.MarkFlagRequired("id") _ = publish.MarkFlagRequired("head") - workspaceCmd.AddCommand(prepare, inspect, publish) + workspaceCmd.AddCommand(prepare, commitSpec, inspect, publish) return workspaceCmd } diff --git a/internal/cli/workspace_test.go b/internal/cli/workspace_test.go index 2bc69e260..c5855d0ad 100644 --- a/internal/cli/workspace_test.go +++ b/internal/cli/workspace_test.go @@ -7,7 +7,7 @@ import ( "github.com/danielhanold/docket/internal/testsupport" ) -// TestWorkspaceCommandsRegistered: the three `workspace` subcommands are +// TestWorkspaceCommandsRegistered: the four `workspace` subcommands are // registered under the group with their scalar flags, and the bare group reports // a missing command. func TestWorkspaceCommandsRegistered(t *testing.T) { @@ -20,6 +20,7 @@ func TestWorkspaceCommandsRegistered(t *testing.T) { {[]string{"workspace", "prepare"}, []string{"id", "revision", "repo-dir"}}, {[]string{"workspace", "inspect"}, []string{"id", "repo-dir"}}, {[]string{"workspace", "publish"}, []string{"id", "head", "repo-dir"}}, + {[]string{"workspace", "commit-spec"}, []string{"id", "repo-dir"}}, } for _, tc := range cases { cmd, _, err := root.Find(tc.path) @@ -74,6 +75,24 @@ func TestWorkspacePrepareRoutesFlags(t *testing.T) { } } +// TestWorkspaceCommitSpecRoutesFlags: `docket workspace commit-spec` routes --id +// into app.WorkspaceCommitSpec; against a non-repository directory the pin +// fails, still yielding one document naming the commit-spec operation. Omitting +// --id is an argument error (exit 2). +func TestWorkspaceCommitSpecRoutesFlags(t *testing.T) { + root := testsupport.TempDir(t) + out, errS, _ := runCLI(t, "workspace", "commit-spec", "--id", "7", "--repo-dir", root, "--json") + if errS != "" { + t.Fatalf("unexpected stderr %q", errS) + } + if !strings.Contains(out, `"operation":"workspace.commit-spec"`) { + t.Fatalf("commit-spec document did not name the operation: %q", out) + } + if _, errS, code := runCLI(t, "workspace", "commit-spec"); code != 2 || !strings.Contains(errS, "id") { + t.Errorf("missing --id not rejected: err=%q code=%d", errS, code) + } +} + // TestWorkspaceRequiredFlags: prepare requires --id and --revision; publish // requires --head — omitting one is an argument error (exit 2). func TestWorkspaceRequiredFlags(t *testing.T) { @@ -99,7 +118,7 @@ func TestWorkspaceInspectHumanMode(t *testing.T) { // workspace commands read the repository and drive Git, never installed assets, // so they must be asset-independent. func TestWorkspaceCommandsAssetIndependent(t *testing.T) { - for _, key := range []string{"workspace", "workspace prepare", "workspace inspect", "workspace publish"} { + for _, key := range []string{"workspace", "workspace prepare", "workspace inspect", "workspace publish", "workspace commit-spec"} { if !assetIndependent[key] { t.Errorf("%q is not registered asset-independent", key) } From a88bf023c9ee070b7798f3077350137e82a40922 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 01:27:13 -0400 Subject: [PATCH 08/28] feat(attach-plan): the plan is written on the metadata branch with its backlink change.attach-plan now carries the plan Markdown (--markdown <file|->) and, in one metadata transaction, writes docs/superpowers/plans/<file> on the metadata branch behind its freshly rendered backlink (any submitted backlink block is replaced), sets plan: and updated:, and re-renders the artifact block and board. A re-plan at the linked path replaces the content; an identical re-attach declares nothing. New typed refusals: empty-markdown, authored-input-too-large, artifact-path-mismatch (plan: already names another path), and artifact-path-occupied (a file at the path whose backlink targets another change). Malformed backlink markers and placeholder-only slots still refuse. The feature-head, descends-from-base, single-artifact-delta, and plan-path trailer checks are removed with their reasons and helpers. run.verify reads the plan on the metadata tip; status checks the plan on the metadata branch with an integration-branch fallback for records closed before plans moved. attach-results is unchanged until it moves next. --- internal/app/change_attach.go | 463 ++++++++++++------ .../app/change_attach_git_helpers_test.go | 10 +- .../app/change_attach_git_integration_test.go | 52 +- internal/app/change_attach_test.go | 388 +++++++++++++-- internal/app/change_integration_test.go | 12 + internal/app/claim_workflow_git_test.go | 11 - internal/app/finalize_e2e_test.go | 16 +- internal/app/finding_codes.go | 2 + .../app/named_isolation_integration_test.go | 11 +- internal/app/run_verify.go | 66 ++- internal/app/run_verify_test.go | 9 +- internal/app/status.go | 36 +- internal/app/status_git.go | 4 +- internal/app/status_test.go | 63 ++- internal/app/workflow_e2e_test.go | 30 +- internal/app/workflow_integration_test.go | 308 ++++++------ internal/cli/change.go | 63 ++- internal/cli/change_test.go | 62 ++- internal/cli/revision_rename_test.go | 2 +- 19 files changed, 1063 insertions(+), 545 deletions(-) diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index 008bfec57..313b9ccc7 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -3,6 +3,8 @@ package app import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "path" @@ -20,23 +22,29 @@ import ( ) // This file is the `change attach-plan` and `change attach-results` operations: -// two exact-revision metadata transitions that link a verified authored artifact -// (a plan, or an optional results record) to its change and re-render every -// affected v1-owned derived view (the change record's plan:/results: field, its -// refreshed updated date, its artifact block, and the inline board) as one -// atomic transaction. +// two exact-revision metadata transitions that link an authored artifact (a +// plan, or a results record) to its change and re-render every affected v1-owned +// derived view (the change record's plan:/results: field, its refreshed updated +// date, its artifact block, and the inline board) as one atomic transaction. +// The two kinds run under different profiles: // -// The load-bearing property is pre-transaction Git VERIFICATION, done from Git -// and never from the child agent's return: the writer commit must be the current -// feature head, the plan must be a regular tracked file inside the allowed -// planning directory carrying a balanced backlink that targets THIS change, and -// (for a plan) the commit must descend from the prepared base, carry the -// ADR-0094 single-artifact delta (exactly the plan file, rename detection off — -// learning diff-derived-allowlist-needs-no-renames), carry the plan-path commit -// trailer, and carry no placeholder-only plan slot (change 0414). Only after -// every check passes does the exact-revision transaction open. +// - attach-plan (changeAttachMetadata) carries the plan Markdown itself and +// WRITES the plan file on the metadata branch in the same transaction, the +// way change.groom writes a spec: any submitted docket:backlink block is +// replaced by the freshly rendered one, the body must be non-empty, bounded, +// parse cleanly, and carry no placeholder-only slot (change 0414), and the +// path must sit inside the planning root. Inside the transaction a field that +// already names a different path refuses, and a file already at the path +// that does not point home to THIS change is never overwritten. A re-plan at +// the linked path replaces the content. +// - attach-results (changeAttach) verifies an artifact already committed on +// the feature branch from Git, never from the child agent's return: the +// writer commit must be the current feature head, the results file a regular +// tracked file inside the configured results root carrying a balanced +// backlink that targets THIS change and passing checkpoint-phase content +// validation. Only after every check passes does the transaction open. // -// Idempotency is keyed on the PROMISED state — the (id, path, blob-at-commit) +// Idempotency is keyed on the PROMISED state — the (id, path, artifact bytes) // triple — so a lost-response retry replays the original applied receipt rather // than attaching whatever now occupies the path (learning idempotency-keying). @@ -47,8 +55,9 @@ const ( ) // The two artifact kinds an attach operation links. Kind selects the owned -// frontmatter field, the allowed planning root, and (plan only) the required -// commit trailer and single-artifact/placeholder verification. +// frontmatter field, the allowed planning root, and the verification profile +// (the plan is written on the metadata branch; results are verified at the +// feature head). const ( attachKindPlan = "plan" attachKindResults = "results" @@ -60,17 +69,10 @@ const ( // (config.Effective.ResultsDir) and is read from the resolved workflow config. const plansPlanningRoot = "docs/superpowers/plans" -// The commit trailer the plan-writer stamps (ADR-0094). Results carry their own -// path trailer. Both are verified verbatim off the writer commit, never trusted -// from the child return. -const ( - planPathTrailerKey = "Docket-Plan-Path" - resultsPathTrailerKey = "Docket-Results-Path" -) - // Stable machine reasons the attach operations report for their typed refusals. -// Message text is explanatory and must not be parsed. Every refusal predates the -// transaction, so a refused call writes nothing. +// Message text is explanatory and must not be parsed. A refused call writes +// nothing: most refusals predate the transaction, and the in-transaction ones +// (artifact-path-mismatch, artifact-path-occupied) refuse before any commit. const ( // ReasonAttachAbsolutePath: the artifact path is absolute; paths crossing the // CLI are canonical repository-relative (Global Constraints). @@ -86,26 +88,31 @@ const ( ReasonAttachUnknownChange = "unknown-change" ReasonAttachAmbiguousID = "ambiguous-change" // ReasonAttachCommitNotHead: the verified commit is not the current feature - // head, so it does not describe the checkout the change owns. + // head, so it does not describe the checkout the change owns (results). ReasonAttachCommitNotHead = "commit-not-head" - // ReasonAttachCommitNotDescendant: the commit does not descend from the - // prepared base — it was not built on the workspace this change prepared. - ReasonAttachCommitNotDescendant = "commit-not-descendant" // ReasonAttachUntrackedFile: the artifact path is not a tracked file at the - // verified commit. + // verified commit (results). ReasonAttachUntrackedFile = "untracked-file" // ReasonAttachSymlinkedPlan: the artifact path is a symlink at the commit, not - // a regular file. + // a regular file (results). ReasonAttachSymlinkedPlan = "symlinked-plan" - // ReasonAttachMultiArtifactDelta: the writer commit changes more than the one - // allowed artifact (ADR-0094). A rename with detection off reddens here too. - ReasonAttachMultiArtifactDelta = "multi-artifact-delta" - // ReasonAttachMissingTrailer: the writer commit lacks the required path trailer - // (or its value does not name the artifact). - ReasonAttachMissingTrailer = "missing-trailer" // ReasonAttachUnbalancedBacklink: the artifact's managed backlink markers are - // malformed (dangling/out-of-order/nested) — the blob will not parse. + // malformed (dangling/out-of-order/nested) — the body will not parse. For a + // plan it is the submitted Markdown that refuses, before anything is read. ReasonAttachUnbalancedBacklink = "unbalanced-backlink" + // ReasonAttachEmptyMarkdown: the submitted artifact Markdown is empty or + // whitespace-only. + ReasonAttachEmptyMarkdown = "empty-markdown" + // ReasonAttachMarkdownTooLarge: the submitted artifact Markdown exceeds the + // authored-input bound (maxAuthoredMarkdownBytes). + ReasonAttachMarkdownTooLarge = "authored-input-too-large" + // ReasonAttachPathMismatch: the change's owned field already names a + // different artifact path; an attach never silently re-points the link. + ReasonAttachPathMismatch = "artifact-path-mismatch" + // ReasonAttachPathOccupied: a file already sits at the artifact path on the + // metadata branch and its backlink does not point home to this change, so + // the attach refuses rather than overwrite another change's artifact. + ReasonAttachPathOccupied = "artifact-path-occupied" // ReasonAttachMissingBacklink: the artifact carries no docket:backlink block. ReasonAttachMissingBacklink = "missing-backlink" // ReasonAttachBacklinkMismatch: the artifact's backlink targets a different @@ -127,18 +134,23 @@ const ( // ChangeAttachRequest is the closed request for one attach. ID and Revision pin // the change record (exact submitted blob); Path is the canonical repo-relative -// artifact path; Commit is the exact feature commit the writer reported. +// artifact path; Commit is the exact feature commit the writer reported +// (results only); Markdown is the authored plan body (plan only). type ChangeAttachRequest struct { ID int `json:"id" docket:"required"` Revision string `json:"revision" docket:"required"` Path string `json:"path" docket:"required"` - Commit string `json:"commit" docket:"required"` + Commit string `json:"commit"` // results only + // Markdown is the authored artifact body, read from --markdown at the CLI + // boundary (a non-JSON file input, ADR-0138), never a JSON key. + Markdown []byte `json:"-"` } // attachDigestPayload is the idempotency digest payload: the promised state a -// retry must match — the change id, the artifact path, and the exact blob object -// id at the verified commit. Keying on the blob (not merely the path) means a -// retry after the path was overwritten is NOT a replay. +// retry must match — the change id, the artifact path, and the artifact's +// identity: for a plan, the sha256 of the stored artifact bytes; for results, +// the exact blob object id at the verified commit. Keying on the content (not +// merely the path) means a retry after the content changed is NOT a replay. type attachDigestPayload struct { Blob string `json:"blob"` ID int `json:"id"` @@ -188,9 +200,10 @@ func attachRefusal(opKey string, result Result, kind, reason, message string) Ch return newAttachResult(opKey, result, ChangeAttachResult{Kind: kind, Reason: reason, Message: message}) } -// ChangeAttachPlan verifies a written plan from Git and links it to the change. -func ChangeAttachPlan(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult { - return changeAttach(ctx, deps, wdeps, repoDir, req, attachKindPlan) +// ChangeAttachPlan writes the authored plan on the metadata branch with its +// backlink and links it to the change, in one metadata transaction. +func ChangeAttachPlan(ctx context.Context, deps PlanningDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult { + return changeAttachMetadata(ctx, deps, repoDir, req, attachKindPlan) } // ChangeAttachResults verifies an authored results record (required at @@ -203,9 +216,192 @@ func ChangeAttachResults(ctx context.Context, deps PlanningDeps, wdeps Workspace return changeAttach(ctx, deps, wdeps, repoDir, req, attachKindResults) } -// changeAttach is the shared driver. It validates the request shape, pins -// context, verifies the artifact from Git under the kind's verification profile, -// then drives one atomic, idempotency-keyed exact-revision transaction. +// changeAttachMetadata is the metadata-profile driver: it validates the request +// and the authored body, pins context, assembles the stored artifact (the +// freshly rendered backlink prepended to the body), then drives one atomic, +// idempotency-keyed exact-revision transaction that writes the artifact file and +// links it. Every refusal before the transaction writes nothing; the +// state-dependent refusals (a field naming another path, an occupied path) are +// decided inside the transaction against the attempt's own fresh tree. +func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string, req ChangeAttachRequest, kind string) ChangeAttachResult { + opKey := attachOpKey(kind) + + // (1) Request shape: the pinned-entity scalars, then the authored body — + // non-empty, bounded, parsing cleanly (balanced managed markers), and free + // of placeholder-only slots. All of it is decided before anything is read. + if findings := validateLifecycleShape("id", req.ID, req.Path, req.Revision); len(findings) > 0 { + return newAttachResult(opKey, ResultInvalidInput, ChangeAttachResult{Kind: kind, Findings: findings}) + } + if r := checkAttachMarkdown(opKey, kind, req.Markdown); r != nil { + return *r + } + + // (2) Pin context, check the board surface, discover the repository. + pin, err := deps.Reader.PinContext(ctx, repoDir) + if err != nil { + result, reason := classifyStatusError(ctx, err) + return attachRefusal(opKey, result, kind, reason, err.Error()) + } + eff := pin.Config.Effective + inline, err := resolveBoardSurface(eff) + if err != nil { + if pe, ok := asPlanningError(err); ok { + return attachRefusal(opKey, pe.Result, kind, pe.Reason, pe.Message) + } + return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, err.Error()) + } + repo, err := deps.Client.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: repoDir}) + if err != nil { + result, reason := classifyStatusError(ctx, classifyGitFailure(err)) + return attachRefusal(opKey, result, kind, reason, err.Error()) + } + + // (3) Resolve the change record (path, backlink target) from one corpus read. + ac, refusal := resolveAttachChange(ctx, deps, pin, eff, req.ID, opKey, kind) + if refusal != nil { + return *refusal + } + + // (4) Canonical-path containment inside the kind's allowed planning root. + if r := verifyAttachPath(opKey, kind, req.Path, eff); r != nil { + return *r + } + + // (5) An owned field that already names a different path refuses: the link + // is never silently re-pointed (the Plan closure re-checks on fresh state). + if linked := attachFieldValue(ac.change, kind); linked != "" && linked != req.Path { + return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachPathMismatch, pathMismatchMessage(kind, linked, req.Path)) + } + + // (6) Assemble the stored artifact: the submitted body with any backlink + // block it carries replaced by the freshly rendered one for this change. + backlink, err := render.BacklinkContent(ac.change, ac.link) + if err != nil { + return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, err.Error()) + } + artifact, err := metadataArtifactBytes(req.Markdown, backlink) + if err != nil { + // Unreachable after checkAttachMarkdown parsed the same body; kept so a + // future reorder can never store a malformed artifact. + return attachRefusal(opKey, ResultInvalidInput, kind, ReasonAttachUnbalancedBacklink, + fmt.Sprintf("the submitted Markdown has a malformed managed-block population: %v", err)) + } + + // (7) Open the exact-revision, idempotency-keyed transaction. The promised + // state is (id, path, stored bytes), so a lost-response retry of the same + // body replays and a different body is a new request. + sum := sha256.Sum256(artifact) + contentID := hex.EncodeToString(sum[:]) + digest, derr := canonicalDigest(opKey, attachDigestPayload{Blob: contentID, ID: req.ID, Path: req.Path}) + if derr != nil { + return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, derr.Error()) + } + op := changeAttachOp{ + opKey: opKey, + kind: kind, + changeID: req.ID, + artifact: req.Path, + artifactBytes: artifact, + eff: eff, + clock: deps.Clock, + inline: inline, + link: linkContextOf(pin), + changesDir: eff.ChangesDir.Value, + } + res, execErr := deps.Engine.Execute(ctx, transaction.Request{ + Repository: repo, + Remote: originRemote, + TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), + Expected: []transaction.EntityExpectation{{ + Path: gitcli.RepoPath(ac.recPath), + Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, + }}, + Idempotency: &transaction.IdempotencyKey{ + RequestID: fmt.Sprintf("attach-%s-%d-%s", kind, req.ID, contentID[:16]), + Digest: digest, + }, + Loader: newPlanningLoader(eff), + Scope: changeScope(req.ID, ac.recPath, false), + Operation: op, + }) + return attachResultFromOutcome(opKey, kind, req.Path, res, execErr) +} + +// checkAttachMarkdown applies the authored-body request checks shared by the +// metadata profile: non-empty, within the authored-input bound, a clean parse +// (balanced managed markers), and — for a plan — no slot whose whole body is a +// bare placeholder token (change 0414). Managed blocks are not author content, +// so the slot check reads the submitted body exactly as it reads the stored +// artifact. It returns nil when the body is usable. +func checkAttachMarkdown(opKey, kind string, markdown []byte) *ChangeAttachResult { + refuse := func(result Result, reason, msg string) *ChangeAttachResult { + r := attachRefusal(opKey, result, kind, reason, msg) + return &r + } + if len(bytes.TrimSpace(markdown)) == 0 { + return refuse(ResultInvalidInput, ReasonAttachEmptyMarkdown, + fmt.Sprintf("the %s Markdown is empty; send the authored artifact body", kind)) + } + if len(markdown) > maxAuthoredMarkdownBytes { + return refuse(ResultInvalidInput, ReasonAttachMarkdownTooLarge, + fmt.Sprintf("the %s Markdown is %d bytes, over the %d-byte authored-input bound", kind, len(markdown), maxAuthoredMarkdownBytes)) + } + if _, err := document.Parse(markdown); err != nil { + return refuse(ResultInvalidInput, ReasonAttachUnbalancedBacklink, + fmt.Sprintf("the submitted Markdown has a malformed managed-block population: %v", err)) + } + if kind == attachKindPlan { + // A plan that merely MENTIONS a token — an instruction, a code example, + // ambiguous prose — is build-actionable and attaches; completeness + // judgment stays with plan authoring and review. + if slot, found := planPlaceholderSlot(markdown); found { + return refuse(ResultInvalidState, ReasonAttachPlaceholderToken, + fmt.Sprintf("%s of the plan contains only a placeholder token; fill the slot with real content", slot)) + } + } + return nil +} + +// metadataArtifactBytes assembles the stored artifact: any submitted backlink +// block removed, the freshly rendered backlink prepended. Leading blank lines of +// the body are dropped, so an artifact resubmitted exactly as read back from the +// metadata branch reassembles byte-identical (a fixed point, never a duplicate +// block or a growing gap). A body whose managed markers are malformed returns +// the parse error untouched. +func metadataArtifactBytes(markdown []byte, backlink string) ([]byte, error) { + doc, err := document.Parse(markdown) + if err != nil { + return nil, err + } + body := markdown + if _, ok := doc.Block(backlinkBlockName); ok { + var ps document.PatchSet + ps.RemoveBlock(backlinkBlockName) + if body, err = doc.Apply(ps); err != nil { + return nil, err + } + } + return assembleSpecFile(backlink, strings.TrimLeft(string(body), "\r\n")), nil +} + +// attachFieldValue returns the change's current value of the kind's owned +// artifact field ("" when unset). +func attachFieldValue(c domain.Change, kind string) string { + if kind == attachKindResults { + return strings.TrimSpace(c.Results().Value) + } + return strings.TrimSpace(c.Plan().Value) +} + +// pathMismatchMessage explains an artifact-path-mismatch refusal. +func pathMismatchMessage(kind, linked, requested string) string { + return fmt.Sprintf("the change's %s: field already names %q; attach the %s at that path rather than %q", kind, linked, kind, requested) +} + +// changeAttach is the feature-head driver attach-results still runs. It +// validates the request shape, pins context, verifies the committed artifact +// from Git, then drives one atomic, idempotency-keyed exact-revision +// transaction. func changeAttach(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req ChangeAttachRequest, kind string) ChangeAttachResult { opKey := attachOpKey(kind) @@ -259,67 +455,28 @@ func changeAttach(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, r "the verified commit is not the current feature head; re-read the workspace head before attaching") } - // (6, plan only) The commit descends from the prepared base. - if kind == attachKindPlan { - desc, err := deps.Client.IsAncestor(ctx, repo, gitcli.ObjectID(insp.BaseCommit), gitcli.ObjectID(req.Commit)) - if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - return attachRefusal(opKey, result, kind, reason, err.Error()) - } - if !desc { - return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachCommitNotDescendant, - "the commit does not descend from the prepared base; it was not built on this change's workspace") - } - } - - // (7) Read the artifact blob AT THE VERIFIED COMMIT — tracked, regular, no + // (6) Read the artifact blob AT THE VERIFIED COMMIT — tracked, regular, no // symlink — and keep its exact bytes and object id (never the working tree). blob, r := readAttachBlob(ctx, deps, repo, req.Commit, req.Path, opKey, kind) if r != nil { return *r } - // (8, plan only) The writer commit carries the ADR-0094 single-artifact delta. - if kind == attachKindPlan { - if r := verifySingleArtifactDelta(ctx, deps, repo, req.Commit, req.Path, opKey); r != nil { - return *r - } - // (9, plan only) The writer commit carries the required path trailer. - if r := verifyPathTrailer(ctx, deps, repo, req.Commit, req.Path, planPathTrailerKey, opKey); r != nil { - return *r - } - } - - // (10) The artifact carries a balanced backlink targeting THIS change. + // (7) The artifact carries a balanced backlink targeting THIS change. if r := verifyBacklink(opKey, kind, blob.Blob.Bytes, ac); r != nil { return *r } - // (11b, results only) checkpoint-phase content sanity: a truthful in-progress - // artifact passes; raw template scaffolding, a missing title, or a malformed - // document refuses. The FINAL content contract binds at mark-implemented, not - // here — the phase is explicit so checkpoint attachment can never claim it. - if kind == attachKindResults { - if fs := ValidateResultsContent(blob.Blob.Bytes, ResultsPhaseCheckpoint); len(fs) > 0 { - return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachResultsContent, - fmt.Sprintf("the results artifact fails checkpoint content validation: %s: %s", fs[0].Reason, fs[0].Message)) - } + // (8) Checkpoint-phase content sanity: a truthful in-progress artifact + // passes; raw template scaffolding, a missing title, or a malformed document + // refuses. The FINAL content contract binds at mark-implemented, not here — + // the phase is explicit so checkpoint attachment can never claim it. + if fs := ValidateResultsContent(blob.Blob.Bytes, ResultsPhaseCheckpoint); len(fs) > 0 { + return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachResultsContent, + fmt.Sprintf("the results artifact fails checkpoint content validation: %s: %s", fs[0].Reason, fs[0].Message)) } - // (11, plan only) No whole-slot placeholder filler (change 0414): a section - // or the pre-heading preamble whose ENTIRE authored body is one bare - // planning token (tbd/todo/fixme/tktk/xxx/placeholder, any case) is an - // unfilled slot and refuses. A plan that merely mentions such a token — an - // instruction, a code example, ambiguous prose — is build-actionable and - // attaches; completeness judgment stays with plan authoring and review. - if kind == attachKindPlan { - if slot, found := planPlaceholderSlot(blob.Blob.Bytes); found { - return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachPlaceholderToken, - fmt.Sprintf("%s of the plan contains only a placeholder token; fill the slot with real content", slot)) - } - } - - // (12) Every verification passed: open the exact-revision, idempotency-keyed + // (9) Every verification passed: open the exact-revision, idempotency-keyed // transaction that stores the artifact path and re-renders the derived views. digest, derr := canonicalDigest(opKey, attachDigestPayload{Blob: string(blob.Blob.ObjectID), ID: req.ID, Path: req.Path}) if derr != nil { @@ -492,47 +649,6 @@ func readAttachBlob(ctx context.Context, deps PlanningDeps, repo gitcli.Reposito return results[0], nil } -// verifySingleArtifactDelta proves the writer commit changes exactly the one -// artifact path against its first parent, rename detection off. -func verifySingleArtifactDelta(ctx context.Context, deps PlanningDeps, repo gitcli.Repository, commit, artifactPath, opKey string) *ChangeAttachResult { - paths, err := deps.Client.CommitChangedPaths(ctx, repo, gitcli.ObjectID(commit)) - if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - r := attachRefusal(opKey, result, attachKindPlan, reason, err.Error()) - return &r - } - if len(paths) != 1 || string(paths[0]) != artifactPath { - r := attachRefusal(opKey, ResultInvalidState, attachKindPlan, ReasonAttachMultiArtifactDelta, - fmt.Sprintf("the writer commit changes %d paths, not exactly the one allowed artifact", len(paths))) - return &r - } - return nil -} - -// verifyPathTrailer proves the writer commit carries the required path trailer, -// whose value names the artifact. -func verifyPathTrailer(ctx context.Context, deps PlanningDeps, repo gitcli.Repository, commit, artifactPath, key, opKey string) *ChangeAttachResult { - scanned, err := deps.Client.ScanCommitTrailers(ctx, repo, gitcli.ObjectID(commit), []string{key}) - if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - r := attachRefusal(opKey, result, attachKindPlan, reason, err.Error()) - return &r - } - for _, ct := range scanned { - if string(ct.Commit) != commit { - continue - } - for _, tr := range ct.Trailers { - if tr.Key == key && tr.Value == artifactPath { - return nil - } - } - } - r := attachRefusal(opKey, ResultInvalidState, attachKindPlan, ReasonAttachMissingTrailer, - fmt.Sprintf("the writer commit lacks the required %q trailer naming the artifact", key)) - return &r -} - // backlinkTargets reports whether artifactBytes carries a balanced docket:backlink // managed block whose interior equals the backlink rendered for ch under link. It // is the shared backlink-identity check both change.attach-results (verifyBacklink) @@ -633,27 +749,32 @@ func decodeChangeAttachReceipt(b []byte) (changeAttachReceipt, bool) { } // changeAttachOp is the SemanticOperation the engine drives per attempt. Every -// field is fixed before the transaction; the state-dependent work (field -// patching, artifact-block and board rendering) re-runs from the attempt's own -// fresh state. +// field is fixed before the transaction; the state-dependent work (the path +// checks, field patching, artifact-block and board rendering) re-runs from the +// attempt's own fresh state. type changeAttachOp struct { - opKey string - kind string - changeID int - artifact string - eff config.Effective - clock transaction.Clock - inline bool - link render.LinkContext - changesDir string + opKey string + kind string + changeID int + artifact string + // artifactBytes is the stored artifact the metadata profile writes at + // artifact in the same commit (backlink + authored body); nil for the + // feature-head profile, which links a file already committed elsewhere. + artifactBytes []byte + eff config.Effective + clock transaction.Clock + inline bool + link render.LinkContext + changesDir string } func (o changeAttachOp) Key() transaction.OperationKey { return transaction.OperationKey(o.opKey) } // Plan stores the artifact path in the owned frontmatter field (plan or // results), refreshes the updated date, re-renders the artifact block, and -// assembles the closed plan: the mutated change record and the re-rendered board -// when inline is enabled. +// assembles the closed plan: the mutated change record, the re-rendered board +// when inline is enabled, and — for the metadata profile — the artifact file +// itself. func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { snap := st.State.Snapshot @@ -677,6 +798,35 @@ func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) ( field = attachKindResults } + // The metadata profile writes the artifact file, so it decides on this + // attempt's own tree: a field already naming another path never re-points, + // and a file already at the path is overwritten only when the link already + // names it or its backlink points home to THIS change (a prior attach of + // this change). Anything else belongs to someone else and refuses. + var artifactFile *transaction.FileMutation + if o.artifactBytes != nil { + linked := attachFieldValue(c, o.kind) + if linked != "" && linked != o.artifact { + return refuseLifecycle(FindingCode(ReasonAttachPathMismatch), pathMismatchMessage(o.kind, linked, o.artifact)) + } + existing, _, exists, err := treeBlob(ctx, st.Tree, o.artifact) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change attach: %w", err) + } + if exists && linked != o.artifact { + if home, berr := backlinkTargets(existing, c, o.link); berr != nil || !home { + return refuseLifecycle(FindingCode(ReasonAttachPathOccupied), + fmt.Sprintf("a file already exists at %q on the metadata branch and its backlink does not point to change %04d; refusing to overwrite it", o.artifact, o.changeID)) + } + } + switch { + case !exists: + artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationCreate, Bytes: o.artifactBytes} + case !bytes.Equal(existing, o.artifactBytes): + artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationReplace, Bytes: o.artifactBytes} + } + } + // First patch pass: the owned artifact field plus the refreshed updated date. intermediate, err := upsertFieldBytes(src, field, document.String(o.artifact)) if err != nil { @@ -724,6 +874,9 @@ func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) ( Path: gitcli.RepoPath(c.Path()), Kind: transaction.MutationReplace, Bytes: finalBytes, }) } + if artifactFile != nil { + files = append(files, *artifactFile) + } if o.inline { // Attaching an artifact edits no board-visible field, so includeBoard's // declare-only-when-changed shape can render byte-identical to the diff --git a/internal/app/change_attach_git_helpers_test.go b/internal/app/change_attach_git_helpers_test.go index 09786d7a0..f15d5f068 100644 --- a/internal/app/change_attach_git_helpers_test.go +++ b/internal/app/change_attach_git_helpers_test.go @@ -22,8 +22,14 @@ func attachBacklinkBlock(id int, title, recPath string) string { // ambiguous decision sentence, both attach; only a whole-slot bare-token filler // refuses). Every slot here holds substantive content. func attachHappyPlan(id int, title, recPath string) string { - return attachBacklinkBlock(id, title, recPath) + - "\n# Implementation Plan\n\n## Task 1\n\nRemove the " + tok("todo") + + return attachBacklinkBlock(id, title, recPath) + "\n" + attachHappyPlanBody() +} + +// attachHappyPlanBody is attachHappyPlan's authored body without the backlink +// block — the Markdown change.attach-plan receives; the operation renders and +// prepends the backlink itself. +func attachHappyPlanBody() string { + return "# Implementation Plan\n\n## Task 1\n\nRemove the " + tok("todo") + " in retry.go and replace it with bounded retry logic.\n\n" + "## Error handling\n" + tok("todo") + ": decide whether failed requests should retry or stop.\n" } diff --git a/internal/app/change_attach_git_integration_test.go b/internal/app/change_attach_git_integration_test.go index 79ae17b69..99034e9d0 100644 --- a/internal/app/change_attach_git_integration_test.go +++ b/internal/app/change_attach_git_integration_test.go @@ -24,20 +24,20 @@ func symlinkRepoFile(t *testing.T, root, rel, target string) { } } -// These are the real-git pre-transaction verification tests for change -// attach-plan: they drive the real ChangeAttachPlan operation over a real -// prepared workspace and a real bare metadata remote, so the from-Git guards -// (head, descent, single-artifact delta, trailer, tracked/regular file, -// balanced-and-targeted backlink, no placeholder token) are exercised end-to-end -// rather than faked. The guard table is a MUTATION test: every row is the happy -// fixture with exactly one property corrupted, and each asserts its own stable -// reason string — proof the guard reddens for the reason it names, not merely -// that something failed (learning assert-pins-outcome-not-mechanism). - -// attachSetup builds a main-mode repo with one in-progress change, prepares its -// feature workspace against the resolved base, and returns everything a -// verification row needs. The workspace sits on the feature ref at the base tip; -// a row commits its own plan variant, then runs ChangeAttachPlan. +// These are the real-git attach fixtures: they drive the real ChangeAttachPlan +// and ChangeAttachResults operations over a real prepared workspace and a real +// bare metadata remote. attach-plan writes the plan on the metadata branch, so +// its guards (path containment, body shape, a field naming another path, an +// occupied path) are exercised against the remote; attach-results still +// verifies an artifact committed at the feature head, so its rows commit first. +// Each refusal row asserts its own stable reason string — proof the guard +// reddens for the reason it names, not merely that something failed (learning +// assert-pins-outcome-not-mechanism). + +// attachSetup builds a repo with one in-progress change, prepares its feature +// workspace against the resolved base, and returns everything a row needs. The +// workspace sits on the feature ref at the base tip; a results row commits its +// own artifact variant before attaching. type attachFixture struct { ctx context.Context deps PlanningDeps @@ -102,19 +102,15 @@ func (f *attachFixture) reset(t *testing.T) { runGit(t, f.wp, "clean", "-fdq") } -// commitPlan writes files into the workspace and commits them, optionally adding -// the plan-path trailer, and returns the new head. -func (f *attachFixture) commitPlan(t *testing.T, files map[string]string, trailerPath string) string { +// commitArtifact writes files into the feature workspace and commits them, +// returning the new head (the results artifact a results row attaches). +func (f *attachFixture) commitArtifact(t *testing.T, files map[string]string) string { t.Helper() for rel, content := range files { writeRepoFile(t, f.wp, rel, content) } runGit(t, f.wp, "add", "-A") - args := []string{"commit", "-q", "-m", "write plan"} - if trailerPath != "" { - args = append(args, "--trailer", "Docket-Plan-Path: "+trailerPath) - } - runGit(t, f.wp, args...) + runGit(t, f.wp, "commit", "-q", "-m", "write artifact") return runGit(t, f.wp, "rev-parse", "HEAD") } @@ -140,10 +136,10 @@ func advanceDocketOrigin(t *testing.T, repo *gitRepo, files map[string]string) { func TestIntegrationRecordOpsChangeAttachUnrelatedInvalidRecordProgress(t *testing.T) { f := attachSetupWith(t, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) - head := f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) - res := ChangeAttachPlan(f.ctx, f.deps, f.wdeps, f.invocation, ChangeAttachRequest{ - ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, Commit: head, + res := ChangeAttachPlan(f.ctx, f.deps, f.invocation, ChangeAttachRequest{ + ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, + Markdown: []byte(attachHappyPlanBody()), }) if res.Result != ResultApplied { t.Fatalf("attach-plan beside an unrelated unparseable record = %q (reason %q findings %v), want applied", @@ -163,12 +159,12 @@ func TestIntegrationRecordOpsChangeAttachUnrelatedInvalidRecordRefusals(t *testi for _, c := range cases { t.Run(c.name, func(t *testing.T) { f := attachSetupWith(t, map[string]string{unrelatedBrokenPath: unrelatedBrokenBytes}) - head := f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) advanceDocketOrigin(t, f.repo, c.files) tip := originTip(t, f.repo.origin, "docket") - res := ChangeAttachPlan(f.ctx, f.deps, f.wdeps, f.invocation, ChangeAttachRequest{ - ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, Commit: head, + res := ChangeAttachPlan(f.ctx, f.deps, f.invocation, ChangeAttachRequest{ + ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, + Markdown: []byte(attachHappyPlanBody()), }) if res.Result == ResultApplied { t.Fatalf("attach-plan applied despite %s; want a refusal", c.name) diff --git a/internal/app/change_attach_test.go b/internal/app/change_attach_test.go index 489ae489a..3b8bb5a76 100644 --- a/internal/app/change_attach_test.go +++ b/internal/app/change_attach_test.go @@ -45,29 +45,73 @@ func baseAttachOp(surfaces []string, id int, kind, artifact string) changeAttach } } -// --- TestChangeAttachPlanPatch --------------------------------------------- +// attachTestPlanPath is the canonical plan path the plan-kind unit tests attach. +const attachTestPlanPath = "docs/superpowers/plans/2026-08-17-widget-plan.md" -// TestChangeAttachPlanPatch proves the transaction stores the plan path in the -// owned plan: field, re-renders the artifact block, refreshes updated, and names -// the record + board surfaces — nothing else in the record moves. -func TestChangeAttachPlanPatch(t *testing.T) { +// attachTestPlanMarkdown is a substantive plan body (no backlink block). +const attachTestPlanMarkdown = "# Plan\n\n## Task 1\n\nDo it.\n" + +// planAttachOp is baseAttachOp for the plan kind carrying the stored artifact +// bytes the driver assembles: markdown behind the backlink rendered for change 3 +// ("A change" at its active path), built by the production metadataArtifactBytes. +func planAttachOp(t *testing.T, surfaces []string, markdown string) changeAttachOp { + t.Helper() + op := baseAttachOp(surfaces, 3, attachKindPlan, attachTestPlanPath) + artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))) + if err != nil { + t.Fatalf("metadataArtifactBytes: %v", err) + } + op.artifactBytes = artifact + return op +} + +// plannedFile returns the declared mutation for path, failing when absent. +func plannedFile(t *testing.T, plan transaction.MutationPlan, path string) transaction.FileMutation { + t.Helper() + for _, f := range plan.Files { + if string(f.Path) == path { + return f + } + } + t.Fatalf("path %q not planned; files: %v", path, planPaths(plan)) + return transaction.FileMutation{} +} + +// refusalCode returns the first refusal finding's code, failing when the plan +// did not refuse. +func refusalCode(t *testing.T, opRes transaction.OperationResult) string { + t.Helper() + if !opRes.Refused || len(opRes.Findings) == 0 { + t.Fatalf("Plan did not refuse: %+v", opRes) + } + return opRes.Findings[0].Code +} + +// --- attach-plan: the plan is written on the metadata branch ---------------- + +// TestAttachPlanWritesFileFieldAndBacklink proves one metadata transaction +// stores the plan path in the owned plan: field, re-renders the artifact block +// and board, AND creates the plan file whose bytes are the rendered backlink +// block prepended to the authored Markdown (the same prepend shape the spec file +// uses). +func TestAttachPlanWritesFileFieldAndBacklink(t *testing.T) { recPath := groomPath(3, "widget") - planPath := "docs/superpowers/plans/2026-08-17-widget-plan.md" files := map[string]string{ recPath: lifecycleChange(3, "widget", "in-progress"), "docs/changes/BOARD.md": "# Backlog\n\nold\n", } - plan, opRes := attachPlanFor(t, files, baseAttachOp([]string{"inline"}, 3, attachKindPlan, planPath)) + plan, opRes := attachPlanFor(t, files, planAttachOp(t, []string{"inline"}, attachTestPlanMarkdown)) if opRes.Refused { t.Fatalf("unexpected refusal: %v", opRes.Findings) } assertPlanPaths(t, plan, map[string]transaction.MutationKind{ recPath: transaction.MutationReplace, "docs/changes/BOARD.md": transaction.MutationReplace, + attachTestPlanPath: transaction.MutationCreate, }) rec := lifecycleRecordBytes(t, plan, recPath) for _, want := range []string{ - "plan: '" + planPath + "'", + "plan: '" + attachTestPlanPath + "'", "updated: '2026-08-16'", "docket:artifacts:start", "status: in-progress", // untouched, still unquoted @@ -79,12 +123,265 @@ func TestChangeAttachPlanPatch(t *testing.T) { if strings.Contains(rec, "results: '") { t.Errorf("attach-plan wrote a results field:\n%s", rec) } + want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath), attachTestPlanMarkdown) + if got := plannedFile(t, plan, attachTestPlanPath).Bytes; string(got) != string(want) { + t.Errorf("plan file bytes =\n%q\nwant\n%q", got, want) + } +} + +// TestAttachPlanStripsSubmittedBacklink proves a body resubmitted exactly as read +// back (it still carries a docket:backlink block, any interior) is stored with +// exactly one backlink block, the freshly rendered one — and that re-assembling +// the stored bytes is a fixed point, so a read-back resubmission is no change. +func TestAttachPlanStripsSubmittedBacklink(t *testing.T) { + fresh := attachBacklinkBlock(3, "A change", groomPath(3, "widget")) + stale := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + got, err := metadataArtifactBytes([]byte(stale+"\n"+attachTestPlanMarkdown), fresh) + if err != nil { + t.Fatalf("metadataArtifactBytes: %v", err) + } + if n := strings.Count(string(got), "docket:backlink:start"); n != 1 { + t.Fatalf("stored artifact carries %d backlink blocks, want 1:\n%s", n, got) + } + if !strings.HasPrefix(string(got), fresh) || strings.Contains(string(got), "Another change") { + t.Errorf("stored backlink is not the freshly rendered one:\n%s", got) + } + if want := assembleSpecFile(fresh, attachTestPlanMarkdown); string(got) != string(want) { + t.Errorf("stored artifact =\n%q\nwant\n%q", got, want) + } + again, err := metadataArtifactBytes(got, fresh) + if err != nil { + t.Fatalf("metadataArtifactBytes (resubmit): %v", err) + } + if string(again) != string(got) { + t.Errorf("resubmitting the stored artifact changed it:\n%q\nwant\n%q", again, got) + } +} + +// TestAttachPlanRefusesMalformedBacklink proves a body whose backlink markers are +// malformed (a dangling start marker) refuses unbalanced-backlink before any pin +// or engine work — nothing is declared. +func TestAttachPlanRefusesMalformedBacklink(t *testing.T) { + bad := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n> ↩ dangling\n\n# Plan\n\nSteps.\n" + if _, err := metadataArtifactBytes([]byte(bad), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))); err == nil { + t.Errorf("metadataArtifactBytes accepted malformed backlink markers") + } + res, engine, reader := runAttachPlanUnit(t, ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestPlanPath, Markdown: []byte(bad)}) + if res.Reason != ReasonAttachUnbalancedBacklink { + t.Fatalf("reason = %q, want %q (result %q msg %q)", res.Reason, ReasonAttachUnbalancedBacklink, res.Result, res.Message) + } + assertNothingAttempted(t, res, engine, reader) +} + +// TestAttachPlanReplacesContentOnReplan proves a re-plan at the already linked +// path replaces the plan file's content in one MutationReplace and leaves the +// record undeclared when its bytes are unchanged (same day, field already set). +func TestAttachPlanReplacesContentOnReplan(t *testing.T) { + recPath := groomPath(3, "widget") + files := map[string]string{recPath: lifecycleChange(3, "widget", "in-progress")} + first, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) + if opRes.Refused { + t.Fatalf("first attach refused: %v", opRes.Findings) + } + files[recPath] = lifecycleRecordBytes(t, first, recPath) + files[attachTestPlanPath] = string(plannedFile(t, first, attachTestPlanPath).Bytes) + + revised := "# Plan\n\n## Task 1\n\nDo it differently.\n" + second, opRes := attachPlanFor(t, files, planAttachOp(t, nil, revised)) + if opRes.Refused { + t.Fatalf("re-plan refused: %v", opRes.Findings) + } + assertPlanPaths(t, second, map[string]transaction.MutationKind{ + attachTestPlanPath: transaction.MutationReplace, + }) + if got := string(plannedFile(t, second, attachTestPlanPath).Bytes); !strings.Contains(got, "Do it differently.") { + t.Errorf("re-plan did not write the revised content:\n%s", got) + } +} + +// TestAttachPlanRefusesDifferentPathWhenLinked proves a change whose plan: field +// already names path A refuses an attach of path B (artifact-path-mismatch) +// rather than silently re-pointing the link and orphaning A. +func TestAttachPlanRefusesDifferentPathWhenLinked(t *testing.T) { + recPath := groomPath(3, "widget") + linked := strings.Replace(lifecycleChange(3, "widget", "in-progress"), + "plan:\n", "plan: 'docs/superpowers/plans/2026-08-01-widget-old.md'\n", 1) + files := map[string]string{recPath: linked} + _, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) + if code := refusalCode(t, opRes); code != ReasonAttachPathMismatch { + t.Fatalf("refusal code = %q, want %q", code, ReasonAttachPathMismatch) + } +} + +// TestAttachPlanRefusesOccupiedPath proves an unlinked change refuses to write +// over a file already at the path whose backlink targets another change +// (artifact-path-occupied) — and that a file whose backlink targets THIS change +// (a prior attach's own file) is replaced. +func TestAttachPlanRefusesOccupiedPath(t *testing.T) { + recPath := groomPath(3, "widget") + t.Run("another change's file refuses", func(t *testing.T) { + files := map[string]string{ + recPath: lifecycleChange(3, "widget", "in-progress"), + attachTestPlanPath: attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + "\n# Other plan\n", + } + _, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) + if code := refusalCode(t, opRes); code != ReasonAttachPathOccupied { + t.Fatalf("refusal code = %q, want %q", code, ReasonAttachPathOccupied) + } + }) + t.Run("a file with no backlink refuses", func(t *testing.T) { + files := map[string]string{ + recPath: lifecycleChange(3, "widget", "in-progress"), + attachTestPlanPath: "# Hand-written plan\n", + } + _, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) + if code := refusalCode(t, opRes); code != ReasonAttachPathOccupied { + t.Fatalf("refusal code = %q, want %q", code, ReasonAttachPathOccupied) + } + }) + t.Run("this change's own file is replaced", func(t *testing.T) { + files := map[string]string{ + recPath: lifecycleChange(3, "widget", "in-progress"), + attachTestPlanPath: attachBacklinkBlock(3, "A change", recPath) + "\n# Older plan\n", + } + plan, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) + if opRes.Refused { + t.Fatalf("own file refused: %v", opRes.Findings) + } + if k := plannedFile(t, plan, attachTestPlanPath).Kind; k != transaction.MutationReplace { + t.Errorf("own plan file kind = %q, want replace", k) + } + }) +} + +// TestAttachPlanIdenticalReattachIsNoOp pins the same-path same-day re-attach +// (change 0458): once the record stores the path with today's date and the plan +// file holds the same bytes, re-running the identical attach declares NO files — +// an empty plan the engine commits as a clean no-op, never an unchanged replace +// the engine's delta verifier (verifyActualDelta) refuses as invalid-state. +func TestAttachPlanIdenticalReattachIsNoOp(t *testing.T) { + recPath := groomPath(3, "widget") + files := map[string]string{ + recPath: lifecycleChange(3, "widget", "in-progress"), + "docs/changes/BOARD.md": "# Backlog\n\nold\n", + } + op := planAttachOp(t, []string{"inline"}, attachTestPlanMarkdown) + first, opRes := attachPlanFor(t, files, op) + if opRes.Refused { + t.Fatalf("first attach refused: %v", opRes.Findings) + } + for _, f := range first.Files { + files[string(f.Path)] = string(f.Bytes) + } + second, opRes := attachPlanFor(t, files, op) + if opRes.Refused { + t.Fatalf("re-attach refused: %v", opRes.Findings) + } + if len(second.Files) != 0 { + t.Errorf("identical re-attach declared files %v, want an empty (no-op) plan", planPaths(second)) + } +} + +// TestAttachPlanPlaceholderStillRefuses proves a plan section whose entire body +// is a bare placeholder token refuses placeholder-token before any pin or engine +// work. +func TestAttachPlanPlaceholderStillRefuses(t *testing.T) { + md := "# Plan\n\n## Error handling\n\n" + tok("tbd") + "\n" + res, engine, reader := runAttachPlanUnit(t, ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestPlanPath, Markdown: []byte(md)}) + if res.Reason != ReasonAttachPlaceholderToken { + t.Fatalf("reason = %q, want %q (result %q msg %q)", res.Reason, ReasonAttachPlaceholderToken, res.Result, res.Message) + } + if !strings.Contains(res.Message, `section "Error handling"`) { + t.Errorf("placeholder message does not name the slot: %q", res.Message) + } + assertNothingAttempted(t, res, engine, reader) +} + +// TestAttachPlanRejectsBadShape proves the request-shape checks refuse before any +// pin or engine work: the id/path/revision scalars, an empty or oversized +// Markdown body. (The path-containment reasons need the resolved config and are +// pinned against real git in TestIntegrationWorkflowRepoChangeAttachPlanMetadataRefusals.) +func TestAttachPlanRejectsBadShape(t *testing.T) { + valid := ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestPlanPath, Markdown: []byte(attachTestPlanMarkdown)} + t.Run("scalars", func(t *testing.T) { + cases := []struct { + name string + mut func(*ChangeAttachRequest) + code string + }{ + {"non-positive id", func(r *ChangeAttachRequest) { r.ID = 0 }, "invalid-id"}, + {"empty path", func(r *ChangeAttachRequest) { r.Path = "" }, "empty-path"}, + {"empty revision", func(r *ChangeAttachRequest) { r.Revision = "" }, "empty-revision"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := valid + c.mut(&req) + res, engine, reader := runAttachPlanUnit(t, req) + if res.Result != ResultInvalidInput { + t.Fatalf("result = %q, want invalid-input", res.Result) + } + if !hasFindingCode(res.Findings, c.code) { + t.Errorf("missing finding %q; got %v", c.code, res.Findings) + } + assertNothingAttempted(t, res, engine, reader) + }) + } + }) + t.Run("markdown", func(t *testing.T) { + cases := []struct { + name string + markdown []byte + reason string + }{ + {"empty", nil, ReasonAttachEmptyMarkdown}, + {"blank", []byte(" \n\n"), ReasonAttachEmptyMarkdown}, + {"too large", []byte("# Plan\n\n" + strings.Repeat("x", maxAuthoredMarkdownBytes) + "\n"), ReasonAttachMarkdownTooLarge}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + req := valid + req.Markdown = c.markdown + res, engine, reader := runAttachPlanUnit(t, req) + if res.Result != ResultInvalidInput || res.Reason != c.reason { + t.Fatalf("result/reason = %q/%q, want invalid-input/%q (msg %q)", res.Result, res.Reason, c.reason, res.Message) + } + assertNothingAttempted(t, res, engine, reader) + }) + } + }) +} + +// runAttachPlanUnit drives ChangeAttachPlan over a recording engine and a reader +// that records whether it was pinned, with no git client: every refusal it pins +// must land before the operation reads anything. +func runAttachPlanUnit(t *testing.T, req ChangeAttachRequest) (ChangeAttachResult, *recordingEngine, *fakeChangeReader) { + t.Helper() + engine := &recordingEngine{} + reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} + deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} + return ChangeAttachPlan(context.Background(), deps, "", req), engine, reader +} + +// assertNothingAttempted proves a refusal read nothing and opened no transaction. +func assertNothingAttempted(t *testing.T, res ChangeAttachResult, engine *recordingEngine, reader *fakeChangeReader) { + t.Helper() + if res.Result == ResultApplied { + t.Fatalf("attach applied, want a refusal") + } + if len(engine.calls) != 0 { + t.Errorf("engine called %d times on a pre-transaction refusal, want 0", len(engine.calls)) + } + if reader.pinned != 0 { + t.Errorf("reader pinned %d times on a request-shape refusal, want 0", reader.pinned) + } } // --- TestChangeAttachResultsPatch ------------------------------------------ // TestChangeAttachResultsPatch proves the results transaction stores the results -// path in the owned results: field, leaving plan: untouched. +// path in the owned results: field, leaving plan: untouched, and (until results +// move to the metadata branch) declares no artifact file. func TestChangeAttachResultsPatch(t *testing.T) { recPath := groomPath(3, "widget") resultsPath := "docs/results/2026-08-17-widget-results.md" @@ -93,6 +390,7 @@ func TestChangeAttachResultsPatch(t *testing.T) { if opRes.Refused { t.Fatalf("unexpected refusal: %v", opRes.Findings) } + assertPlanPaths(t, plan, map[string]transaction.MutationKind{recPath: transaction.MutationReplace}) rec := lifecycleRecordBytes(t, plan, recPath) if !strings.Contains(rec, "results: '"+resultsPath+"'") { t.Errorf("attached record missing results field:\n%s", rec) @@ -120,12 +418,13 @@ func TestChangeAttachContention(t *testing.T) { } } -// --- TestChangeAttachRejectsBadShape --------------------------------------- +// --- TestChangeAttachResultsRejectsBadShape -------------------------------- -// TestChangeAttachRejectsBadShape proves the request-shape check refuses a -// malformed request before any pin/engine work, for every missing scalar. -func TestChangeAttachRejectsBadShape(t *testing.T) { - valid := ChangeAttachRequest{ID: 3, Revision: blobV, Path: "docs/superpowers/plans/x.md", Commit: blobV} +// TestChangeAttachResultsRejectsBadShape proves the results request-shape check +// refuses a malformed request before any pin/engine work, for every missing +// scalar (results still verify a feature commit, so commit is required). +func TestChangeAttachResultsRejectsBadShape(t *testing.T) { + valid := ChangeAttachRequest{ID: 3, Revision: blobV, Path: "docs/results/x-results.md", Commit: blobV} cases := []struct { name string mut func(*ChangeAttachRequest) @@ -144,7 +443,7 @@ func TestChangeAttachRejectsBadShape(t *testing.T) { reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} - res := ChangeAttachPlan(context.Background(), deps, WorkspaceDeps{}, "", req) + res := ChangeAttachResults(context.Background(), deps, WorkspaceDeps{}, "", req) if res.Result != ResultInvalidInput { t.Fatalf("result = %q, want invalid-input", res.Result) @@ -159,47 +458,30 @@ func TestChangeAttachRejectsBadShape(t *testing.T) { } } -// --- TestChangeAttachIdenticalReattachIsNoOp -------------------------------- +// --- TestChangeAttachResultsIdenticalReattachIsNoOp ------------------------ -// TestChangeAttachIdenticalReattachIsNoOp pins the same-path same-day -// re-attach (change 0458): once the record already stores the artifact path -// with today's updated date and a rendered artifact block, re-running the -// identical attach must declare NO files — an empty plan the engine commits -// as a clean no-op — never an unchanged replace the engine's delta verifier -// (verifyActualDelta) refuses as invalid-state. Both kinds, inline board on. -func TestChangeAttachIdenticalReattachIsNoOp(t *testing.T) { - cases := []struct { - kind string - artifact string - }{ - {attachKindPlan, "docs/superpowers/plans/2026-09-25-widget-plan.md"}, - {attachKindResults, "docs/results/2026-09-25-widget-results.md"}, +// TestChangeAttachResultsIdenticalReattachIsNoOp pins the same-path same-day +// results re-attach (change 0458): an identical re-attach declares NO files. +func TestChangeAttachResultsIdenticalReattachIsNoOp(t *testing.T) { + recPath := groomPath(3, "widget") + files := map[string]string{ + recPath: lifecycleChange(3, "widget", "in-progress"), + "docs/changes/BOARD.md": "# Backlog\n\nold\n", } - for _, c := range cases { - t.Run(c.kind, func(t *testing.T) { - recPath := groomPath(3, "widget") - files := map[string]string{ - recPath: lifecycleChange(3, "widget", "in-progress"), - "docs/changes/BOARD.md": "# Backlog\n\nold\n", - } - op := baseAttachOp([]string{"inline"}, 3, c.kind, c.artifact) + op := baseAttachOp([]string{"inline"}, 3, attachKindResults, "docs/results/2026-09-25-widget-results.md") - // First attach: settle the record and the board on the tree. - first, opRes := attachPlanFor(t, files, op) - if opRes.Refused { - t.Fatalf("first attach refused: %v", opRes.Findings) - } - files[recPath] = lifecycleRecordBytes(t, first, recPath) - files["docs/changes/BOARD.md"] = lifecycleRecordBytes(t, first, "docs/changes/BOARD.md") + first, opRes := attachPlanFor(t, files, op) + if opRes.Refused { + t.Fatalf("first attach refused: %v", opRes.Findings) + } + files[recPath] = lifecycleRecordBytes(t, first, recPath) + files["docs/changes/BOARD.md"] = lifecycleRecordBytes(t, first, "docs/changes/BOARD.md") - // Identical re-attach, same clock day: nothing may be declared. - second, opRes := attachPlanFor(t, files, op) - if opRes.Refused { - t.Fatalf("re-attach refused: %v", opRes.Findings) - } - if len(second.Files) != 0 { - t.Errorf("identical re-attach declared files %v, want an empty (no-op) plan", planPaths(second)) - } - }) + second, opRes := attachPlanFor(t, files, op) + if opRes.Refused { + t.Fatalf("re-attach refused: %v", opRes.Findings) + } + if len(second.Files) != 0 { + t.Errorf("identical re-attach declared files %v, want an empty (no-op) plan", planPaths(second)) } } diff --git a/internal/app/change_integration_test.go b/internal/app/change_integration_test.go index 3ee1bc6f1..22fff1049 100644 --- a/internal/app/change_integration_test.go +++ b/internal/app/change_integration_test.go @@ -4167,6 +4167,18 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test }) } + // The plan lives on the metadata branch: a plan file present only at the + // feature head (the fixture commits rvPlanPath there) and absent on the + // metadata tip is plan-file-missing — the feature head is never consulted. + t.Run("plan only at the feature head, absent on metadata", func(t *testing.T) { + deps, wdeps, gdeps := pub.deps(rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsPath, recordedPR, "feat/"+rvSlug), ev), rvPR(pub.head, prBodyNoEvidence)) + delete(deps.Reader.(*fakeReader).artifactData, sourceMetadata+"|"+rvPlanPath) + res := RunVerify(context.Background(), deps, wdeps, gdeps, pub.repo.invocation, RunVerifyRequest{ID: 3}) + if got := unmetReasons(res); res.Verdict != VerdictRunIncomplete || len(got) != 1 || got[0] != ReasonRunPlanMissing { + t.Fatalf("verdict %q unmet %v, want run-incomplete with exactly [%s]", res.Verdict, got, ReasonRunPlanMissing) + } + }) + // The remote-head postcondition needs an unpublished feature head: the local // head exists but the remote never received it, so the remote is absent. t.Run("feature head differs from remote", func(t *testing.T) { diff --git a/internal/app/claim_workflow_git_test.go b/internal/app/claim_workflow_git_test.go index 7c88709b8..29429d40c 100644 --- a/internal/app/claim_workflow_git_test.go +++ b/internal/app/claim_workflow_git_test.go @@ -33,17 +33,6 @@ func buildReadyChange(id int, slug string) string { return strings.Replace(groomableChange(id, slug), "trivial: false\n", "trivial: true\n", 1) } -// commitPlanFile writes one plan artifact into a feature workspace and commits it -// with the ADR-0094 plan-path trailer, returning the new head. It is the writer -// half every attach test needs — the plan-writer's single-artifact commit. -func commitPlanFile(t *testing.T, wp, planPath, content, trailerPath string) string { - t.Helper() - writeRepoFile(t, wp, planPath, content) - runGit(t, wp, "add", "-A") - runGit(t, wp, "commit", "-q", "-m", "write plan", "--trailer", "Docket-Plan-Path: "+trailerPath) - return runGit(t, wp, "rev-parse", "HEAD") -} - // --- claim race: two claimants, same context revision, one loses cleanly ----- // --- claim retry after a lost response: replay, never a second claim -------- diff --git a/internal/app/finalize_e2e_test.go b/internal/app/finalize_e2e_test.go index 9cb1f0951..c99504c41 100644 --- a/internal/app/finalize_e2e_test.go +++ b/internal/app/finalize_e2e_test.go @@ -379,17 +379,11 @@ func (e *implEnv) implement(t *testing.T, id int, slug, planPath, title string) } wp := prep.Path - writeRepoFile(t, wp, planPath, "# Implementation Plan\n\nConcrete steps here.\n") - bl := ArtifactBacklink(e.ctx, e.node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: planPath, ChangePath: recPath}) - if bl.Result != ResultApplied { - t.Fatalf("artifact backlink id %d = %q (reason %q)", id, bl.Result, bl.Reason) - } - runGit(t, wp, "add", "-A") - runGit(t, wp, "commit", "-q", "-m", "write plan", "--trailer", "Docket-Plan-Path: "+planPath) - planHead := runGit(t, wp, "rev-parse", "HEAD") - - attach := ChangeAttachPlan(e.ctx, e.node.deps, e.wdeps, e.node.dir, - ChangeAttachRequest{ID: id, Revision: ver(), Path: planPath, Commit: planHead}) + // The plan is written on the metadata branch; nothing lands on the feature + // branch for it. + attach := ChangeAttachPlan(e.ctx, e.node.deps, e.node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: planPath, Markdown: []byte("# Implementation Plan\n\nConcrete steps here.\n"), + }) if attach.Result != ResultApplied { t.Fatalf("attach plan id %d = %q (reason %q findings %v)", id, attach.Result, attach.Reason, attach.Findings) } diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index be94832d4..9c228faa9 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -186,6 +186,8 @@ var AllFindingCodes = []FindingCode{ FindingCode("ambiguous-adr"), FindingCode("ambiguous-change"), FCArtifactMissing, + FindingCode(ReasonAttachPathMismatch), + FindingCode(ReasonAttachPathOccupied), FCArtifactRenderFailed, FCAuthoredInputTooLarge, FCBranchMalformed, diff --git a/internal/app/named_isolation_integration_test.go b/internal/app/named_isolation_integration_test.go index 4ef0f5e34..784109ba5 100644 --- a/internal/app/named_isolation_integration_test.go +++ b/internal/app/named_isolation_integration_test.go @@ -246,13 +246,10 @@ func TestIntegrationNamedImplementationFlowIsolation(t *testing.T) { } wp := prep.Path - // (5) Plan attach. - writeRepoFile(t, wp, planPath, "# Implementation Plan\n\nConcrete steps here.\n") - if bl := ArtifactBacklink(ctx, node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: planPath, ChangePath: recPath}); bl.Result != ResultApplied { - t.Fatalf("plan backlink = %q (reason %q msg %q)", bl.Result, bl.Reason, bl.Message) - } - planHead := commitPlanFile(t, wp, planPath, string(mustReadFile(t, filepath.Join(wp, planPath))), planPath) - attach := ChangeAttachPlan(ctx, node.deps, wdeps, node.dir, ChangeAttachRequest{ID: id, Revision: ver(), Path: planPath, Commit: planHead}) + // (5) Plan attach: the plan is written on the metadata branch. + attach := ChangeAttachPlan(ctx, node.deps, node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: planPath, Markdown: []byte("# Implementation Plan\n\nConcrete steps here.\n"), + }) if attach.Result != ResultApplied { t.Fatalf("attach plan = %q (reason %q msg %q findings %v), want applied", attach.Result, attach.Reason, attach.Message, attach.Findings) } diff --git a/internal/app/run_verify.go b/internal/app/run_verify.go index 5236ddd29..1d45e3712 100644 --- a/internal/app/run_verify.go +++ b/internal/app/run_verify.go @@ -71,8 +71,8 @@ const ( ReasonRunNotImplemented = "not-implemented" // ReasonRunPlanUnlinked: the change carries no linked plan. ReasonRunPlanUnlinked = "plan-unlinked" - // ReasonRunPlanMissing: the linked plan no longer resolves to a tracked regular - // file at the current feature head. + // ReasonRunPlanMissing: the linked plan no longer resolves to a file on the + // metadata branch. ReasonRunPlanMissing = "plan-file-missing" // ReasonRunRemoteHeadMismatch: the remote feature ref is absent or names a // commit other than the current local feature head. @@ -347,21 +347,30 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep add(ReasonRunLeaseContended, b.Value) } - // Plan link and plan-file identity at the feature head. + // Plan link, and plan-file identity on the metadata branch (the plan lives + // there; the feature head is never consulted for it). planPath := strings.TrimSpace(c.Plan().Value) resultsPath := strings.TrimSpace(c.Results().Value) if planPath == "" { add(ReasonRunPlanUnlinked, "") + } else { + art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, planPath) + if err != nil { + return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, err.Error(), req.ID) + } + if !art.Found { + add(ReasonRunPlanMissing, planPath) + } } // A results artifact is REQUIRED at the implemented boundary (change 0410): // an absent link is an unmet condition on its own. It needs no blob read, so it - // is added OUTSIDE the object-source guard below — a run with neither a plan - // nor results still reports results-unlinked without opening the head source. + // is added OUTSIDE the object-source guard below — a run with no results still + // reports results-unlinked without opening the head source. if resultsPath == "" { add(ReasonRunResultsUnlinked, "") } - // Open the head's object source once when a blob read is needed. - if planPath != "" || resultsPath != "" { + // Open the head's object source when the results blob read is needed. + if resultsPath != "" { src, err := deps.Client.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(head)}) if err != nil { result, reason := classifyStatusError(ctx, classifyGitFailure(err)) @@ -370,28 +379,17 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } return runOperationalRefusal(result, reason, err.Error(), req.ID) } - if planPath != "" { - okFile, rerr := trackedRegularBlob(ctx, src, planPath) - if rerr != nil { - return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, rerr.Error(), req.ID) - } - if !okFile { - add(ReasonRunPlanMissing, planPath) - } + blob, okFile, rerr := trackedRegularBlobBytes(ctx, src, resultsPath) + if rerr != nil { + return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, rerr.Error(), req.ID) } - if resultsPath != "" { - blob, okFile, rerr := trackedRegularBlobBytes(ctx, src, resultsPath) - if rerr != nil { - return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, rerr.Error(), req.ID) - } - if !okFile { - // The linked path is absent or a symlink: identity, not prose. - add(ReasonRunResultsIdentity, resultsPath) - } else if fs := ValidateResultsContent(blob, ResultsPhaseFinal); len(fs) > 0 { - // The path resolves to a tracked regular file whose FINAL content - // contract fails. Observed names the path and the first finding. - add(ReasonRunResultsInvalid, resultsPath+": "+fs[0].Reason) - } + if !okFile { + // The linked path is absent or a symlink: identity, not prose. + add(ReasonRunResultsIdentity, resultsPath) + } else if fs := ValidateResultsContent(blob, ResultsPhaseFinal); len(fs) > 0 { + // The path resolves to a tracked regular file whose FINAL content + // contract fails. Observed names the path and the first finding. + add(ReasonRunResultsInvalid, resultsPath+": "+fs[0].Reason) } } @@ -533,8 +531,8 @@ func evaluateRunWaiting(changeID int, c domain.Change, workspaceHead string, r W // (non symlink) file at the source's pinned commit and, when it does, returns the // blob's bytes. A read-infrastructure error is returned as an error // (operational); a missing path or a symlink is a clean (nil, false, nil) — the -// caller maps that to the relevant postcondition miss. It is the single probe -// behind both trackedRegularBlob (identity only) and the results-content check. +// caller maps that to the relevant postcondition miss. It is the probe behind +// the results identity and content checks. func trackedRegularBlobBytes(ctx context.Context, src gitcli.ObjectSource, path string) ([]byte, bool, error) { results, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(path)}) if err != nil { @@ -548,11 +546,3 @@ func trackedRegularBlobBytes(ctx context.Context, src gitcli.ObjectSource, path } return results[0].Blob.Bytes, true, nil } - -// trackedRegularBlob reports whether path resolves to a tracked, regular (non -// symlink) file at the source's pinned commit, discarding the bytes. It is the -// identity-only view of trackedRegularBlobBytes. -func trackedRegularBlob(ctx context.Context, src gitcli.ObjectSource, path string) (bool, error) { - _, ok, err := trackedRegularBlobBytes(ctx, src, path) - return ok, err -} diff --git a/internal/app/run_verify_test.go b/internal/app/run_verify_test.go index ac64357a7..4c785c000 100644 --- a/internal/app/run_verify_test.go +++ b/internal/app/run_verify_test.go @@ -122,14 +122,17 @@ func newRunVerifyFixture(t *testing.T, publish bool) *rvFixture { } // deps assembles the run-verify deps over a fixture: the fake reader supplies the -// implemented corpus record, the fake workspace service reports the local head, -// the fake GitHub adapter reports the PR, and the real client performs the remote -// probe and blob reads. +// implemented corpus record and the plan on the metadata branch, the fake +// workspace service reports the local head, the fake GitHub adapter reports the +// PR, and the real client performs the remote probe and the head blob reads. func (f *rvFixture) deps(record []byte, pr githubcli.PullRequest) (PlanningDeps, WorkspaceDeps, GitHubDeps) { reader := &fakeReader{ pin: f.pin, corpus: []StatusBlob{{Kind: repository.KindChange, Location: repository.LocationActive, Path: groomPath(3, rvSlug), Revision: miRevision, Data: record}}, facts: domain.NewBranchFacts(nil), + artifactData: map[string]StatusArtifact{ + sourceMetadata + "|" + rvPlanPath: {Found: true, Revision: "planblob", Data: []byte("# plan\n")}, + }, } deps := PlanningDeps{Client: f.client, Reader: reader, Clock: testClock()} wdeps := WorkspaceDeps{Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}} diff --git a/internal/app/status.go b/internal/app/status.go index edbe2d0f3..f62371a79 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -113,7 +113,7 @@ type StatusReader interface { // live stack relationships and reports which exist on the remote. BranchFacts(ctx context.Context, pin StatusPin, branches []string) (domain.BranchFacts, error) // ArtifactExists reports whether a repo-relative path exists on the named - // pinned source ("metadata" for specs, "integration" for plans/results). + // pinned source ("metadata" or "integration"). ArtifactExists(ctx context.Context, pin StatusPin, source, path string) (bool, error) // ReadArtifact reads a repo-relative path from the named pinned source, // returning its exact bytes and blob object id, or Found=false when the @@ -516,27 +516,37 @@ func readinessReason(r domain.Readiness) string { } // artifactChecks verifies each non-empty artifact link of an active change -// against the source its kind lives on. A missing target is an error finding; -// an empty link produces no finding (a distinct, benign state). An -// ArtifactExists error propagates as an operation failure. +// against the sources its kind lives on, in order: the spec and the plan on the +// metadata branch — a plan absent there falls back to the integration branch, +// where a record closed before plans moved to the metadata branch keeps it — +// and results on the integration branch. A target absent from every source is +// an error finding; an empty link produces no finding (a distinct, benign +// state). An ArtifactExists error propagates as an operation failure. func artifactChecks(ctx context.Context, reader StatusReader, pin StatusPin, c domain.Change) ([]StatusFinding, error) { var findings []StatusFinding links := []struct { - field string - value domain.OptionalString - source string + field string + value domain.OptionalString + sources []string }{ - {"spec", c.Spec(), sourceMetadata}, - {"plan", c.Plan(), sourceIntegration}, - {"results", c.Results(), sourceIntegration}, + {"spec", c.Spec(), []string{sourceMetadata}}, + {"plan", c.Plan(), []string{sourceMetadata, sourceIntegration}}, + {"results", c.Results(), []string{sourceIntegration}}, } for _, link := range links { if link.value.State != domain.FieldPresent || link.value.Value == "" { continue } - exists, err := reader.ArtifactExists(ctx, pin, link.source, link.value.Value) - if err != nil { - return nil, err + exists := false + for _, source := range link.sources { + found, err := reader.ArtifactExists(ctx, pin, source, link.value.Value) + if err != nil { + return nil, err + } + if found { + exists = true + break + } } if !exists { findings = append(findings, StatusFinding{ diff --git a/internal/app/status_git.go b/internal/app/status_git.go index 544e21e5e..19681a53f 100644 --- a/internal/app/status_git.go +++ b/internal/app/status_git.go @@ -163,8 +163,8 @@ func (r *gitStatusReader) BranchFacts(ctx context.Context, pin StatusPin, branch } // ArtifactExists reports whether a repo-relative path exists on the named -// pinned source: "metadata" for specs, "integration" for plans and results. An -// absent path is a clean (false, nil). +// pinned source: "metadata" or "integration". An absent path is a clean +// (false, nil). func (r *gitStatusReader) ArtifactExists(ctx context.Context, pin StatusPin, source, artifactPath string) (bool, error) { rev, err := sourceRevision(pin, source) if err != nil { diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 85cb9dc0a..7bf0b1fd9 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -219,7 +219,7 @@ func TestStatusSourceDistinction(t *testing.T) { facts: domain.NewBranchFacts(nil), artifacts: map[string]bool{ "metadata|" + specPath: true, - "integration|" + planPath: true, + "metadata|" + planPath: true, "metadata|docs/changes/specs/spec-b.md": true, }, } @@ -229,20 +229,61 @@ func TestStatusSourceDistinction(t *testing.T) { t.Fatalf("result = %q, want applied; message=%q", got.Result, got.Message) } - var sawMetaSpec, sawIntegrationPlan bool + asked := map[string]bool{} for _, ask := range fake.artifactAsks { - if ask == "metadata|"+specPath { - sawMetaSpec = true - } - if ask == "integration|"+planPath { - sawIntegrationPlan = true - } + asked[ask] = true } - if !sawMetaSpec { + if !asked["metadata|"+specPath] { t.Errorf("spec was not checked against the metadata source; asks=%v", fake.artifactAsks) } - if !sawIntegrationPlan { - t.Errorf("plan was not checked against the integration source; asks=%v", fake.artifactAsks) + if !asked["metadata|"+planPath] { + t.Errorf("plan was not checked against the metadata source; asks=%v", fake.artifactAsks) + } + if asked["integration|"+planPath] { + t.Errorf("a plan present on the metadata branch was also asked of the integration source; asks=%v", fake.artifactAsks) + } + if hasFindingCode(got.Findings, string(FCArtifactMissing)) { + t.Errorf("a plan present on the metadata branch reported artifact-missing: %v", got.Findings) + } + + // A plan absent on the metadata branch falls back to the integration source + // (a record closed before plans moved there), and is missing only when both + // sources lack it. + for _, c := range []struct { + name string + integration bool + wantMissing bool + }{ + {"integration fallback", true, false}, + {"absent on both", false, true}, + } { + t.Run(c.name, func(t *testing.T) { + fb := &fakeReader{ + pin: pin, + corpus: corpus, + facts: domain.NewBranchFacts(nil), + artifacts: map[string]bool{ + "metadata|" + specPath: true, + "integration|" + planPath: c.integration, + "metadata|docs/changes/specs/spec-b.md": true, + }, + } + res := Status(context.Background(), fb, StatusOptions{}) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied; message=%q", res.Result, res.Message) + } + var sawMeta, sawIntegration bool + for _, ask := range fb.artifactAsks { + sawMeta = sawMeta || ask == "metadata|"+planPath + sawIntegration = sawIntegration || ask == "integration|"+planPath + } + if !sawMeta || !sawIntegration { + t.Errorf("plan absent on metadata must be asked of metadata then integration; asks=%v", fb.artifactAsks) + } + if got := hasFindingCode(res.Findings, string(FCArtifactMissing)); got != c.wantMissing { + t.Errorf("artifact-missing reported = %v, want %v (findings %v)", got, c.wantMissing, res.Findings) + } + }) } // The pin is threaded verbatim into every post-pin reader call. diff --git a/internal/app/workflow_e2e_test.go b/internal/app/workflow_e2e_test.go index 4cf29a55d..dc6491088 100644 --- a/internal/app/workflow_e2e_test.go +++ b/internal/app/workflow_e2e_test.go @@ -114,25 +114,23 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . } wp := prep.Path - // (5) The plan-writer half: author the plan body, stamp the deterministic - // backlink through the artifact-backlink operation (writing a feature-tree - // file, not a metadata transaction), then commit with the ADR-0094 single - // artifact and plan-path trailer. - writeRepoFile(t, wp, planPath, "# Implementation Plan\n\nConcrete steps here.\n") - bl := ArtifactBacklink(ctx, node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: planPath, ChangePath: recPath}) - if bl.Result != ResultApplied { - t.Fatalf("artifact backlink = %q (reason %q msg %q)", bl.Result, bl.Reason, bl.Message) - } - runGit(t, wp, "add", "-A") - runGit(t, wp, "commit", "-q", "-m", "write plan", "--trailer", "Docket-Plan-Path: "+planPath) - planHead := runGit(t, wp, "rev-parse", "HEAD") - - // (6) Attach the verified plan. - attach := ChangeAttachPlan(ctx, node.deps, wdeps, node.dir, - ChangeAttachRequest{ID: id, Revision: ver(), Path: planPath, Commit: planHead}) + // (5)–(6) The plan-writer half: author the plan body and hand it to + // attach-plan, which writes it on the metadata branch with its backlink + // in one transaction. Nothing is committed on the feature branch. + prepHead := runGit(t, wp, "rev-parse", "HEAD") + attach := ChangeAttachPlan(ctx, node.deps, node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: planPath, Markdown: []byte("# Implementation Plan\n\nConcrete steps here.\n"), + }) if attach.Result != ResultApplied { t.Fatalf("attach plan = %q (reason %q msg %q findings %v)", attach.Result, attach.Reason, attach.Message, attach.Findings) } + storedPlan, ok := originFile(t, repo.origin, m.branch, planPath) + if !ok || !strings.Contains(storedPlan, "docket:backlink:start") || !strings.Contains(storedPlan, "Concrete steps here.") { + t.Fatalf("plan file at the metadata remote tip is missing or lacks its backlink:\n%s", storedPlan) + } + if got := runGit(t, wp, "rev-parse", "HEAD"); got != prepHead { + t.Fatalf("planning moved the feature head %s -> %s", prepHead, got) + } // (7) The implementation commit advances the feature head. A results artifact is // REQUIRED at the implemented boundary (change 0410), so it rides this commit diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index ceaa2f121..7c8fda196 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -18,9 +18,10 @@ import ( ) // TestAttachPlanBoardLinkAtomicity proves the attach-plan metadata transaction is -// atomic: the single applied commit rewrites the change record (its plan: field -// and its re-rendered artifact block) and the inline board together — never one -// without the other. It inspects the winning commit's exact changed-path set. +// atomic: the single applied commit writes the plan file, rewrites the change +// record (its plan: field and its re-rendered artifact block), and re-renders the +// inline board together — never one without the others. It inspects the winning +// commit's exact changed-path set. func TestIntegrationWorkflowRepoAttachPlanBoardLinkAtomicity(t *testing.T) { requireRealGit(t) const ( @@ -37,21 +38,10 @@ func TestIntegrationWorkflowRepoAttachPlanBoardLinkAtomicity(t *testing.T) { }) revision := blobRevisionAt(t, repo.origin, m.branch, recPath) 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} ctx := context.Background() - prep := WorkspacePrepare(ctx, node.deps, wdeps, repo.invocation, WorkspaceIDRequest{ID: id, Revision: revision}) - if prep.Result != ResultApplied { - t.Fatalf("prepare workspace = %q (reason %q msg %q)", prep.Result, prep.Reason, prep.Message) - } - head := commitPlanFile(t, prep.Path, planPath, attachHappyPlan(id, "A change", recPath), planPath) - - res := ChangeAttachPlan(ctx, node.deps, wdeps, repo.invocation, - ChangeAttachRequest{ID: id, Revision: revision, Path: planPath, Commit: head}) + res := ChangeAttachPlan(ctx, node.deps, repo.invocation, + ChangeAttachRequest{ID: id, Revision: revision, Path: planPath, Markdown: []byte(attachHappyPlanBody())}) if res.Result != ResultApplied { t.Fatalf("attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) } @@ -59,12 +49,13 @@ func TestIntegrationWorkflowRepoAttachPlanBoardLinkAtomicity(t *testing.T) { t.Fatalf("applied attach carried no committed revision") } - // The one metadata commit changes exactly the change record and the board. - want := []string{"docs/changes/BOARD.md", recPath} + // The one metadata commit changes exactly the plan file, the change + // record, and the board. + want := []string{"docs/changes/BOARD.md", recPath, planPath} sort.Strings(want) got := originCommitPaths(t, repo.origin, res.Revision) if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("attach commit changed %v, want exactly %v (record + board in one commit)", got, want) + t.Errorf("attach commit changed %v, want exactly %v (plan + record + board in one commit)", got, want) } final, ok := originFile(t, repo.origin, m.branch, recPath) if !ok || !strings.Contains(final, "plan: '"+planPath+"'") { @@ -74,182 +65,137 @@ func TestIntegrationWorkflowRepoAttachPlanBoardLinkAtomicity(t *testing.T) { } } -// TestChangeAttachPlanGitVerification is the guard-table mutation test: each row -// corrupts exactly one property of the happy fixture and asserts the operation -// refuses with that guard's stable reason. -func TestIntegrationWorkflowRepoChangeAttachPlanGitVerification(t *testing.T) { - f := attachSetup(t) +// attachOutcomeCode is the stable machine reason of a refused attach: the +// pre-transaction reason, or the in-transaction refusal's finding code. +func attachOutcomeCode(res ChangeAttachResult) string { + if res.Reason != "" { + return res.Reason + } + if len(res.Findings) > 0 { + return res.Findings[0].Code + } + return "" +} + +// TestIntegrationWorkflowRepoChangeAttachPlanMetadataRefusals is the guard table +// against real git: each row starts from the happy fixture, corrupts exactly one +// property, and asserts the operation refuses with that guard's stable reason +// and leaves the plan unlinked and the plan path untouched on the remote. +func TestIntegrationWorkflowRepoChangeAttachPlanMetadataRefusals(t *testing.T) { otherPlan := "docs/superpowers/plans/2026-08-17-decoy.md" + foreign := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + "\n# Other plan\n\nSteps.\n" rows := []struct { - name string - build func(t *testing.T) ChangeAttachRequest // resets, mutates, returns the request + name string + // build prepares the remote and returns the request. + build func(t *testing.T, f *attachFixture) ChangeAttachRequest reason string + // planAt is the path whose remote bytes must be unchanged by the refusal. + planAt string }{ { name: "plan outside the planning root", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - head := f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: "docs/notes/outside.md", Commit: head} + build: func(t *testing.T, f *attachFixture) ChangeAttachRequest { + return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: "docs/notes/outside.md", Markdown: []byte(attachHappyPlanBody())} }, reason: ReasonAttachPathOutsideRoot, }, - { - name: "commit is not the feature head", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - _ = f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) - // The base is a real commit that is not the head. - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: f.base} - }, - reason: ReasonAttachCommitNotHead, - }, - { - name: "commit does not descend from the prepared base", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - // A root (orphan) commit carrying a valid plan: it is the head, but it - // does not descend from the prepared base. - runGit(t, f.wp, "checkout", "-q", "--orphan", "_orphan") - writeRepoFile(t, f.wp, f.planPath, attachHappyPlan(f.id, "A change", f.recPath)) - runGit(t, f.wp, "add", "-A") - runGit(t, f.wp, "commit", "-q", "-m", "orphan plan", "--trailer", "Docket-Plan-Path: "+f.planPath) - orphan := runGit(t, f.wp, "rev-parse", "HEAD") - runGit(t, f.wp, "branch", "-qf", "feat/"+f.slug, "_orphan") - runGit(t, f.wp, "checkout", "-q", "feat/"+f.slug) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: orphan} - }, - reason: ReasonAttachCommitNotDescendant, - }, - { - name: "artifact path is not tracked at the commit", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - // The commit adds a DIFFERENT file; the requested plan path is absent. - head := f.commitPlan(t, map[string]string{otherPlan: attachHappyPlan(f.id, "A change", f.recPath)}, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} - }, - reason: ReasonAttachUntrackedFile, - }, - { - name: "artifact is a symlink at the commit", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - symlinkRepoFile(t, f.wp, f.planPath, "README.md") - runGit(t, f.wp, "add", "-A") - runGit(t, f.wp, "commit", "-q", "-m", "symlink plan", "--trailer", "Docket-Plan-Path: "+f.planPath) - head := runGit(t, f.wp, "rev-parse", "HEAD") - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} - }, - reason: ReasonAttachSymlinkedPlan, - }, - { - name: "writer commit changes two artifacts", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - head := f.commitPlan(t, map[string]string{ - f.planPath: attachHappyPlan(f.id, "A change", f.recPath), - "docs/superpowers/plans/2026-08-17-extra.md": "# extra\n", - }, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} - }, - reason: ReasonAttachMultiArtifactDelta, - }, - { - name: "writer commit is a rename (two-sided with --no-renames)", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - // A prior commit places an old file; the writer commit renames it to - // the plan path. With rename detection off this is a delete + an add. - old := "docs/superpowers/plans/2026-08-17-old.md" - f.commitPlan(t, map[string]string{old: attachHappyPlan(f.id, "A change", f.recPath)}, "") - runGit(t, f.wp, "mv", old, f.planPath) - runGit(t, f.wp, "commit", "-q", "-m", "rename plan", "--trailer", "Docket-Plan-Path: "+f.planPath) - head := runGit(t, f.wp, "rev-parse", "HEAD") - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} - }, - reason: ReasonAttachMultiArtifactDelta, - }, - { - name: "writer commit lacks the plan-path trailer", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - head := f.commitPlan(t, map[string]string{f.planPath: attachHappyPlan(f.id, "A change", f.recPath)}, "") - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} - }, - reason: ReasonAttachMissingTrailer, - }, { name: "backlink markers are unbalanced", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) + build: func(t *testing.T, f *attachFixture) ChangeAttachRequest { // A dangling start marker with no partner: the whole-population marker // validation fails the parse. bad := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n> ↩ dangling\n\n# Plan\n\nSteps.\n" - head := f.commitPlan(t, map[string]string{f.planPath: bad}, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} + return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Markdown: []byte(bad)} }, reason: ReasonAttachUnbalancedBacklink, }, { - name: "backlink targets another change", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - // A balanced backlink pointing at a different change. - bad := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + "\n# Plan\n\nSteps.\n" - head := f.commitPlan(t, map[string]string{f.planPath: bad}, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} + name: "plan whose section body is only a placeholder token", + build: func(t *testing.T, f *attachFixture) ChangeAttachRequest { + // A whole-slot filler: the section's entire body is the bare token. + // A plan that merely MENTIONS a token attaches (the happy path + // proves that direction). + withFillerSlot := "# Plan\n\n## Error handling\n\n" + tok("tbd") + "\n" + return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Markdown: []byte(withFillerSlot)} }, - reason: ReasonAttachBacklinkMismatch, + reason: ReasonAttachPlaceholderToken, }, { - name: "plan whose section body is only a placeholder token", - build: func(t *testing.T) ChangeAttachRequest { - f.reset(t) - // A whole-slot filler: the section's entire body is the bare - // token. A plan that merely MENTIONS a token attaches (the - // success test proves that direction). - withFillerSlot := attachBacklinkBlock(f.id, "A change", f.recPath) + - "\n# Plan\n\n## Error handling\n\n" + tok("tbd") + "\n" - head := f.commitPlan(t, map[string]string{f.planPath: withFillerSlot}, f.planPath) - return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head} + name: "plan: already names a different path", + build: func(t *testing.T, f *attachFixture) ChangeAttachRequest { + first := ChangeAttachPlan(f.ctx, f.deps, f.invocation, ChangeAttachRequest{ + ID: f.id, Revision: f.revision, Path: otherPlan, Markdown: []byte(attachHappyPlanBody()), + }) + if first.Result != ResultApplied { + t.Fatalf("seed attach = %q (reason %q findings %v)", first.Result, first.Reason, first.Findings) + } + return ChangeAttachRequest{ + ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, + Markdown: []byte(attachHappyPlanBody()), + } }, - reason: ReasonAttachPlaceholderToken, + reason: ReasonAttachPathMismatch, + }, + { + name: "another change's file already occupies the path", + build: func(t *testing.T, f *attachFixture) ChangeAttachRequest { + advanceDocketOrigin(t, f.repo, map[string]string{f.planPath: foreign}) + return ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Markdown: []byte(attachHappyPlanBody())} + }, + reason: ReasonAttachPathOccupied, + planAt: "docs/superpowers/plans/2026-08-17-widget-plan.md", }, } for _, row := range rows { row := row t.Run(row.name, func(t *testing.T) { - req := row.build(t) - res := ChangeAttachPlan(f.ctx, f.deps, f.wdeps, f.invocation, req) + f := attachSetup(t) + req := row.build(t, f) + var before string + var hadBefore bool + if row.planAt != "" { + before, hadBefore = originFile(t, f.repo.origin, "docket", row.planAt) + } + tip := originTip(t, f.repo.origin, "docket") + + res := ChangeAttachPlan(f.ctx, f.deps, f.invocation, req) if res.Result == ResultApplied { t.Fatalf("%s: attach applied, want a refusal", row.name) } - if res.Reason != row.reason { - t.Fatalf("%s: reason = %q, want %q (msg %q)", row.name, res.Reason, row.reason, res.Message) + if got := attachOutcomeCode(res); got != row.reason { + t.Fatalf("%s: reason = %q, want %q (msg %q findings %v)", row.name, got, row.reason, res.Message, res.Findings) } - // A refusal opens no transaction: the remote record keeps no plan field. - final, ok := originFile(t, f.repo.origin, "docket", f.recPath) - if ok && strings.Contains(final, "plan: '") { - t.Errorf("%s: a refused attach wrote the plan field to the remote", row.name) + // A refusal commits nothing: the metadata remote did not move, the + // requested path was never linked, and an occupied file is untouched. + if got := originTip(t, f.repo.origin, "docket"); got != tip { + t.Errorf("%s: a refused attach moved the metadata branch %s -> %s", row.name, tip, got) + } + final, _ := originFile(t, f.repo.origin, "docket", f.recPath) + if strings.Contains(final, "plan: '"+req.Path+"'") { + t.Errorf("%s: a refused attach linked %q on the remote", row.name, req.Path) + } + if row.planAt != "" { + after, hasAfter := originFile(t, f.repo.origin, "docket", row.planAt) + if hasAfter != hadBefore || after != before { + t.Errorf("%s: a refused attach changed %q on the remote", row.name, row.planAt) + } } }) } } -// TestChangeAttachPlanGitVerificationHappyPath proves a correctly written plan -// commit passes every from-Git guard and lands the metadata transaction: the -// change record on the remote gains the plan: field, rendered by the engine. -func TestIntegrationWorkflowRepoChangeAttachPlanGitVerificationHappyPath(t *testing.T) { +// TestIntegrationWorkflowRepoChangeAttachPlanMetadataHappyPath proves a plan +// attach lands one metadata commit: the plan file exists at the metadata remote +// tip carrying the backlink rendered for this change ahead of the authored body, +// the record gains the plan: field, and the feature workspace never moves. +func TestIntegrationWorkflowRepoChangeAttachPlanMetadataHappyPath(t *testing.T) { f := attachSetup(t) - head := f.commitPlan(t, map[string]string{ - f.planPath: attachHappyPlan(f.id, "A change", f.recPath), - }, f.planPath) + headBefore := runGit(t, f.wp, "rev-parse", "HEAD") - res := ChangeAttachPlan(f.ctx, f.deps, f.wdeps, f.invocation, - ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Commit: head}) + res := ChangeAttachPlan(f.ctx, f.deps, f.invocation, + ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: f.planPath, Markdown: []byte(attachHappyPlanBody())}) if res.Result != ResultApplied { t.Fatalf("happy attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) } @@ -263,6 +209,28 @@ func TestIntegrationWorkflowRepoChangeAttachPlanGitVerificationHappyPath(t *test if !strings.Contains(final, "plan: '"+f.planPath+"'") { t.Errorf("committed record missing the plan field:\n%s", final) } + plan, ok := originFile(t, f.repo.origin, "docket", f.planPath) + if !ok { + t.Fatalf("plan file missing at the metadata remote tip") + } + if want := attachHappyPlan(f.id, "A change", f.recPath); plan != want { + t.Errorf("stored plan =\n%q\nwant\n%q", plan, want) + } + if got := runGit(t, f.wp, "rev-parse", "HEAD"); got != headBefore { + t.Errorf("attaching the plan moved the feature head %s -> %s", headBefore, got) + } + + // Resubmitting the stored plan exactly as read back (backlink included) is + // no change: the stored bytes stay byte-identical. + again := ChangeAttachPlan(f.ctx, f.deps, f.invocation, ChangeAttachRequest{ + ID: f.id, Revision: blobRevisionAt(t, f.repo.origin, "docket", f.recPath), Path: f.planPath, Markdown: []byte(plan), + }) + if again.Result != ResultApplied && again.Result != ResultNoOp { + t.Fatalf("read-back resubmit = %q (reason %q findings %v)", again.Result, again.Reason, again.Findings) + } + if got, _ := originFile(t, f.repo.origin, "docket", f.planPath); got != plan { + t.Errorf("read-back resubmit changed the stored plan:\n%q\nwant\n%q", got, plan) + } } // TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent proves @@ -282,7 +250,7 @@ func TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent(t *testing. // template prompts (change 0414 — derived from the shipped template). scaffold := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n# <Change title> — Results\n\n## Outcome\n\n<The original problem, the delivered behavior, and any material departure from the\nagreed design — lead with observable effects. Explain unfamiliar Docket concepts when\nnecessary; include method names, stored fields, or internal identifiers only when they\nhelp the reader understand a consequence or take action.>\n" - head := f.commitPlan(t, map[string]string{resultsPath: scaffold}, "") + head := f.commitArtifact(t, map[string]string{resultsPath: scaffold}) res := ChangeAttachResults(f.ctx, f.deps, f.wdeps, f.invocation, ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Commit: head}) if res.Result == ResultApplied { @@ -304,7 +272,7 @@ func TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent(t *testing. // checkpoint artifact must not be held to the final content contract. artifact := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n# Widget — Results\n\n## Outcome\n\nDelivered the in-progress slice; behavior X now refuses Y.\n" - head := f.commitPlan(t, map[string]string{resultsPath: artifact}, "") + head := f.commitArtifact(t, map[string]string{resultsPath: artifact}) res := ChangeAttachResults(f.ctx, f.deps, f.wdeps, f.invocation, ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Commit: head}) if res.Result != ResultApplied { @@ -505,11 +473,12 @@ func TestIntegrationWorkflowLifecycleClaimToImplementedWorkflow(t *testing.T) { } // TestEffectiveBaseConsumedFromDomain proves a stacked change's workspace prepare -// and attach ancestry checks run against the PARENT branch base that -// domain.ResolveEffectiveBase resolves — not the integration branch. The child's -// workspace is created at the parent feature branch tip, and the plan committed -// on it (which descends from that tip, not from main) passes attach's descendant -// check. Both metadata modes. +// runs against the PARENT branch base that domain.ResolveEffectiveBase resolves — +// not the integration branch: the child's workspace is created at the parent +// feature branch tip. The plan half once pinned attach's ancestry check against +// that base; the plan is now written on the metadata branch, so no plan commit +// lands on the workspace and no ancestry property remains — the attach below +// only proves a stacked child attaches its plan without moving the workspace. func TestIntegrationWorkflowRepoEffectiveBaseConsumedFromDomain(t *testing.T) { requireRealGit(t) const ( @@ -569,14 +538,15 @@ func TestIntegrationWorkflowRepoEffectiveBaseConsumedFromDomain(t *testing.T) { t.Fatalf("prepared base is the integration branch tip; the stacked base was not consumed from the domain") } - // A plan committed on the parent-based workspace descends from the parent - // tip, so attach's ancestry check (run against the same resolved base) - // passes. - head := commitPlanFile(t, prep.Path, childPlan, attachHappyPlan(childID, "A change", childRec), childPlan) - res := ChangeAttachPlan(ctx, node.deps, wdeps, repo.invocation, - ChangeAttachRequest{ID: childID, Revision: revision, Path: childPlan, Commit: head}) + // The stacked child attaches its plan on the metadata branch; the + // parent-based workspace stays at the resolved base. + res := ChangeAttachPlan(ctx, node.deps, repo.invocation, + ChangeAttachRequest{ID: childID, Revision: revision, Path: childPlan, Markdown: []byte(attachHappyPlanBody())}) if res.Result != ResultApplied { - t.Fatalf("attach on the stacked workspace = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) + t.Fatalf("attach for the stacked child = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) + } + if got := runGit(t, prep.Path, "rev-parse", "HEAD"); got != parentTip { + t.Errorf("attaching the plan moved the stacked workspace off the parent base: %s, want %s", got, parentTip) } }) } diff --git a/internal/cli/change.go b/internal/cli/change.go index 830989236..bc09e2440 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -110,10 +110,11 @@ func newChangeCommand(setResult func(app.OperationResult)) *cobra.Command { setResult(app.ChangeReconcile(c.Context(), deps, repoDir, req)) }, EffectMetadataWrite) - attachPlan := changeAttachSubcommand("attach-plan", - "Verify a written plan from Git and link it to an in-progress change", - func(c *cobra.Command, deps app.PlanningDeps, wdeps app.WorkspaceDeps, repoDir string, req app.ChangeAttachRequest) { - setResult(app.ChangeAttachPlan(c.Context(), deps, wdeps, repoDir, req)) + attachPlan := changeAttachMarkdownSubcommand("attach-plan", + "Write a plan on the metadata branch and link it to an in-progress change", + "plan Markdown `file`, or - for stdin; the operation writes it on the metadata branch with its backlink (required)", + func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest) { + setResult(app.ChangeAttachPlan(c.Context(), deps, repoDir, req)) }, EffectMetadataWrite) attachResults := changeAttachSubcommand("attach-results", @@ -342,11 +343,57 @@ func newMarkImplementedSubcommand(setResult func(app.OperationResult)) *cobra.Co return cmd } +// changeAttachMarkdownSubcommand builds one `change <verb>` command that writes +// an authored artifact on the metadata branch and links it: the (id, revision, +// path) scalars ride flags, and the artifact body is the raw Markdown read from +// `--markdown <file>` (or `-` for stdin) — a non-JSON file input (ADR-0138) the +// operation stores verbatim behind its rendered backlink. It needs only the +// planning seams: nothing reads the feature workspace. +func changeAttachMarkdownSubcommand(verb, short, markdownUsage string, run func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest), effects ...Effect) *cobra.Command { + cmd := &cobra.Command{ + Use: verb, + Short: short, + Args: cobra.NoArgs, + Annotations: capability("change."+verb, effects...), + RunE: func(c *cobra.Command, _ []string) error { + repoDir, err := resolveRepoDir(c) + if err != nil { + return err + } + id, _ := c.Flags().GetInt("id") + revision, _ := c.Flags().GetString("revision") + artifactPath, _ := c.Flags().GetString("path") + src, _ := c.Flags().GetString("markdown") + markdown, err := readRecordSource(c.InOrStdin(), src) + if err != nil { + return err + } + deps, err := newPlanningDeps(repoDir) + if err != nil { + return err + } + run(c, deps, repoDir, app.ChangeAttachRequest{ID: id, Revision: revision, Path: artifactPath, Markdown: markdown}) + return nil + }, + } + cmd.Flags().Int("id", 0, "change `id` to attach the artifact to (required)") + cmd.Flags().String("revision", "", "exact record `revision` (the blob object id) from the authoritative context read (required)") + cmd.Flags().String("path", "", "canonical repository-relative artifact `path` (required)") + cmd.Flags().String("markdown", "", markdownUsage) + cmd.Flags().String("repo-dir", "", "repository `dir` to operate on (default: current directory)") + _ = cmd.MarkFlagRequired("id") + _ = cmd.MarkFlagRequired("revision") + _ = cmd.MarkFlagRequired("path") + _ = cmd.MarkFlagRequired("markdown") + return cmd +} + // changeAttachSubcommand builds one `change <verb>` command whose input is the -// (id, revision, path, commit) tuple: an attach verifies a written artifact from -// Git and links it, so it takes scalar flags (Global Constraints: request files -// are for authored Markdown, never these). It builds the workspace-backed deps -// the attach operation needs to inspect the owned checkout. +// (id, revision, path, commit) tuple: attach-results verifies an artifact +// committed at the feature head from Git and links it, so it takes scalar flags +// (Global Constraints: request files are for authored Markdown, never these). It +// builds the workspace-backed deps the attach operation needs to inspect the +// owned checkout. func changeAttachSubcommand(verb, short string, run func(c *cobra.Command, deps app.PlanningDeps, wdeps app.WorkspaceDeps, repoDir string, req app.ChangeAttachRequest), effects ...Effect) *cobra.Command { cmd := &cobra.Command{ Use: verb, diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go index 782ddacc9..41095bb5e 100644 --- a/internal/cli/change_test.go +++ b/internal/cli/change_test.go @@ -304,22 +304,37 @@ func TestChangeClaimCommandsReachOperation(t *testing.T) { } // TestChangeAttachCommandsRegistered proves attach-plan and attach-results are -// wired as change subcommands carrying the scalar --id/--revision/--path/--commit -// flags (no --request: they name a verified Git artifact, not authored Markdown). +// wired as change subcommands carrying their kind's flag set: attach-plan takes +// the scalar --id/--revision/--path plus the --markdown body file (the plan is +// written on the metadata branch) and no --commit; attach-results still names a +// verified feature commit with --commit. func TestChangeAttachCommandsRegistered(t *testing.T) { root := captureTree(t) - for _, sub := range []string{"attach-plan", "attach-results"} { - cmd, _, err := root.Find([]string{"change", sub}) - if err != nil || cmd == nil || cmd.Name() != sub { - t.Fatalf("change %s not registered: cmd=%v err=%v", sub, cmd, err) + cases := []struct { + sub string + flags []string + without []string + }{ + {"attach-plan", []string{"id", "revision", "path", "markdown", "repo-dir"}, []string{"commit"}}, + {"attach-results", []string{"id", "revision", "path", "commit", "repo-dir"}, []string{"markdown"}}, + } + for _, c := range cases { + cmd, _, err := root.Find([]string{"change", c.sub}) + if err != nil || cmd == nil || cmd.Name() != c.sub { + t.Fatalf("change %s not registered: cmd=%v err=%v", c.sub, cmd, err) } - for _, flag := range []string{"id", "revision", "path", "commit"} { + for _, flag := range c.flags { if cmd.Flags().Lookup(flag) == nil { - t.Errorf("change %s: missing --%s flag", sub, flag) + t.Errorf("change %s: missing --%s flag", c.sub, flag) + } + } + for _, flag := range c.without { + if cmd.Flags().Lookup(flag) != nil { + t.Errorf("change %s: unexpected --%s flag", c.sub, flag) } } - if !assetIndependent["change "+sub] { - t.Errorf("change %s is not registered asset-independent", sub) + if !assetIndependent["change "+c.sub] { + t.Errorf("change %s is not registered asset-independent", c.sub) } } } @@ -387,6 +402,12 @@ func TestChangeAttachFlagsRequired(t *testing.T) { if code != 2 || errS == "" { t.Fatalf("err=%q code=%d, want a required-flag argument error", errS, code) } + // The plan body is required: every scalar present but no --markdown. + _, errS, code = runCLI(t, "change", "attach-plan", "--id", "7", + "--revision", "1234123412341234123412341234123412341234", "--path", "docs/superpowers/plans/x.md") + if code != 2 || !strings.Contains(errS, "markdown") { + t.Fatalf("err=%q code=%d, want a required --markdown argument error", errS, code) + } } // TestChangeAttachCommandsReachOperation proves both attach commands decode their @@ -394,15 +415,22 @@ func TestChangeAttachFlagsRequired(t *testing.T) { // naming it. A bare tempdir is no docket repo, so the operation fails past its // shape check — but only after naming itself. func TestChangeAttachCommandsReachOperation(t *testing.T) { - cases := []struct{ sub, op string }{ - {"attach-plan", "change.attach-plan"}, - {"attach-results", "change.attach-results"}, + dir := testsupport.TempDir(t) + planFile := filepath.Join(dir, "plan.md") + if err := os.WriteFile(planFile, []byte("# Plan\n\n## Task 1\n\nDo it.\n"), 0o644); err != nil { + t.Fatalf("seed plan: %v", err) + } + cases := []struct { + sub, op string + args []string + }{ + {"attach-plan", "change.attach-plan", []string{"--path", "docs/superpowers/plans/x.md", "--markdown", planFile}}, + {"attach-results", "change.attach-results", []string{"--path", "docs/results/x-results.md", "--commit", "1234123412341234123412341234123412341234"}}, } for _, c := range cases { - out, errS, _ := runCLI(t, "change", c.sub, - "--id", "7", "--revision", "1234123412341234123412341234123412341234", - "--path", "docs/superpowers/plans/x.md", "--commit", "1234123412341234123412341234123412341234", - "--repo-dir", testsupport.TempDir(t), "--json") + args := append([]string{"change", c.sub, "--id", "7", "--revision", "1234123412341234123412341234123412341234"}, c.args...) + args = append(args, "--repo-dir", testsupport.TempDir(t), "--json") + out, errS, _ := runCLI(t, args...) if errS != "" { t.Fatalf("%s: unexpected stderr %q", c.sub, errS) } diff --git a/internal/cli/revision_rename_test.go b/internal/cli/revision_rename_test.go index 16315565a..0975b06b5 100644 --- a/internal/cli/revision_rename_test.go +++ b/internal/cli/revision_rename_test.go @@ -159,7 +159,7 @@ func TestRevisionFlagReachesRequest(t *testing.T) { {"", []string{"change", "refresh-claim", "--id", "1"}}, {"", []string{"change", "reclaim", "--id", "1"}}, {"", []string{"change", "resume-halted", "--id", "1"}}, - {"", []string{"change", "attach-plan", "--id", "1", "--path", "p.md", "--commit", head}}, + {"# Plan\n\nSteps.\n", []string{"change", "attach-plan", "--id", "1", "--path", "p.md", "--markdown", "-"}}, {"", []string{"change", "attach-results", "--id", "1", "--path", "r.md", "--commit", head}}, {"", []string{"change", "mark-implemented", "--id", "1", "--head", head, "--pr", "https://github.com/o/r/pull/1", "--evidence", ev}}, {"{}", []string{"change", "halt", "--id", "1", "--input", "-"}}, From 1ba55160944ddf734b48073a537cc6a439058d28 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 01:49:23 -0400 Subject: [PATCH 09/28] feat(attach-results): results checkpoints are metadata commits; readers follow change.attach-results now carries the results Markdown (--markdown <file|->) and runs the same metadata transaction as attach-plan: it writes the results file on the metadata branch behind its freshly rendered backlink, sets results: and updated:, and re-renders the artifact block and board in one commit. A later checkpoint at the linked path replaces the content; the feature branch is never read or written, so a checkpoint after the gate leaves the tested head (and its evidence) intact. Checkpoint-phase content validation still refuses raw template scaffolding (results-content-invalid), now before anything is read. The feature-head driver is deleted with everything only it used: --commit, ChangeAttachRequest.Commit, the WorkspaceDeps parameter, readAttachBlob, verifyBacklink, and the commit-not-head, untracked-file, symlinked-plan, missing-backlink, backlink-mismatch, and artifact-unreadable reasons, plus the now-unminted empty-commit finding code. Both CLI verbs share one flag set (--id --revision --path --markdown). mark-implemented (condition 5) and run.verify read results on the pinned metadata tip; a results file present only at the feature head is results-identity-broken. status checks results on the metadata branch with an integration-branch fallback for records closed before results moved. The run-fence ownership fixtures now serve the plan and results on the metadata branch too. --- internal/app/change_attach.go | 370 ++++-------------- .../app/change_attach_git_integration_test.go | 28 +- internal/app/change_attach_test.go | 157 ++++++-- internal/app/change_groom.go | 4 +- internal/app/change_groom_test.go | 4 +- internal/app/change_implemented.go | 51 ++- .../change_implemented_integration_test.go | 3 + internal/app/change_implemented_test.go | 43 +- internal/app/change_integration_test.go | 21 +- internal/app/finalize_e2e_test.go | 15 +- internal/app/finding_codes.go | 2 - .../app/named_isolation_integration_test.go | 9 +- internal/app/plan_content.go | 3 +- internal/app/run_verify.go | 60 +-- internal/app/run_verify_test.go | 51 ++- .../runtracker_ownership_integration_test.go | 19 +- internal/app/status.go | 10 +- internal/app/status_test.go | 107 ++--- internal/app/workflow_e2e_test.go | 54 ++- internal/app/workflow_integration_test.go | 86 ++-- internal/cli/change.go | 65 +-- internal/cli/change_test.go | 44 ++- internal/cli/revision_rename_test.go | 2 +- 23 files changed, 563 insertions(+), 645 deletions(-) diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index 313b9ccc7..f9d64f23b 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -22,27 +22,24 @@ import ( ) // This file is the `change attach-plan` and `change attach-results` operations: -// two exact-revision metadata transitions that link an authored artifact (a -// plan, or a results record) to its change and re-render every affected v1-owned -// derived view (the change record's plan:/results: field, its refreshed updated -// date, its artifact block, and the inline board) as one atomic transaction. -// The two kinds run under different profiles: +// two exact-revision metadata transitions that carry an authored artifact (a +// plan, or a results record), WRITE it on the metadata branch, and link it to +// its change, re-rendering every affected v1-owned derived view (the change +// record's plan:/results: field, its refreshed updated date, its artifact +// block, and the inline board) in one atomic transaction (changeAttachMetadata). +// The feature branch is never read or written: a results checkpoint is a +// metadata commit, so it never moves the tested feature head. // -// - attach-plan (changeAttachMetadata) carries the plan Markdown itself and -// WRITES the plan file on the metadata branch in the same transaction, the -// way change.groom writes a spec: any submitted docket:backlink block is -// replaced by the freshly rendered one, the body must be non-empty, bounded, -// parse cleanly, and carry no placeholder-only slot (change 0414), and the -// path must sit inside the planning root. Inside the transaction a field that -// already names a different path refuses, and a file already at the path -// that does not point home to THIS change is never overwritten. A re-plan at -// the linked path replaces the content. -// - attach-results (changeAttach) verifies an artifact already committed on -// the feature branch from Git, never from the child agent's return: the -// writer commit must be the current feature head, the results file a regular -// tracked file inside the configured results root carrying a balanced -// backlink that targets THIS change and passing checkpoint-phase content -// validation. Only after every check passes does the transaction open. +// Both kinds share one profile, the way change.groom writes a spec: any +// submitted docket:backlink block is replaced by the freshly rendered one, the +// body must be non-empty, bounded, and parse cleanly, and the path must sit +// inside the kind's planning root (the plans directory, or the configured +// results root). The content rule is kind-specific: a plan carries no +// placeholder-only slot (change 0414); results pass checkpoint-phase content +// validation (the FINAL contract binds at mark-implemented). Inside the +// transaction a field that already names a different path refuses, and a file +// already at the path that does not point home to THIS change is never +// overwritten. A later attach at the linked path replaces the content. // // Idempotency is keyed on the PROMISED state — the (id, path, artifact bytes) // triple — so a lost-response retry replays the original applied receipt rather @@ -55,9 +52,7 @@ const ( ) // The two artifact kinds an attach operation links. Kind selects the owned -// frontmatter field, the allowed planning root, and the verification profile -// (the plan is written on the metadata branch; results are verified at the -// feature head). +// frontmatter field, the allowed planning root, and the content rule. const ( attachKindPlan = "plan" attachKindResults = "results" @@ -87,18 +82,9 @@ const ( // than one; the operation never chooses. ReasonAttachUnknownChange = "unknown-change" ReasonAttachAmbiguousID = "ambiguous-change" - // ReasonAttachCommitNotHead: the verified commit is not the current feature - // head, so it does not describe the checkout the change owns (results). - ReasonAttachCommitNotHead = "commit-not-head" - // ReasonAttachUntrackedFile: the artifact path is not a tracked file at the - // verified commit (results). - ReasonAttachUntrackedFile = "untracked-file" - // ReasonAttachSymlinkedPlan: the artifact path is a symlink at the commit, not - // a regular file (results). - ReasonAttachSymlinkedPlan = "symlinked-plan" - // ReasonAttachUnbalancedBacklink: the artifact's managed backlink markers are - // malformed (dangling/out-of-order/nested) — the body will not parse. For a - // plan it is the submitted Markdown that refuses, before anything is read. + // ReasonAttachUnbalancedBacklink: the submitted Markdown's managed backlink + // markers are malformed (dangling/out-of-order/nested) — the body will not + // parse, so it refuses before anything is read. ReasonAttachUnbalancedBacklink = "unbalanced-backlink" // ReasonAttachEmptyMarkdown: the submitted artifact Markdown is empty or // whitespace-only. @@ -113,19 +99,11 @@ const ( // metadata branch and its backlink does not point home to this change, so // the attach refuses rather than overwrite another change's artifact. ReasonAttachPathOccupied = "artifact-path-occupied" - // ReasonAttachMissingBacklink: the artifact carries no docket:backlink block. - ReasonAttachMissingBacklink = "missing-backlink" - // ReasonAttachBacklinkMismatch: the artifact's backlink targets a different - // change than the one being attached. - ReasonAttachBacklinkMismatch = "backlink-mismatch" // ReasonAttachPlaceholderToken: a plan slot — a section body or the // pre-heading preamble — contains only a bare placeholder token (an // unfilled slot; change 0414). A token mentioned inside substantive // content never refuses. ReasonAttachPlaceholderToken = "placeholder-token" - // ReasonAttachArtifactUnreadable: the verified commit or its blob could not be - // read for a reason other than plain absence. - ReasonAttachArtifactUnreadable = "artifact-unreadable" // ReasonAttachResultsContent: a results artifact fails checkpoint-phase content // validation (raw template scaffolding, a missing title, or a malformed // document). The FINAL content contract binds at mark-implemented, not here. @@ -134,23 +112,20 @@ const ( // ChangeAttachRequest is the closed request for one attach. ID and Revision pin // the change record (exact submitted blob); Path is the canonical repo-relative -// artifact path; Commit is the exact feature commit the writer reported -// (results only); Markdown is the authored plan body (plan only). +// artifact path; Markdown is the authored artifact body the operation writes. type ChangeAttachRequest struct { ID int `json:"id" docket:"required"` Revision string `json:"revision" docket:"required"` Path string `json:"path" docket:"required"` - Commit string `json:"commit"` // results only // Markdown is the authored artifact body, read from --markdown at the CLI // boundary (a non-JSON file input, ADR-0138), never a JSON key. Markdown []byte `json:"-"` } // attachDigestPayload is the idempotency digest payload: the promised state a -// retry must match — the change id, the artifact path, and the artifact's -// identity: for a plan, the sha256 of the stored artifact bytes; for results, -// the exact blob object id at the verified commit. Keying on the content (not -// merely the path) means a retry after the content changed is NOT a replay. +// retry must match — the change id, the artifact path, and Blob, the sha256 of +// the stored artifact bytes. Keying on the content (not merely the path) means a +// retry after the content changed is NOT a replay. type attachDigestPayload struct { Blob string `json:"blob"` ID int `json:"id"` @@ -206,17 +181,15 @@ func ChangeAttachPlan(ctx context.Context, deps PlanningDeps, repoDir string, re return changeAttachMetadata(ctx, deps, repoDir, req, attachKindPlan) } -// ChangeAttachResults verifies an authored results record (required at -// completion since change 0410) from Git and links it to the change. It applies -// the canonical-path, containment, tracked-file, backlink, exact-head, and -// revision rules — a results document is never gate evidence, so it carries no -// single-artifact/trailer/descent proof; checkpoint attaches validate -// checkpoint-phase content. -func ChangeAttachResults(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult { - return changeAttach(ctx, deps, wdeps, repoDir, req, attachKindResults) +// ChangeAttachResults writes an authored results record (required at completion +// since change 0410) on the metadata branch with its backlink and links it to +// the change, in one metadata transaction. Each checkpoint is checked against +// the checkpoint-phase content contract; the feature head is never consulted. +func ChangeAttachResults(ctx context.Context, deps PlanningDeps, repoDir string, req ChangeAttachRequest) ChangeAttachResult { + return changeAttachMetadata(ctx, deps, repoDir, req, attachKindResults) } -// changeAttachMetadata is the metadata-profile driver: it validates the request +// changeAttachMetadata is the attach driver for both kinds: it validates the request // and the authored body, pins context, assembles the stored artifact (the // freshly rendered backlink prepended to the body), then drives one atomic, // idempotency-keyed exact-revision transaction that writes the artifact file and @@ -227,8 +200,9 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string opKey := attachOpKey(kind) // (1) Request shape: the pinned-entity scalars, then the authored body — - // non-empty, bounded, parsing cleanly (balanced managed markers), and free - // of placeholder-only slots. All of it is decided before anything is read. + // non-empty, bounded, parsing cleanly (balanced managed markers), and + // passing the kind's content rule. All of it is decided before anything is + // read. if findings := validateLifecycleShape("id", req.ID, req.Path, req.Revision); len(findings) > 0 { return newAttachResult(opKey, ResultInvalidInput, ChangeAttachResult{Kind: kind, Findings: findings}) } @@ -327,12 +301,15 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string return attachResultFromOutcome(opKey, kind, req.Path, res, execErr) } -// checkAttachMarkdown applies the authored-body request checks shared by the -// metadata profile: non-empty, within the authored-input bound, a clean parse -// (balanced managed markers), and — for a plan — no slot whose whole body is a -// bare placeholder token (change 0414). Managed blocks are not author content, -// so the slot check reads the submitted body exactly as it reads the stored -// artifact. It returns nil when the body is usable. +// checkAttachMarkdown applies the authored-body request checks both kinds +// share — non-empty, within the authored-input bound, a clean parse (balanced +// managed markers) — then the kind's content rule: a plan has no slot whose +// whole body is a bare placeholder token (change 0414); results pass +// checkpoint-phase content validation (raw template scaffolding, a missing +// title, or a malformed document refuses; the FINAL content contract binds at +// mark-implemented, so a checkpoint can never claim it). Managed blocks are not +// author content, so both rules read the submitted body exactly as they read +// the stored artifact. It returns nil when the body is usable. func checkAttachMarkdown(opKey, kind string, markdown []byte) *ChangeAttachResult { refuse := func(result Result, reason, msg string) *ChangeAttachResult { r := attachRefusal(opKey, result, kind, reason, msg) @@ -350,14 +327,19 @@ func checkAttachMarkdown(opKey, kind string, markdown []byte) *ChangeAttachResul return refuse(ResultInvalidInput, ReasonAttachUnbalancedBacklink, fmt.Sprintf("the submitted Markdown has a malformed managed-block population: %v", err)) } - if kind == attachKindPlan { - // A plan that merely MENTIONS a token — an instruction, a code example, - // ambiguous prose — is build-actionable and attaches; completeness - // judgment stays with plan authoring and review. - if slot, found := planPlaceholderSlot(markdown); found { - return refuse(ResultInvalidState, ReasonAttachPlaceholderToken, - fmt.Sprintf("%s of the plan contains only a placeholder token; fill the slot with real content", slot)) + if kind == attachKindResults { + if fs := ValidateResultsContent(markdown, ResultsPhaseCheckpoint); len(fs) > 0 { + return refuse(ResultInvalidState, ReasonAttachResultsContent, + fmt.Sprintf("the results artifact fails checkpoint content validation: %s: %s", fs[0].Reason, fs[0].Message)) } + return nil + } + // A plan that merely MENTIONS a token — an instruction, a code example, + // ambiguous prose — is build-actionable and attaches; completeness + // judgment stays with plan authoring and review. + if slot, found := planPlaceholderSlot(markdown); found { + return refuse(ResultInvalidState, ReasonAttachPlaceholderToken, + fmt.Sprintf("%s of the plan contains only a placeholder token; fill the slot with real content", slot)) } return nil } @@ -398,120 +380,6 @@ func pathMismatchMessage(kind, linked, requested string) string { return fmt.Sprintf("the change's %s: field already names %q; attach the %s at that path rather than %q", kind, linked, kind, requested) } -// changeAttach is the feature-head driver attach-results still runs. It -// validates the request shape, pins context, verifies the committed artifact -// from Git, then drives one atomic, idempotency-keyed exact-revision -// transaction. -func changeAttach(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, repoDir string, req ChangeAttachRequest, kind string) ChangeAttachResult { - opKey := attachOpKey(kind) - - // (1) Request shape. - findings := validateLifecycleShape("id", req.ID, req.Path, req.Revision) - if strings.TrimSpace(req.Commit) == "" { - findings = append(findings, lifecycleFinding(FCEmptyCommit, "commit must be the exact feature commit the writer reported")) - } - if len(findings) > 0 { - return newAttachResult(opKey, ResultInvalidInput, ChangeAttachResult{Kind: kind, Findings: findings}) - } - - // (2) Pin context, check the board surface, discover the repository. - pin, err := deps.Reader.PinContext(ctx, repoDir) - if err != nil { - result, reason := classifyStatusError(ctx, err) - return attachRefusal(opKey, result, kind, reason, err.Error()) - } - eff := pin.Config.Effective - inline, err := resolveBoardSurface(eff) - if err != nil { - if pe, ok := asPlanningError(err); ok { - return attachRefusal(opKey, pe.Result, kind, pe.Reason, pe.Message) - } - return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, err.Error()) - } - repo, err := deps.Client.Discover(ctx, gitcli.DiscoverOptions{InvocationPath: repoDir}) - if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - return attachRefusal(opKey, result, kind, reason, err.Error()) - } - - // (3) Resolve the change record (path, backlink target) from one corpus read. - ac, refusal := resolveAttachChange(ctx, deps, pin, eff, req.ID, opKey, kind) - if refusal != nil { - return *refusal - } - - // (4) Canonical-path containment inside the kind's allowed planning root. - if r := verifyAttachPath(opKey, kind, req.Path, eff); r != nil { - return *r - } - - // (5) The commit is the current feature head (workspace inspection). - insp := WorkspaceInspect(ctx, deps, wdeps, repoDir, WorkspaceIDRequest{ID: req.ID}) - if insp.Result != ResultApplied { - return attachRefusal(opKey, insp.Result, kind, insp.Reason, insp.Message) - } - if req.Commit != insp.Head { - return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachCommitNotHead, - "the verified commit is not the current feature head; re-read the workspace head before attaching") - } - - // (6) Read the artifact blob AT THE VERIFIED COMMIT — tracked, regular, no - // symlink — and keep its exact bytes and object id (never the working tree). - blob, r := readAttachBlob(ctx, deps, repo, req.Commit, req.Path, opKey, kind) - if r != nil { - return *r - } - - // (7) The artifact carries a balanced backlink targeting THIS change. - if r := verifyBacklink(opKey, kind, blob.Blob.Bytes, ac); r != nil { - return *r - } - - // (8) Checkpoint-phase content sanity: a truthful in-progress artifact - // passes; raw template scaffolding, a missing title, or a malformed document - // refuses. The FINAL content contract binds at mark-implemented, not here — - // the phase is explicit so checkpoint attachment can never claim it. - if fs := ValidateResultsContent(blob.Blob.Bytes, ResultsPhaseCheckpoint); len(fs) > 0 { - return attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachResultsContent, - fmt.Sprintf("the results artifact fails checkpoint content validation: %s: %s", fs[0].Reason, fs[0].Message)) - } - - // (9) Every verification passed: open the exact-revision, idempotency-keyed - // transaction that stores the artifact path and re-renders the derived views. - digest, derr := canonicalDigest(opKey, attachDigestPayload{Blob: string(blob.Blob.ObjectID), ID: req.ID, Path: req.Path}) - if derr != nil { - return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, derr.Error()) - } - - op := changeAttachOp{ - opKey: opKey, - kind: kind, - changeID: req.ID, - artifact: req.Path, - eff: eff, - clock: deps.Clock, - inline: inline, - link: linkContextOf(pin), - changesDir: eff.ChangesDir.Value, - } - - res, execErr := deps.Engine.Execute(ctx, transaction.Request{ - Repository: repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName), - Expected: []transaction.EntityExpectation{{ - Path: gitcli.RepoPath(ac.recPath), - Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, - }}, - Idempotency: &transaction.IdempotencyKey{RequestID: attachRequestID(kind, req), Digest: digest}, - Loader: newPlanningLoader(eff), - Scope: changeScope(req.ID, ac.recPath, false), - Operation: op, - }) - - return attachResultFromOutcome(opKey, kind, req.Path, res, execErr) -} - // attachOpKey maps an artifact kind to its operation key. func attachOpKey(kind string) string { if kind == attachKindResults { @@ -520,12 +388,6 @@ func attachOpKey(kind string) string { return OperationChangeAttachPlan } -// attachRequestID derives the idempotency request id from the kind and the -// (id, commit) pair, so a lost-response retry of the same request replays. -func attachRequestID(kind string, req ChangeAttachRequest) string { - return fmt.Sprintf("attach-%s-%d-%s", kind, req.ID, req.Commit) -} - // attachChange bundles the resolved change with its current record path and the // link context its backlink renders under. type attachChange struct { @@ -620,40 +482,12 @@ func withinPlanningRoot(root, p string) bool { return strings.HasPrefix(p, root+"/") } -// readAttachBlob opens the verified commit and reads the artifact blob: absent is -// untracked-file, a symlink (mode 120000) is symlinked-plan, anything else -// unreadable. On success it returns the BlobResult carrying exact bytes and oid. -func readAttachBlob(ctx context.Context, deps PlanningDeps, repo gitcli.Repository, commit, artifactPath, opKey, kind string) (gitcli.BlobResult, *ChangeAttachResult) { - src, err := deps.Client.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(commit)}) - if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - r := attachRefusal(opKey, result, kind, reason, err.Error()) - return gitcli.BlobResult{}, &r - } - results, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(artifactPath)}) - if err != nil { - // A directory/gitlink requested as a blob, or an unreadable object store. - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachArtifactUnreadable, err.Error()) - return gitcli.BlobResult{}, &r - } - if len(results) != 1 || !results[0].Found { - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachUntrackedFile, - fmt.Sprintf("no tracked file at %q in the verified commit", artifactPath)) - return gitcli.BlobResult{}, &r - } - if results[0].Blob.Mode == "120000" { - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachSymlinkedPlan, - fmt.Sprintf("the artifact at %q is a symlink at the verified commit, not a regular file", artifactPath)) - return gitcli.BlobResult{}, &r - } - return results[0], nil -} - // backlinkTargets reports whether artifactBytes carries a balanced docket:backlink // managed block whose interior equals the backlink rendered for ch under link. It -// is the shared backlink-identity check both change.attach-results (verifyBacklink) -// and change.mark-implemented (verifyImplementedResults) apply to an artifact's own -// bytes. A malformed managed-block population or a backlink-render failure is +// is the shared backlink-identity check the attach transaction (does a file +// already at the path point home to this change?) and change.mark-implemented +// (verifyImplementedResults) apply to an artifact's own bytes. A malformed +// managed-block population or a backlink-render failure is // returned as the error (the caller classifies it); an artifact with no backlink // block, or one whose interior names a DIFFERENT change, is (false, nil). func backlinkTargets(artifactBytes []byte, ch domain.Change, link render.LinkContext) (bool, error) { @@ -673,40 +507,6 @@ func backlinkTargets(artifactBytes []byte, ch domain.Change, link render.LinkCon return got == backlinkInterior(expected), nil } -// verifyBacklink proves the artifact carries a balanced docket:backlink block -// whose interior targets THIS change. A malformed managed-block population fails -// the parse (unbalanced-backlink); an absent block is missing-backlink; a block -// naming a different change is backlink-mismatch. It classifies the parse and -// presence cases here — each is its own stable attach reason — before delegating -// the interior comparison to backlinkTargets (which re-parses; the redundant parse -// is cheap and keeps the shared helper self-contained for the mark-implemented -// caller that wants a single yes/no). -func verifyBacklink(opKey, kind string, artifactBytes []byte, ac attachChange) *ChangeAttachResult { - doc, err := document.Parse(artifactBytes) - if err != nil { - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachUnbalancedBacklink, - fmt.Sprintf("the artifact has a malformed managed-block population: %v", err)) - return &r - } - if _, ok := doc.Block(backlinkBlockName); !ok { - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachMissingBacklink, - "the artifact carries no docket:backlink block") - return &r - } - ok, err := backlinkTargets(artifactBytes, ac.change, ac.link) - if err != nil { - // The parse above already succeeded, so this is a backlink-render failure. - r := attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, err.Error()) - return &r - } - if !ok { - r := attachRefusal(opKey, ResultInvalidState, kind, ReasonAttachBacklinkMismatch, - "the artifact's backlink targets a different change than the one being attached") - return &r - } - return nil -} - // changeAttachReceipt is the canonical receipt persisted with an attach commit. // Field order is alphabetical so json.Marshal emits the sorted-key compact form // the engine's receipt validator requires. @@ -757,9 +557,8 @@ type changeAttachOp struct { kind string changeID int artifact string - // artifactBytes is the stored artifact the metadata profile writes at - // artifact in the same commit (backlink + authored body); nil for the - // feature-head profile, which links a file already committed elsewhere. + // artifactBytes is the stored artifact written at artifact in the same + // commit (backlink + authored body). artifactBytes []byte eff config.Effective clock transaction.Clock @@ -773,8 +572,7 @@ func (o changeAttachOp) Key() transaction.OperationKey { return transaction.Oper // Plan stores the artifact path in the owned frontmatter field (plan or // results), refreshes the updated date, re-renders the artifact block, and // assembles the closed plan: the mutated change record, the re-rendered board -// when inline is enabled, and — for the metadata profile — the artifact file -// itself. +// when inline is enabled, and the artifact file itself. func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { snap := st.State.Snapshot @@ -798,34 +596,32 @@ func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) ( field = attachKindResults } - // The metadata profile writes the artifact file, so it decides on this + // The artifact file is written here, so the decision is made on this // attempt's own tree: a field already naming another path never re-points, // and a file already at the path is overwritten only when the link already // names it or its backlink points home to THIS change (a prior attach of // this change). Anything else belongs to someone else and refuses. - var artifactFile *transaction.FileMutation - if o.artifactBytes != nil { - linked := attachFieldValue(c, o.kind) - if linked != "" && linked != o.artifact { - return refuseLifecycle(FindingCode(ReasonAttachPathMismatch), pathMismatchMessage(o.kind, linked, o.artifact)) - } - existing, _, exists, err := treeBlob(ctx, st.Tree, o.artifact) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change attach: %w", err) - } - if exists && linked != o.artifact { - if home, berr := backlinkTargets(existing, c, o.link); berr != nil || !home { - return refuseLifecycle(FindingCode(ReasonAttachPathOccupied), - fmt.Sprintf("a file already exists at %q on the metadata branch and its backlink does not point to change %04d; refusing to overwrite it", o.artifact, o.changeID)) - } - } - switch { - case !exists: - artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationCreate, Bytes: o.artifactBytes} - case !bytes.Equal(existing, o.artifactBytes): - artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationReplace, Bytes: o.artifactBytes} + linked := attachFieldValue(c, o.kind) + if linked != "" && linked != o.artifact { + return refuseLifecycle(FindingCode(ReasonAttachPathMismatch), pathMismatchMessage(o.kind, linked, o.artifact)) + } + existing, _, exists, err := treeBlob(ctx, st.Tree, o.artifact) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change attach: %w", err) + } + if exists && linked != o.artifact { + if home, berr := backlinkTargets(existing, c, o.link); berr != nil || !home { + return refuseLifecycle(FindingCode(ReasonAttachPathOccupied), + fmt.Sprintf("a file already exists at %q on the metadata branch and its backlink does not point to change %04d; refusing to overwrite it", o.artifact, o.changeID)) } } + var artifactFile *transaction.FileMutation + switch { + case !exists: + artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationCreate, Bytes: o.artifactBytes} + case !bytes.Equal(existing, o.artifactBytes): + artifactFile = &transaction.FileMutation{Path: gitcli.RepoPath(o.artifact), Kind: transaction.MutationReplace, Bytes: o.artifactBytes} + } // First patch pass: the owned artifact field plus the refreshed updated date. intermediate, err := upsertFieldBytes(src, field, document.String(o.artifact)) diff --git a/internal/app/change_attach_git_integration_test.go b/internal/app/change_attach_git_integration_test.go index 99034e9d0..57af83a34 100644 --- a/internal/app/change_attach_git_integration_test.go +++ b/internal/app/change_attach_git_integration_test.go @@ -5,39 +5,23 @@ package app import ( "context" "github.com/danielhanold/docket/internal/workspace" - "os" - "path/filepath" "strings" "testing" ) -// symlinkRepoFile creates a symlink at a repo-relative path (creating parents) -// pointing at target, so a committed artifact can be a symlink (mode 120000). -func symlinkRepoFile(t *testing.T, root, rel, target string) { - t.Helper() - p := filepath.Join(root, rel) - if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { - t.Fatal(err) - } - if err := os.Symlink(target, p); err != nil { - t.Fatal(err) - } -} - // These are the real-git attach fixtures: they drive the real ChangeAttachPlan // and ChangeAttachResults operations over a real prepared workspace and a real -// bare metadata remote. attach-plan writes the plan on the metadata branch, so -// its guards (path containment, body shape, a field naming another path, an -// occupied path) are exercised against the remote; attach-results still -// verifies an artifact committed at the feature head, so its rows commit first. +// bare metadata remote. Both write their artifact on the metadata branch, so +// their guards (path containment, body shape, a field naming another path, an +// occupied path) are exercised against the remote; the prepared workspace is +// only there to prove the feature branch is never touched. // Each refusal row asserts its own stable reason string — proof the guard // reddens for the reason it names, not merely that something failed (learning // assert-pins-outcome-not-mechanism). // attachSetup builds a repo with one in-progress change, prepares its feature // workspace against the resolved base, and returns everything a row needs. The -// workspace sits on the feature ref at the base tip; a results row commits its -// own artifact variant before attaching. +// workspace sits on the feature ref at the base tip. type attachFixture struct { ctx context.Context deps PlanningDeps @@ -103,7 +87,7 @@ func (f *attachFixture) reset(t *testing.T) { } // commitArtifact writes files into the feature workspace and commits them, -// returning the new head (the results artifact a results row attaches). +// returning the new head. func (f *attachFixture) commitArtifact(t *testing.T, files map[string]string) string { t.Helper() for rel, content := range files { diff --git a/internal/app/change_attach_test.go b/internal/app/change_attach_test.go index 3b8bb5a76..7ff989fab 100644 --- a/internal/app/change_attach_test.go +++ b/internal/app/change_attach_test.go @@ -377,27 +377,110 @@ func assertNothingAttempted(t *testing.T, res ChangeAttachResult, engine *record } } -// --- TestChangeAttachResultsPatch ------------------------------------------ +// --- attach-results: results are written on the metadata branch ------------- -// TestChangeAttachResultsPatch proves the results transaction stores the results -// path in the owned results: field, leaving plan: untouched, and (until results -// move to the metadata branch) declares no artifact file. -func TestChangeAttachResultsPatch(t *testing.T) { +// attachTestResultsPath is the canonical results path the results-kind unit +// tests attach (inside the configured results root). +const attachTestResultsPath = "docs/results/2026-08-17-widget-results.md" + +// attachTestResultsMarkdown is a truthful checkpoint results body (no backlink +// block): a title and one real Outcome paragraph. +const attachTestResultsMarkdown = "# Widget — Results\n\n## Outcome\n\nDelivered the in-progress slice; behavior X now refuses Y.\n" + +// resultsAttachOp is baseAttachOp for the results kind carrying the stored +// artifact bytes the driver assembles for change 3, built by the production +// metadataArtifactBytes. +func resultsAttachOp(t *testing.T, surfaces []string, markdown string) changeAttachOp { + t.Helper() + op := baseAttachOp(surfaces, 3, attachKindResults, attachTestResultsPath) + artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))) + if err != nil { + t.Fatalf("metadataArtifactBytes: %v", err) + } + op.artifactBytes = artifact + return op +} + +// TestAttachResultsWritesFileAndField proves one metadata transaction stores the +// results path in the owned results: field (plan: untouched) AND creates the +// results file whose bytes are the rendered backlink prepended to the authored +// Markdown. +func TestAttachResultsWritesFileAndField(t *testing.T) { recPath := groomPath(3, "widget") - resultsPath := "docs/results/2026-08-17-widget-results.md" files := map[string]string{recPath: lifecycleChange(3, "widget", "in-progress")} - plan, opRes := attachPlanFor(t, files, baseAttachOp(nil, 3, attachKindResults, resultsPath)) + plan, opRes := attachPlanFor(t, files, resultsAttachOp(t, nil, attachTestResultsMarkdown)) if opRes.Refused { t.Fatalf("unexpected refusal: %v", opRes.Findings) } - assertPlanPaths(t, plan, map[string]transaction.MutationKind{recPath: transaction.MutationReplace}) + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + recPath: transaction.MutationReplace, + attachTestResultsPath: transaction.MutationCreate, + }) rec := lifecycleRecordBytes(t, plan, recPath) - if !strings.Contains(rec, "results: '"+resultsPath+"'") { + if !strings.Contains(rec, "results: '"+attachTestResultsPath+"'") { t.Errorf("attached record missing results field:\n%s", rec) } if strings.Contains(rec, "plan: '") { t.Errorf("attach-results wrote a plan field:\n%s", rec) } + want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath), attachTestResultsMarkdown) + if got := plannedFile(t, plan, attachTestResultsPath).Bytes; string(got) != string(want) { + t.Errorf("results file bytes =\n%q\nwant\n%q", got, want) + } +} + +// TestAttachResultsCheckpointContentRefuses proves a raw template scaffold (an +// unfilled H1 and Outcome body) refuses results-content-invalid before any pin +// or engine work, while a plan-only rule (a placeholder-token slot) is never +// applied to results. +func TestAttachResultsCheckpointContentRefuses(t *testing.T) { + scaffold := "# <Change title> — Results\n\n## Outcome\n\n<The original problem, the delivered behavior, and any material departure from the\nagreed design — lead with observable effects.>\n" + res, engine, reader := runAttachResultsUnit(t, ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestResultsPath, Markdown: []byte(scaffold)}) + if res.Result != ResultInvalidState || res.Reason != ReasonAttachResultsContent { + t.Fatalf("result/reason = %q/%q, want invalid-state/%q (msg %q)", res.Result, res.Reason, ReasonAttachResultsContent, res.Message) + } + if res.Kind != attachKindResults || res.Envelope.Operation != OperationChangeAttachResults { + t.Errorf("refusal names kind %q operation %q, want results/%s", res.Kind, res.Envelope.Operation, OperationChangeAttachResults) + } + assertNothingAttempted(t, res, engine, reader) +} + +// TestAttachResultsLaterCheckpointReplacesContent proves a later checkpoint at +// the already linked results path replaces the file's content in one +// MutationReplace and leaves the record undeclared (same day, field already +// set) — every checkpoint is a metadata commit, never a feature commit. +func TestAttachResultsLaterCheckpointReplacesContent(t *testing.T) { + recPath := groomPath(3, "widget") + files := map[string]string{recPath: lifecycleChange(3, "widget", "in-progress")} + first, opRes := attachPlanFor(t, files, resultsAttachOp(t, nil, attachTestResultsMarkdown)) + if opRes.Refused { + t.Fatalf("first checkpoint refused: %v", opRes.Findings) + } + files[recPath] = lifecycleRecordBytes(t, first, recPath) + files[attachTestResultsPath] = string(plannedFile(t, first, attachTestResultsPath).Bytes) + + later := "# Widget — Results\n\n## Outcome\n\nDelivered the whole widget; the gate certifies the head.\n" + second, opRes := attachPlanFor(t, files, resultsAttachOp(t, nil, later)) + if opRes.Refused { + t.Fatalf("later checkpoint refused: %v", opRes.Findings) + } + assertPlanPaths(t, second, map[string]transaction.MutationKind{ + attachTestResultsPath: transaction.MutationReplace, + }) + if got := string(plannedFile(t, second, attachTestResultsPath).Bytes); !strings.Contains(got, "the gate certifies the head") { + t.Errorf("later checkpoint did not write the revised content:\n%s", got) + } +} + +// runAttachResultsUnit drives ChangeAttachResults over a recording engine and a +// reader that records whether it was pinned, with no git client and no +// workspace seam at all: the results attach never consults the feature head. +func runAttachResultsUnit(t *testing.T, req ChangeAttachRequest) (ChangeAttachResult, *recordingEngine, *fakeChangeReader) { + t.Helper() + engine := &recordingEngine{} + reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} + deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} + return ChangeAttachResults(context.Background(), deps, "", req), engine, reader } // --- TestChangeAttachContention -------------------------------------------- @@ -420,40 +503,41 @@ func TestChangeAttachContention(t *testing.T) { // --- TestChangeAttachResultsRejectsBadShape -------------------------------- -// TestChangeAttachResultsRejectsBadShape proves the results request-shape check -// refuses a malformed request before any pin/engine work, for every missing -// scalar (results still verify a feature commit, so commit is required). +// TestChangeAttachResultsRejectsBadShape proves the results request-shape checks +// refuse before any pin or engine work: every missing scalar, and an empty or +// oversized Markdown body. The request carries no commit — the feature head is +// never part of a results attach. func TestChangeAttachResultsRejectsBadShape(t *testing.T) { - valid := ChangeAttachRequest{ID: 3, Revision: blobV, Path: "docs/results/x-results.md", Commit: blobV} + valid := ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestResultsPath, Markdown: []byte(attachTestResultsMarkdown)} cases := []struct { - name string - mut func(*ChangeAttachRequest) - code string + name string + mut func(*ChangeAttachRequest) + code string + reason string }{ - {"non-positive id", func(r *ChangeAttachRequest) { r.ID = 0 }, "invalid-id"}, - {"empty path", func(r *ChangeAttachRequest) { r.Path = "" }, "empty-path"}, - {"empty revision", func(r *ChangeAttachRequest) { r.Revision = "" }, "empty-revision"}, - {"empty commit", func(r *ChangeAttachRequest) { r.Commit = " " }, "empty-commit"}, + {name: "non-positive id", mut: func(r *ChangeAttachRequest) { r.ID = 0 }, code: "invalid-id"}, + {name: "empty path", mut: func(r *ChangeAttachRequest) { r.Path = "" }, code: "empty-path"}, + {name: "empty revision", mut: func(r *ChangeAttachRequest) { r.Revision = "" }, code: "empty-revision"}, + {name: "empty markdown", mut: func(r *ChangeAttachRequest) { r.Markdown = nil }, reason: ReasonAttachEmptyMarkdown}, + {name: "too large", mut: func(r *ChangeAttachRequest) { + r.Markdown = []byte("# R\n\n" + strings.Repeat("x", maxAuthoredMarkdownBytes) + "\n") + }, reason: ReasonAttachMarkdownTooLarge}, } for _, c := range cases { t.Run(c.name, func(t *testing.T) { req := valid c.mut(&req) - engine := &recordingEngine{} - reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} - deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} - - res := ChangeAttachResults(context.Background(), deps, WorkspaceDeps{}, "", req) - + res, engine, reader := runAttachResultsUnit(t, req) if res.Result != ResultInvalidInput { - t.Fatalf("result = %q, want invalid-input", res.Result) - } - if len(engine.calls) != 0 { - t.Errorf("engine called %d times on a shape failure, want 0", len(engine.calls)) + t.Fatalf("result = %q, want invalid-input (reason %q)", res.Result, res.Reason) } - if !hasFindingCode(res.Findings, c.code) { + if c.code != "" && !hasFindingCode(res.Findings, c.code) { t.Errorf("missing finding %q; got %v", c.code, res.Findings) } + if c.reason != "" && res.Reason != c.reason { + t.Errorf("reason = %q, want %q", res.Reason, c.reason) + } + assertNothingAttempted(t, res, engine, reader) }) } } @@ -461,21 +545,24 @@ func TestChangeAttachResultsRejectsBadShape(t *testing.T) { // --- TestChangeAttachResultsIdenticalReattachIsNoOp ------------------------ // TestChangeAttachResultsIdenticalReattachIsNoOp pins the same-path same-day -// results re-attach (change 0458): an identical re-attach declares NO files. +// results re-attach (change 0458): once the record stores the path with today's +// date and the results file holds the same bytes, an identical re-attach +// declares NO files. func TestChangeAttachResultsIdenticalReattachIsNoOp(t *testing.T) { recPath := groomPath(3, "widget") files := map[string]string{ recPath: lifecycleChange(3, "widget", "in-progress"), "docs/changes/BOARD.md": "# Backlog\n\nold\n", } - op := baseAttachOp([]string{"inline"}, 3, attachKindResults, "docs/results/2026-09-25-widget-results.md") + op := resultsAttachOp(t, []string{"inline"}, attachTestResultsMarkdown) first, opRes := attachPlanFor(t, files, op) if opRes.Refused { t.Fatalf("first attach refused: %v", opRes.Findings) } - files[recPath] = lifecycleRecordBytes(t, first, recPath) - files["docs/changes/BOARD.md"] = lifecycleRecordBytes(t, first, "docs/changes/BOARD.md") + for _, f := range first.Files { + files[string(f.Path)] = string(f.Bytes) + } second, opRes := attachPlanFor(t, files, op) if opRes.Refused { diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index faf62eb53..8fe221dc8 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -576,8 +576,8 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t // Retitle gate (change 0461). A proposed change revived from a deferred // in-progress claim keeps its feature-branch artifacts; their backlinks embed - // the title and are identity-checked (attach's backlink-mismatch, - // mark-implemented's results-identity-broken), so renaming it here would + // the title and are identity-checked (mark-implemented's + // results-identity-broken), so renaming it here would // silently break the later build. A title equal to the current one is no // retitle and passes. if o.req.Title != "" && o.req.Title != c.Title() && diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index 67d7ae5d9..8ed3ff5d8 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -1783,8 +1783,8 @@ func revivedReviseFiles() map[string]string { // TestChangeGroomPlanRetitleRefusedWithFeatureArtifacts pins that a retitle of a // proposed change still carrying branch:/plan:/results: refuses not-retitleable // and writes nothing: those artifacts' backlinks embed the title and are -// identity-checked by attach (backlink-mismatch) and mark-implemented -// (results-identity-broken), so a retitle would silently break them. A request +// identity-checked by mark-implemented (results-identity-broken), so a retitle +// would silently break them. A request // repeating the current title changes nothing and is not refused. func TestChangeGroomPlanRetitleRefusedWithFeatureArtifacts(t *testing.T) { plan, opRes := groomPlanFor(t, revivedReviseFiles(), baseGroomOp([]string{}, titleOnlyReviseRequest("Renamed widget"))) diff --git a/internal/app/change_implemented.go b/internal/app/change_implemented.go index 1c13e18f4..13b87a46d 100644 --- a/internal/app/change_implemented.go +++ b/internal/app/change_implemented.go @@ -42,9 +42,9 @@ import ( // effective-base branch, names that head, and matches the supplied PR number // (parsePRRef reads the --pr assertion as either the full URL or shorthand); // (5) a results artifact is attached (required since change 0410, trivial -// changes included) and, at that head, resolves to a tracked regular file -// that carries this change's backlink and satisfies the final results -// content contract. +// changes included) and resolves to a file on the metadata branch that +// carries this change's backlink and satisfies the final results content +// contract. // // The transaction records the verified PR's canonical URL (pr.URL) as the manifest // pr:, the board-safe form; the --pr assertion may be either form. A response-loss @@ -111,15 +111,15 @@ const ( // caller supplied by reference (condition 4); maps to invalid-state. ReasonImplementedPRReferenceMismatch = "pr-reference-mismatch" // ReasonImplementedResultsIdentity: the attached results path no longer resolves - // to a tracked regular file at the supplied head, or its docket:backlink no - // longer targets this change (condition 5); invalid-state. + // to a file on the metadata branch, or its docket:backlink no longer targets + // this change (condition 5); invalid-state. ReasonImplementedResultsIdentity = "results-identity-broken" // ReasonImplementedResultsMissing: the change carries no attached results path; // a results artifact is REQUIRED at the implemented boundary — trivial changes // included (change 0410, condition 5); maps to invalid-state. ReasonImplementedResultsMissing = "results-missing" // ReasonImplementedResultsInvalid: the attached results artifact fails the FINAL - // results content contract at the supplied head (change 0410, condition 5); + // results content contract on the metadata branch (change 0410, condition 5); // invalid-state. ReasonImplementedResultsInvalid = "results-content-invalid" ) @@ -301,14 +301,14 @@ func ChangeMarkImplemented(ctx context.Context, deps PlanningDeps, wdeps Workspa // (Condition 5) a results artifact is REQUIRED at the implemented boundary // (change 0410) — for trivial changes too. The attached path must resolve to a - // tracked regular file at the supplied head, carry THIS change's backlink, and - // satisfy the FINAL results content contract. + // file on the pinned metadata tip, carry THIS change's backlink, and satisfy + // the FINAL results content contract. The feature head is never consulted. resultsPath := strings.TrimSpace(c.Results().Value) if resultsPath == "" { return implementedRefusal(ResultInvalidState, ReasonImplementedResultsMissing, fmt.Sprintf("change %04d has no attached results artifact; author and attach the final results before marking implemented", req.ID), req.ID) } - if r := verifyImplementedResults(ctx, deps, repo, req.Head, resultsPath, c, linkContextOf(pin), req.ID); r != nil { + if r := verifyImplementedResults(ctx, deps, pin, resultsPath, c, linkContextOf(pin), req.ID); r != nil { return *r } @@ -402,45 +402,40 @@ func resolveImplementedChange(ctx context.Context, deps PlanningDeps, pin Status return c, c.Path(), revision, nil } -// verifyImplementedResults reprobes the attached results artifact at the supplied -// head against the FINAL results contract (change 0410). It confirms, in order: -// the path still resolves to a tracked, regular file at that head +// verifyImplementedResults reprobes the attached results artifact on the pinned +// metadata tip against the FINAL results contract (change 0410). It confirms, in +// order: the path still resolves to a file on the metadata branch // (results-identity-broken); the artifact still carries THIS change's backlink — // a missing/mismatched block or a malformed managed-block population is broken // results identity, not a prose defect (results-identity-broken); and the artifact // satisfies the final results content contract (results-content-invalid, naming -// the first finding). The tracked-file reprobe catches a later fix that deleted or -// replaced the recorded artifact; the backlink and content reprobes catch a head -// whose results were edited away from the revision attach-results validated. -func verifyImplementedResults(ctx context.Context, deps PlanningDeps, repo gitcli.Repository, head, resultsPath string, ch domain.Change, link render.LinkContext, id int) *ChangeLifecycleResult { - src, err := deps.Client.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(head)}) +// the first finding). The presence reprobe catches a record whose results file +// was deleted or never written; the backlink and content reprobes catch results +// edited away from the checkpoint attach-results validated. +func verifyImplementedResults(ctx context.Context, deps PlanningDeps, pin StatusPin, resultsPath string, ch domain.Change, link render.LinkContext, id int) *ChangeLifecycleResult { + art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, resultsPath) if err != nil { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, err.Error(), id) return &r } - results, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(resultsPath)}) - if err != nil { - r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, err.Error(), id) - return &r - } - if len(results) != 1 || !results[0].Found || results[0].Blob.Mode == "120000" { + if !art.Found { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, - fmt.Sprintf("the attached results path %q is not a tracked regular file at the supplied head", resultsPath), id) + fmt.Sprintf("the attached results path %q is not a file on the metadata branch", resultsPath), id) return &r } - artifactBytes := results[0].Blob.Bytes + artifactBytes := art.Data - // The artifact must still carry this change's backlink at the head. + // The artifact must still carry this change's backlink. targets, err := backlinkTargets(artifactBytes, ch, link) if err != nil { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, - fmt.Sprintf("the attached results artifact %q has a malformed or unreadable backlink at the supplied head: %v", resultsPath, err), id) + fmt.Sprintf("the attached results artifact %q has a malformed or unreadable backlink on the metadata branch: %v", resultsPath, err), id) return &r } if !targets { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, - fmt.Sprintf("the attached results artifact %q does not carry this change's backlink at the supplied head", resultsPath), id) + fmt.Sprintf("the attached results artifact %q does not carry this change's backlink on the metadata branch", resultsPath), id) return &r } diff --git a/internal/app/change_implemented_integration_test.go b/internal/app/change_implemented_integration_test.go index 3375a6a9a..6408f6292 100644 --- a/internal/app/change_implemented_integration_test.go +++ b/internal/app/change_implemented_integration_test.go @@ -41,6 +41,7 @@ func TestIntegrationRecordOpsMarkImplementedAcceptsSkippedEvidence(t *testing.T) recPath := groomPath(3, miSlug) real := newWorkingRepo(t, map[string]string{recPath: miRecord(3, miSlug, miPlanPath(), miResultsPath, true, false)}) realHead := miAdvanceHead(t, real) + advanceDocketOrigin(t, real, miMetadataResults()) if res := miRealRunWith(t, real, recPath, realHead, prSkippedEvidenceBytes(t, realHead)); res.Result != ResultApplied { t.Fatalf("real-engine result = %q, want applied (findings %v)", res.Result, res.Findings) } @@ -65,6 +66,7 @@ func TestIntegrationRecordOpsMarkImplementedUnrelatedInvalidRecordProgress(t *te unrelatedBrokenPath: unrelatedBrokenBytes, }) head := miAdvanceHead(t, repo) + advanceDocketOrigin(t, repo, miMetadataResults()) res := miRealRun(t, repo, recPath, head) if res.Result != ResultApplied || res.Status != "implemented" { @@ -82,6 +84,7 @@ func TestIntegrationRecordOpsMarkImplementedUnrelatedInvalidRecordRefusals(t *te t.Run(c.name, func(t *testing.T) { repo := newWorkingRepo(t, c.files) head := miAdvanceHead(t, repo) + advanceDocketOrigin(t, repo, miMetadataResults()) tip := originTip(t, repo.origin, "docket") res := miRealRun(t, repo, recPath, head) diff --git a/internal/app/change_implemented_test.go b/internal/app/change_implemented_test.go index 403b09081..bc415d2ae 100644 --- a/internal/app/change_implemented_test.go +++ b/internal/app/change_implemented_test.go @@ -134,14 +134,17 @@ func TestMarkImplementedApplies(t *testing.T) { // --- reprobe fixture -------------------------------------------------------- -// The results artifact paths the mark-implemented fixtures commit at the feature -// head. A results artifact is REQUIRED at the implemented boundary since change +// The results artifact paths the mark-implemented fixtures place on the metadata +// branch. A results artifact is REQUIRED at the implemented boundary since change // 0410, so every happy fixture attaches miResultsPath; the -invalid / -mismatch -// variants ride the same head so a condition-5 row can point k.results at them. +// variants sit beside it so a condition-5 row can point k.results at them. The +// -head-only path is committed ONLY at the feature head (miHeadFiles) and never +// on the metadata branch, so a row linking it proves the head is not consulted. const ( miResultsPath = "docs/results/2026-08-17-widget-results.md" miResultsInvalidPath = "docs/results/2026-08-17-widget-invalid.md" miResultsMismatchPath = "docs/results/2026-08-17-widget-mismatch.md" + miResultsHeadOnlyPath = "docs/results/2026-08-17-widget-head-only.md" ) // miResultsArtifact is a FINAL-valid results artifact for change 3: the correct @@ -163,7 +166,7 @@ func miResultsFinalInvalid() string { } // miResultsBacklinkMismatch carries a well-formed backlink that targets a DIFFERENT -// change id, so its results identity is broken at the head (results-identity-broken) +// change id, so its results identity is broken (results-identity-broken) // even though its prose would satisfy the final content contract. func miResultsBacklinkMismatch() string { return attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + @@ -171,23 +174,28 @@ func miResultsBacklinkMismatch() string { } // miHeadFiles is the feature-head file set the happy mark-implemented fixtures -// commit: the implementation file plus every results artifact variant the -// condition-5 rows reference. +// commit: the implementation file plus a valid results file that exists ONLY at +// the head (results live on the metadata branch, so it never satisfies a link). func miHeadFiles() map[string]string { return map[string]string{ "impl.go": "package impl\n", + miResultsHeadOnlyPath: miResultsArtifact(), + } +} + +// miMetadataResults is the results artifact set the mark-implemented fixtures +// place on the metadata branch: every variant the condition-5 rows reference. +func miMetadataResults() map[string]string { + return map[string]string{ miResultsPath: miResultsArtifact(), miResultsInvalidPath: miResultsFinalInvalid(), miResultsMismatchPath: miResultsBacklinkMismatch(), } } -// miAdvanceHead commits the happy feature head (the implementation file plus the -// results artifact variants) in the writer clone, pushes it to origin, and fetches -// it into the invocation clone so the mark-implemented client can read the results -// blob at that head LOCALLY — as the primary tree can in a real run. The invocation -// clone is created before writerAdvance's push, so without this fetch the head's -// objects are remote-only and OpenObjectSource fails ref-unavailable. +// miAdvanceHead commits the happy feature head (miHeadFiles) in the writer +// clone, pushes it to origin, and fetches it into the invocation clone so the +// head's objects are local to the primary tree, as they are in a real run. func miAdvanceHead(t *testing.T, repo *gitRepo) string { t.Helper() head := repo.writerAdvance(t, "feat/"+miSlug, miHeadFiles()) @@ -228,7 +236,11 @@ func buildMI(t *testing.T, client *gitcli.Client, invocation string, k miKit) ( Revision: k.revision, Data: []byte(miRecord(3, miSlug, k.plan, k.results, k.reconciled, k.trivial)), } - reader := &fakeReader{pin: mainPin(t), corpus: []StatusBlob{blob}, facts: domain.NewBranchFacts(nil)} + artifacts := map[string]StatusArtifact{} + for p, content := range miMetadataResults() { + artifacts[sourceMetadata+"|"+p] = StatusArtifact{Found: true, Revision: "resultsblob", Data: []byte(content)} + } + reader := &fakeReader{pin: mainPin(t), corpus: []StatusBlob{blob}, facts: domain.NewBranchFacts(nil), artifactData: artifacts} engine := &recordingEngine{result: transaction.Result{ Disposition: transaction.DispositionApplied, AppliedCommit: "cafebabecafebabecafebabecafebabecafebabe", @@ -273,7 +285,10 @@ func firstStatusFindingCode(findings []StatusFinding) string { // unparseable record; the GitHub and workspace seams stay scripted. // miRealRun runs mark-implemented through the production planning seams over -// repo, with every reprobe condition satisfied for head. +// repo, with every reprobe condition satisfied for head. The repo's metadata +// branch must carry the results (seed it with advanceDocketOrigin and +// miMetadataResults AFTER miAdvanceHead, which branches from the writer's +// current checkout). func miRealRun(t *testing.T, repo *gitRepo, recPath, head string) ChangeLifecycleResult { t.Helper() return miRealRunWith(t, repo, recPath, head, prEvidenceBytes(t, head)) diff --git a/internal/app/change_integration_test.go b/internal/app/change_integration_test.go index 22fff1049..b8fca1cf7 100644 --- a/internal/app/change_integration_test.go +++ b/internal/app/change_integration_test.go @@ -1958,7 +1958,7 @@ func TestIntegrationChangeRuntimeMarkImplementedConditions(t *testing.T) { }, reason: ReasonImplementedResultsMissing, }, - { // condition 5 — the attached path is a tracked regular file with the correct + { // condition 5 — the attached path is a metadata file with the correct // backlink, but its FINAL content is filler (## Findings and limitations → None.). name: "attached results fail the final content contract", mutate: func(k *miKit) { k.results = miResultsInvalidPath }, @@ -1970,11 +1970,17 @@ func TestIntegrationChangeRuntimeMarkImplementedConditions(t *testing.T) { mutate: func(k *miKit) { k.results = miResultsMismatchPath }, reason: ReasonImplementedResultsIdentity, }, - { // condition 5 - name: "attached results path no longer tracked at head", + { // condition 5 — results: names a path with no file on the metadata branch. + name: "attached results path absent on the metadata branch", mutate: func(k *miKit) { k.results = "docs/changes/results/0003-widget-ghost.md" }, reason: ReasonImplementedResultsIdentity, }, + { // condition 5 — a valid results file committed only at the feature head + // never satisfies the link: results live on the metadata branch. + name: "attached results exist only at the feature head", + mutate: func(k *miKit) { k.results = miResultsHeadOnlyPath }, + reason: ReasonImplementedResultsIdentity, + }, } for _, row := range rows { @@ -4137,6 +4143,15 @@ func TestIntegrationChangeRuntimeRunVerifyIncompleteEnumeratesConditions(t *test pr: rvPR(pub.head, prBodyNoEvidence), want: ReasonRunResultsIdentity, }, + { + // A results file committed at the feature head but absent from the + // metadata branch: results live on the metadata branch, so the head + // copy never satisfies the link. + name: "results only at the feature head", + record: rvWithEvidence(t, rvRecord(rvPlanPath, rvResultsHeadOnlyPath, recordedPR, "feat/"+rvSlug), ev), + pr: rvPR(pub.head, prBodyNoEvidence), + want: ReasonRunResultsIdentity, + }, { // The recorded branch is honored end-to-end: run verify inspects and // probes feat/other (the record's branch), never a reconstructed diff --git a/internal/app/finalize_e2e_test.go b/internal/app/finalize_e2e_test.go index c99504c41..c649f8534 100644 --- a/internal/app/finalize_e2e_test.go +++ b/internal/app/finalize_e2e_test.go @@ -389,21 +389,18 @@ func (e *implEnv) implement(t *testing.T, id int, slug, planPath, title string) } // A results artifact is REQUIRED at the implemented boundary (change 0410): it - // rides the implementation commit with its stamped backlink and is attached - // before mark-implemented. + // is written on the metadata branch and attached before mark-implemented; the + // implementation commit carries only code. resultsPath := "docs/results/2026-08-17-" + slug + "-results.md" writeRepoFile(t, wp, slug+".go", "package "+slug+"\n") - writeRepoFile(t, wp, resultsPath, "# "+title+" — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered "+slug+" end to end; the gate certifies this head.\n") - blR := ArtifactBacklink(e.ctx, e.node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: resultsPath, ChangePath: recPath}) - if blR.Result != ResultApplied { - t.Fatalf("artifact backlink (results) id %d = %q (reason %q)", id, blR.Result, blR.Reason) - } runGit(t, wp, "add", "-A") runGit(t, wp, "commit", "-q", "-m", "implement "+slug) head := runGit(t, wp, "rev-parse", "HEAD") - attachR := ChangeAttachResults(e.ctx, e.node.deps, e.wdeps, e.node.dir, - ChangeAttachRequest{ID: id, Revision: ver(), Path: resultsPath, Commit: head}) + attachR := ChangeAttachResults(e.ctx, e.node.deps, e.node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: resultsPath, + Markdown: []byte("# " + title + " — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered " + slug + " end to end; the gate certifies this head.\n"), + }) if attachR.Result != ResultApplied { t.Fatalf("attach results id %d = %q (reason %q findings %v)", id, attachR.Result, attachR.Reason, attachR.Findings) } diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index 9c228faa9..cf2d4021e 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -17,7 +17,6 @@ const ( FCAuthoredInputTooLarge FindingCode = "authored-input-too-large" FCEmptyAttempt FindingCode = "empty-attempt" FCEmptyChildPRRevision FindingCode = "empty-child_pr_revision" - FCEmptyCommit FindingCode = "empty-commit" FCEmptyEvidence FindingCode = "empty-evidence" FCEmptyNoteEntry FindingCode = "empty-note-entry" FCEmptyPath FindingCode = "empty-path" @@ -210,7 +209,6 @@ var AllFindingCodes = []FindingCode{ FCEmptyChangePath, FCEmptyChangeRevision, FCEmptyChildPRRevision, - FCEmptyCommit, FCEmptyConsequences, FCEmptyContext, FCEmptyDecision, diff --git a/internal/app/named_isolation_integration_test.go b/internal/app/named_isolation_integration_test.go index 784109ba5..5d368a190 100644 --- a/internal/app/named_isolation_integration_test.go +++ b/internal/app/named_isolation_integration_test.go @@ -257,14 +257,13 @@ func TestIntegrationNamedImplementationFlowIsolation(t *testing.T) { // (6) Implementation + results attach. writeRepoFile(t, wp, "widget.go", "package widget\n") - writeRepoFile(t, wp, resultsPath, "# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget end to end; the gate certifies this head.\n") - if bl := ArtifactBacklink(ctx, node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: resultsPath, ChangePath: recPath}); bl.Result != ResultApplied { - t.Fatalf("results backlink = %q (reason %q msg %q)", bl.Result, bl.Reason, bl.Message) - } runGit(t, wp, "add", "-A") runGit(t, wp, "commit", "-q", "-m", "implement the widget") head := runGit(t, wp, "rev-parse", "HEAD") - attachR := ChangeAttachResults(ctx, node.deps, wdeps, node.dir, ChangeAttachRequest{ID: id, Revision: ver(), Path: resultsPath, Commit: head}) + attachR := ChangeAttachResults(ctx, node.deps, node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: resultsPath, + Markdown: []byte("# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget end to end; the gate certifies this head.\n"), + }) if attachR.Result != ResultApplied { t.Fatalf("attach results = %q (reason %q msg %q findings %v), want applied", attachR.Result, attachR.Reason, attachR.Message, attachR.Findings) } diff --git a/internal/app/plan_content.go b/internal/app/plan_content.go index 80ea7deae..ba556aceb 100644 --- a/internal/app/plan_content.go +++ b/internal/app/plan_content.go @@ -40,7 +40,8 @@ func isPlanFillerBody(body string) bool { // fenced lines never parse as headings but DO count as slot content (code is // substantive). The returned slot names the offending section for the refusal // message. A non-parsing document returns not-found: at the attach seam the -// backlink guard (verifyBacklink) has already refused it before this runs. +// body's parse check (checkAttachMarkdown) has already refused it before this +// runs. func planPlaceholderSlot(source []byte) (string, bool) { doc, err := document.Parse(source) if err != nil { diff --git a/internal/app/run_verify.go b/internal/app/run_verify.go index 1d45e3712..1d2f4c826 100644 --- a/internal/app/run_verify.go +++ b/internal/app/run_verify.go @@ -91,10 +91,10 @@ const ( // results. This condition needs no blob read. ReasonRunResultsUnlinked = "results-unlinked" // ReasonRunResultsIdentity: an attached results path no longer resolves to a - // tracked regular file at the current feature head. + // file on the metadata branch. ReasonRunResultsIdentity = "results-identity-broken" - // ReasonRunResultsInvalid: an attached results path resolves to a tracked - // regular file whose FINAL results content contract is not satisfied + // ReasonRunResultsInvalid: an attached results path resolves to a file on the + // metadata branch whose FINAL results content contract is not satisfied // (ValidateResultsContent, ResultsPhaseFinal). Observed names the path and the // first content finding; it is diagnostic and must not be parsed. ReasonRunResultsInvalid = "results-content-invalid" @@ -347,8 +347,8 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep add(ReasonRunLeaseContended, b.Value) } - // Plan link, and plan-file identity on the metadata branch (the plan lives - // there; the feature head is never consulted for it). + // Plan and results links, and their file identity on the metadata branch + // (both live there; the feature head is never consulted for either). planPath := strings.TrimSpace(c.Plan().Value) resultsPath := strings.TrimSpace(c.Results().Value) if planPath == "" { @@ -363,32 +363,20 @@ func RunVerify(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } } // A results artifact is REQUIRED at the implemented boundary (change 0410): - // an absent link is an unmet condition on its own. It needs no blob read, so it - // is added OUTSIDE the object-source guard below — a run with no results still - // reports results-unlinked without opening the head source. + // an absent link is an unmet condition on its own, needing no read. if resultsPath == "" { add(ReasonRunResultsUnlinked, "") - } - // Open the head's object source when the results blob read is needed. - if resultsPath != "" { - src, err := deps.Client.OpenObjectSource(ctx, repo, gitcli.Revision{Commit: gitcli.ObjectID(head)}) + } else { + art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, resultsPath) if err != nil { - result, reason := classifyStatusError(ctx, classifyGitFailure(err)) - if reason == ReasonStatusInternalError { - reason = ReasonRunArtifactRead - } - return runOperationalRefusal(result, reason, err.Error(), req.ID) - } - blob, okFile, rerr := trackedRegularBlobBytes(ctx, src, resultsPath) - if rerr != nil { - return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, rerr.Error(), req.ID) + return runOperationalRefusal(ResultExternalFailed, ReasonRunArtifactRead, err.Error(), req.ID) } - if !okFile { - // The linked path is absent or a symlink: identity, not prose. + if !art.Found { + // The linked path is not on the metadata branch: identity, not prose. add(ReasonRunResultsIdentity, resultsPath) - } else if fs := ValidateResultsContent(blob, ResultsPhaseFinal); len(fs) > 0 { - // The path resolves to a tracked regular file whose FINAL content - // contract fails. Observed names the path and the first finding. + } else if fs := ValidateResultsContent(art.Data, ResultsPhaseFinal); len(fs) > 0 { + // The file's FINAL content contract fails. Observed names the path + // and the first finding. add(ReasonRunResultsInvalid, resultsPath+": "+fs[0].Reason) } } @@ -526,23 +514,3 @@ func evaluateRunWaiting(changeID int, c domain.Change, workspaceHead string, r W } return r.DriveID, r.Phase, true } - -// trackedRegularBlobBytes reports whether path resolves to a tracked, regular -// (non symlink) file at the source's pinned commit and, when it does, returns the -// blob's bytes. A read-infrastructure error is returned as an error -// (operational); a missing path or a symlink is a clean (nil, false, nil) — the -// caller maps that to the relevant postcondition miss. It is the probe behind -// the results identity and content checks. -func trackedRegularBlobBytes(ctx context.Context, src gitcli.ObjectSource, path string) ([]byte, bool, error) { - results, err := src.ReadBlobs(ctx, []gitcli.RepoPath{gitcli.RepoPath(path)}) - if err != nil { - return nil, false, err - } - if len(results) != 1 || !results[0].Found { - return nil, false, nil - } - if results[0].Blob.Mode == "120000" { - return nil, false, nil - } - return results[0].Blob.Bytes, true, nil -} diff --git a/internal/app/run_verify_test.go b/internal/app/run_verify_test.go index 4c785c000..a6a21613b 100644 --- a/internal/app/run_verify_test.go +++ b/internal/app/run_verify_test.go @@ -25,19 +25,24 @@ const ( rvPlanPath = "docs/superpowers/plans/2026-08-17-widget-plan.md" rvResultsPath = "docs/changes/results/0003-widget-results.md" rvResultsInvalidPath = "docs/changes/results/0003-widget-invalid-results.md" + // rvResultsHeadOnlyPath is a valid results file committed ONLY at the feature + // head and absent from the metadata branch: a record linking it has broken + // results identity, proving run verify never reads results at the head. + rvResultsHeadOnlyPath = "docs/changes/results/0003-widget-head-only-results.md" ) // rvResultsValidContent is a minimal FINAL-valid results artifact: an H1 title // and a substantive ## Outcome. ValidateResultsContent(..., ResultsPhaseFinal) // accepts it, so run verify's results content check adds no condition — the -// fixture head must carry a genuinely valid results file now that a linked +// metadata branch must carry a genuinely valid results file now that a linked // results path is content-validated at the final boundary (change 0410). const rvResultsValidContent = "# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget behavior; the run now refuses the old path.\n" // rvResultsInvalidContent is a FINAL-invalid artifact: a whole-section filler // body (`None.`) under a real section, which ValidateResultsContent reports as // results-filler-section. The linked-but-invalid results path resolves to a -// tracked regular file, so identity holds and only the content contract fails. +// file on the metadata branch, so identity holds and only the content contract +// fails. const rvResultsInvalidContent = "# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nReal outcome prose describing the delivered behavior.\n\n## Findings and limitations\n\nNone.\n" func rvRecordedPR() string { return prRepo().Spec() + "#42" } @@ -89,8 +94,8 @@ func rvPR(head, body string) githubcli.PullRequest { } // rvFixture is a real repo whose invocation clone holds a local feature commit -// (head) carrying the plan and results artifacts, optionally published to the -// bare origin as refs/heads/feat/widget. +// (head), optionally published to the bare origin as refs/heads/feat/widget. +// The plan and results artifacts live on the metadata branch (the fake reader). type rvFixture struct { repo *gitRepo client *gitcli.Client @@ -98,9 +103,10 @@ type rvFixture struct { head string } -// newRunVerifyFixture builds the real-git fixture. The plan and results blobs are -// committed at head so run verify's blob reads succeed; when publish is true the -// feature head is pushed to origin so the remote-head postcondition holds. +// newRunVerifyFixture builds the real-git fixture. The feature head carries the +// implementation plus a results file that exists ONLY there +// (rvResultsHeadOnlyPath); when publish is true the feature head is pushed to +// origin so the remote-head postcondition holds. func newRunVerifyFixture(t *testing.T, publish bool) *rvFixture { t.Helper() requireRealGit(t) @@ -108,9 +114,8 @@ func newRunVerifyFixture(t *testing.T, publish bool) *rvFixture { client := newGitClient(t) runGit(t, repo.invocation, "checkout", "-q", "-b", "feat/"+rvSlug) - writeRepoFile(t, repo.invocation, rvPlanPath, "# plan\n") - writeRepoFile(t, repo.invocation, rvResultsPath, rvResultsValidContent) - writeRepoFile(t, repo.invocation, rvResultsInvalidPath, rvResultsInvalidContent) + writeRepoFile(t, repo.invocation, "widget.go", "package widget\n") + writeRepoFile(t, repo.invocation, rvResultsHeadOnlyPath, rvResultsValidContent) runGit(t, repo.invocation, "add", "-A") runGit(t, repo.invocation, "commit", "-q", "-m", "feature work") head := runGit(t, repo.invocation, "rev-parse", "HEAD") @@ -122,17 +127,15 @@ func newRunVerifyFixture(t *testing.T, publish bool) *rvFixture { } // deps assembles the run-verify deps over a fixture: the fake reader supplies the -// implemented corpus record and the plan on the metadata branch, the fake -// workspace service reports the local head, the fake GitHub adapter reports the -// PR, and the real client performs the remote probe and the head blob reads. +// implemented corpus record and the plan and results files on the metadata +// branch, the fake workspace service reports the local head, the fake GitHub +// adapter reports the PR, and the real client performs the remote probe. func (f *rvFixture) deps(record []byte, pr githubcli.PullRequest) (PlanningDeps, WorkspaceDeps, GitHubDeps) { reader := &fakeReader{ - pin: f.pin, - corpus: []StatusBlob{{Kind: repository.KindChange, Location: repository.LocationActive, Path: groomPath(3, rvSlug), Revision: miRevision, Data: record}}, - facts: domain.NewBranchFacts(nil), - artifactData: map[string]StatusArtifact{ - sourceMetadata + "|" + rvPlanPath: {Found: true, Revision: "planblob", Data: []byte("# plan\n")}, - }, + pin: f.pin, + corpus: []StatusBlob{{Kind: repository.KindChange, Location: repository.LocationActive, Path: groomPath(3, rvSlug), Revision: miRevision, Data: record}}, + facts: domain.NewBranchFacts(nil), + artifactData: rvMetadataArtifacts(), } deps := PlanningDeps{Client: f.client, Reader: reader, Clock: testClock()} wdeps := WorkspaceDeps{Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}} @@ -140,6 +143,16 @@ func (f *rvFixture) deps(record []byte, pr githubcli.PullRequest) (PlanningDeps, return deps, wdeps, gdeps } +// rvMetadataArtifacts is the metadata-branch file set every run-verify fixture +// reader serves: the plan, a FINAL-valid results file, and a FINAL-invalid one. +func rvMetadataArtifacts() map[string]StatusArtifact { + return map[string]StatusArtifact{ + sourceMetadata + "|" + rvPlanPath: {Found: true, Revision: "planblob", Data: []byte("# plan\n")}, + sourceMetadata + "|" + rvResultsPath: {Found: true, Revision: "resultsblob", Data: []byte(rvResultsValidContent)}, + sourceMetadata + "|" + rvResultsInvalidPath: {Found: true, Revision: "invalidblob", Data: []byte(rvResultsInvalidContent)}, + } +} + // unmetReasons projects the result's condition list onto its stable reason // strings, sorted for a stable comparison. func unmetReasons(res RunVerifyResult) []string { diff --git a/internal/app/runtracker_ownership_integration_test.go b/internal/app/runtracker_ownership_integration_test.go index 97fc53539..ca78d1513 100644 --- a/internal/app/runtracker_ownership_integration_test.go +++ b/internal/app/runtracker_ownership_integration_test.go @@ -31,8 +31,8 @@ import ( // run-complete against the one branch the fixture published. // mxImplementedRecord renders an implemented change (id, slug) whose linkage -// verifies against the shared rv fixture: plan/results at the fixture head, the -// recorded PR, and branch feat/widget (the branch newRunVerifyFixture pushed), so +// verifies against the shared rv fixture: plan/results on the metadata branch +// (rvMetadataArtifacts), the recorded PR, and branch feat/widget (the branch newRunVerifyFixture pushed), so // RunVerify reports run-complete for any id that reuses that one feature branch. func mxImplementedRecord(id int, slug string) []byte { src := lifecycleChange(id, slug, "in-progress") @@ -77,15 +77,16 @@ func mxBlob(id int, slug string, record []byte) StatusBlob { // mxDeps assembles run-verify deps over the rv fixture for a single change (id, // slug), mirroring rvFixture.deps but generalized off change 3: the reader supplies -// one record, the fake workspace reports the fixture head, the fake GitHub reports -// the fixture PR, and the real client performs the remote/blob probes. The caller -// attaches wdeps.ClaimProofs. +// one record and the plan and results files on the metadata branch, the fake +// workspace reports the fixture head, the fake GitHub reports the fixture PR, and +// the real client performs the remote probe. The caller attaches wdeps.ClaimProofs. func mxDeps(t *testing.T, f *rvFixture, id int, slug string, record []byte) (PlanningDeps, WorkspaceDeps, GitHubDeps) { t.Helper() reader := &fakeReader{ - pin: f.pin, - corpus: []StatusBlob{mxBlob(id, slug, rvWithEvidence(t, record, prEvidenceBytes(t, f.head)))}, - facts: domain.NewBranchFacts(nil), + pin: f.pin, + corpus: []StatusBlob{mxBlob(id, slug, rvWithEvidence(t, record, prEvidenceBytes(t, f.head)))}, + facts: domain.NewBranchFacts(nil), + artifactData: rvMetadataArtifacts(), } deps := PlanningDeps{Client: f.client, Reader: reader, Clock: testClock()} wdeps := WorkspaceDeps{Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}} @@ -219,7 +220,7 @@ func TestIntegrationRunFenceUnrelatedChurnDoesNotMoveOwnership(t *testing.T) { mxBind(t, f.repo.invocation, key, 3, "claim-3-v", "rA") verdict := func(proofs []ClaimProof, corpus []StatusBlob) RunVerdictResult { - reader := &fakeReader{pin: f.pin, corpus: corpus, facts: domain.NewBranchFacts(nil)} + reader := &fakeReader{pin: f.pin, corpus: corpus, facts: domain.NewBranchFacts(nil), artifactData: rvMetadataArtifacts()} deps := PlanningDeps{Client: f.client, Reader: reader, Clock: testClock()} wdeps := WorkspaceDeps{ Service: &fakeWorkspaceService{inspection: workspace.Inspection{Kind: workspace.StateReady, HeadCommit: gitcli.ObjectID(f.head)}}, diff --git a/internal/app/status.go b/internal/app/status.go index f62371a79..ee1a88506 100644 --- a/internal/app/status.go +++ b/internal/app/status.go @@ -516,10 +516,10 @@ func readinessReason(r domain.Readiness) string { } // artifactChecks verifies each non-empty artifact link of an active change -// against the sources its kind lives on, in order: the spec and the plan on the -// metadata branch — a plan absent there falls back to the integration branch, -// where a record closed before plans moved to the metadata branch keeps it — -// and results on the integration branch. A target absent from every source is +// against the sources its kind lives on, in order: the spec, the plan, and the +// results on the metadata branch — a plan or results absent there falls back to +// the integration branch, where a record closed before they moved to the +// metadata branch keeps them. A target absent from every source is // an error finding; an empty link produces no finding (a distinct, benign // state). An ArtifactExists error propagates as an operation failure. func artifactChecks(ctx context.Context, reader StatusReader, pin StatusPin, c domain.Change) ([]StatusFinding, error) { @@ -531,7 +531,7 @@ func artifactChecks(ctx context.Context, reader StatusReader, pin StatusPin, c d }{ {"spec", c.Spec(), []string{sourceMetadata}}, {"plan", c.Plan(), []string{sourceMetadata, sourceIntegration}}, - {"results", c.Results(), []string{sourceIntegration}}, + {"results", c.Results(), []string{sourceMetadata, sourceIntegration}}, } for _, link := range links { if link.value.State != domain.FieldPresent || link.value.Value == "" { diff --git a/internal/app/status_test.go b/internal/app/status_test.go index 7bf0b1fd9..cf197723c 100644 --- a/internal/app/status_test.go +++ b/internal/app/status_test.go @@ -202,15 +202,17 @@ func TestStatusRecordsOptIn(t *testing.T) { } // TestStatusSourceDistinction is the artifact-source-distinction probe target: -// the operation asks the metadata source for a spec and the integration source -// for a plan, so the recorded source names diverge. +// the operation asks the metadata source for the spec, the plan, and the +// results, and asks the integration source for a plan or results only when the +// metadata branch lacks it, so the recorded source names diverge. func TestStatusSourceDistinction(t *testing.T) { pin := docketPin(t) specPath := "docs/changes/specs/spec-a.md" planPath := "docs/changes/plans/plan-a.md" + resultsPath := "docs/changes/results/results-a.md" corpus := []StatusBlob{ changeBlob(11, "alpha", "feat", "high", - fmt.Sprintf("spec: %s\nplan: %s\n", specPath, planPath)), + fmt.Sprintf("spec: %s\nplan: %s\nresults: %s\n", specPath, planPath, resultsPath)), changeBlob(12, "beta", "fix", "low", "spec: docs/changes/specs/spec-b.md\n"), } fake := &fakeReader{ @@ -220,6 +222,7 @@ func TestStatusSourceDistinction(t *testing.T) { artifacts: map[string]bool{ "metadata|" + specPath: true, "metadata|" + planPath: true, + "metadata|" + resultsPath: true, "metadata|docs/changes/specs/spec-b.md": true, }, } @@ -236,54 +239,66 @@ func TestStatusSourceDistinction(t *testing.T) { if !asked["metadata|"+specPath] { t.Errorf("spec was not checked against the metadata source; asks=%v", fake.artifactAsks) } - if !asked["metadata|"+planPath] { - t.Errorf("plan was not checked against the metadata source; asks=%v", fake.artifactAsks) - } - if asked["integration|"+planPath] { - t.Errorf("a plan present on the metadata branch was also asked of the integration source; asks=%v", fake.artifactAsks) + for _, p := range []string{planPath, resultsPath} { + if !asked["metadata|"+p] { + t.Errorf("%s was not checked against the metadata source; asks=%v", p, fake.artifactAsks) + } + if asked["integration|"+p] { + t.Errorf("%s present on the metadata branch was also asked of the integration source; asks=%v", p, fake.artifactAsks) + } } if hasFindingCode(got.Findings, string(FCArtifactMissing)) { - t.Errorf("a plan present on the metadata branch reported artifact-missing: %v", got.Findings) + t.Errorf("artifacts present on the metadata branch reported artifact-missing: %v", got.Findings) } - // A plan absent on the metadata branch falls back to the integration source - // (a record closed before plans moved there), and is missing only when both - // sources lack it. - for _, c := range []struct { - name string - integration bool - wantMissing bool - }{ - {"integration fallback", true, false}, - {"absent on both", false, true}, + // A plan or results file absent on the metadata branch falls back to the + // integration source (a record closed before they moved there), and is + // missing only when both sources lack it. + for _, kind := range []struct{ field, path, sibling string }{ + {"plan", planPath, resultsPath}, + {"results", resultsPath, planPath}, } { - t.Run(c.name, func(t *testing.T) { - fb := &fakeReader{ - pin: pin, - corpus: corpus, - facts: domain.NewBranchFacts(nil), - artifacts: map[string]bool{ - "metadata|" + specPath: true, - "integration|" + planPath: c.integration, - "metadata|docs/changes/specs/spec-b.md": true, - }, - } - res := Status(context.Background(), fb, StatusOptions{}) - if res.Result != ResultApplied { - t.Fatalf("result = %q, want applied; message=%q", res.Result, res.Message) - } - var sawMeta, sawIntegration bool - for _, ask := range fb.artifactAsks { - sawMeta = sawMeta || ask == "metadata|"+planPath - sawIntegration = sawIntegration || ask == "integration|"+planPath - } - if !sawMeta || !sawIntegration { - t.Errorf("plan absent on metadata must be asked of metadata then integration; asks=%v", fb.artifactAsks) - } - if got := hasFindingCode(res.Findings, string(FCArtifactMissing)); got != c.wantMissing { - t.Errorf("artifact-missing reported = %v, want %v (findings %v)", got, c.wantMissing, res.Findings) - } - }) + for _, c := range []struct { + name string + integration bool + wantMissing bool + }{ + {"integration fallback", true, false}, + {"absent on both", false, true}, + } { + t.Run(kind.field+"/"+c.name, func(t *testing.T) { + fb := &fakeReader{ + pin: pin, + corpus: corpus, + facts: domain.NewBranchFacts(nil), + artifacts: map[string]bool{ + "metadata|" + specPath: true, + "metadata|" + kind.sibling: true, + "integration|" + kind.path: c.integration, + "metadata|docs/changes/specs/spec-b.md": true, + }, + } + res := Status(context.Background(), fb, StatusOptions{}) + if res.Result != ResultApplied { + t.Fatalf("result = %q, want applied; message=%q", res.Result, res.Message) + } + var sawMeta, sawIntegration bool + for _, ask := range fb.artifactAsks { + sawMeta = sawMeta || ask == "metadata|"+kind.path + sawIntegration = sawIntegration || ask == "integration|"+kind.path + } + if !sawMeta || !sawIntegration { + t.Errorf("%s absent on metadata must be asked of metadata then integration; asks=%v", kind.field, fb.artifactAsks) + } + var missing bool + for _, f := range res.Findings { + missing = missing || (f.Code == string(FCArtifactMissing) && f.Field == kind.field) + } + if missing != c.wantMissing { + t.Errorf("%s artifact-missing reported = %v, want %v (findings %v)", kind.field, missing, c.wantMissing, res.Findings) + } + }) + } } // The pin is threaded verbatim into every post-pin reader call. diff --git a/internal/app/workflow_e2e_test.go b/internal/app/workflow_e2e_test.go index dc6491088..8d008d351 100644 --- a/internal/app/workflow_e2e_test.go +++ b/internal/app/workflow_e2e_test.go @@ -132,27 +132,30 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . t.Fatalf("planning moved the feature head %s -> %s", prepHead, got) } - // (7) The implementation commit advances the feature head. A results artifact is - // REQUIRED at the implemented boundary (change 0410), so it rides this commit - // with its deterministic backlink stamped through the artifact-backlink - // operation, and is attached below. + // (7) The implementation commit advances the feature head; it carries only + // the code. writeRepoFile(t, wp, "widget.go", "package widget\n") - writeRepoFile(t, wp, resultsPath, "# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget end to end; the gate certifies this head.\n") - blR := ArtifactBacklink(ctx, node.deps, wp, ArtifactBacklinkRequest{ArtifactPath: resultsPath, ChangePath: recPath}) - if blR.Result != ResultApplied { - t.Fatalf("artifact backlink (results) = %q (reason %q msg %q)", blR.Result, blR.Reason, blR.Message) - } runGit(t, wp, "add", "-A") runGit(t, wp, "commit", "-q", "-m", "implement the widget") head := runGit(t, wp, "rev-parse", "HEAD") + if got := runGit(t, wp, "show", "--name-only", "--format=", head); got != "widget.go" { + t.Fatalf("the implementation commit changed %q, want only widget.go", got) + } - // (7b) Attach the results artifact so the record carries results: — the + // (7b) A results checkpoint: attach-results writes the results on the + // metadata branch with its backlink and sets results: — the // mark-implemented results condition (change 0410) requires it. - attachR := ChangeAttachResults(ctx, node.deps, wdeps, node.dir, - ChangeAttachRequest{ID: id, Revision: ver(), Path: resultsPath, Commit: head}) + attachR := ChangeAttachResults(ctx, node.deps, node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: resultsPath, + Markdown: []byte("# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nImplementation in progress; the gate has not run yet.\n"), + }) if attachR.Result != ResultApplied { t.Fatalf("attach results = %q (reason %q msg %q findings %v)", attachR.Result, attachR.Reason, attachR.Message, attachR.Findings) } + storedResults, ok := originFile(t, repo.origin, m.branch, resultsPath) + if !ok || !strings.Contains(storedResults, "docket:backlink:start") { + t.Fatalf("results file at the metadata remote tip is missing or lacks its backlink:\n%s", storedResults) + } // (8) Launch the real trivially-passing gate through the gate supervisor and // observe it to a passed terminal. @@ -176,6 +179,26 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . t.Fatalf("evidence verify = %q (verdict %q reason %q)", v.Result, v.Verdict, v.Reason) } + // (10a) A later results checkpoint after the gate certified the head is a + // metadata commit only: the feature head does not move, so the evidence + // still verifies against it. + attachR2 := ChangeAttachResults(ctx, node.deps, node.dir, ChangeAttachRequest{ + ID: id, Revision: ver(), Path: resultsPath, + Markdown: []byte("# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget end to end; the gate certifies this head.\n"), + }) + if attachR2.Result != ResultApplied { + t.Fatalf("second attach results = %q (reason %q msg %q findings %v)", attachR2.Result, attachR2.Reason, attachR2.Message, attachR2.Findings) + } + if got := runGit(t, wp, "rev-parse", "HEAD"); got != head { + t.Fatalf("a results checkpoint moved the feature head %s -> %s", head, got) + } + if v := EvidenceVerify(EvidenceVerifyRequest{RecordFile: evidenceBytes, Head: head}); v.Result != ResultApplied { + t.Fatalf("evidence verify after a results checkpoint = %q (verdict %q reason %q)", v.Result, v.Verdict, v.Reason) + } + if got, _ := originFile(t, repo.origin, m.branch, resultsPath); !strings.Contains(got, "the gate certifies this head") { + t.Fatalf("the later checkpoint did not replace the results on the metadata branch:\n%s", got) + } + // (10b) Tear the observed gate down the way a real run's gate lifecycle does. // GateLaunch took the worktree lock and its supervisor released it on exit // (change 0490), so the worktree is already free; the explicit stop on the @@ -246,10 +269,11 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // Negative half: every metadata-remote commit past the fixture base is an // engine transaction (carries Docket-Transaction-ID), and the durable - // transitions are exactly the operations that ran — no direct skill-owned - // metadata write slipped in. + // transitions are exactly the operations that ran — two results + // checkpoints included — and no direct skill-owned metadata write slipped + // in. assertEngineOnlyMetadataCommits(t, repo.origin, m.branch, baseTip, - []string{"change.attach-plan", "change.attach-results", "change.claim", "change.mark-implemented", "change.reconcile"}) + []string{"change.attach-plan", "change.attach-results", "change.attach-results", "change.claim", "change.mark-implemented", "change.reconcile"}) return gdeps } if len(entries) == 0 { diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index 7c8fda196..da56445fc 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -234,47 +234,42 @@ func TestIntegrationWorkflowRepoChangeAttachPlanMetadataHappyPath(t *testing.T) } // TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent proves -// change.attach-results runs checkpoint-phase content validation AFTER the -// backlink guard: a raw template scaffold (angle-bracket placeholders behind a -// correct backlink) refuses with results-content-invalid and writes nothing, -// while a truthful in-progress artifact (title + backlink + one real Outcome -// paragraph and no other sections) attaches — the checkpoint phase never demands -// the final content contract. +// change.attach-results runs checkpoint-phase content validation: a raw template +// scaffold refuses with results-content-invalid and writes nothing, while a +// truthful in-progress artifact (title + one real Outcome paragraph and no other +// sections) is written on the metadata branch with its backlink and linked — the +// checkpoint phase never demands the final content contract. func TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent(t *testing.T) { const resultsPath = "docs/results/2026-08-17-widget-results.md" t.Run("raw template scaffold refuses with results-content-invalid", func(t *testing.T) { f := attachSetup(t) - // A correct backlink (so the backlink guard passes) fronting the unfilled - // authoring template: the H1 and the Outcome body are unfilled emitted - // template prompts (change 0414 — derived from the shipped template). - scaffold := attachBacklinkBlock(f.id, "A change", f.recPath) + - "\n# <Change title> — Results\n\n## Outcome\n\n<The original problem, the delivered behavior, and any material departure from the\nagreed design — lead with observable effects. Explain unfamiliar Docket concepts when\nnecessary; include method names, stored fields, or internal identifiers only when they\nhelp the reader understand a consequence or take action.>\n" - head := f.commitArtifact(t, map[string]string{resultsPath: scaffold}) - res := ChangeAttachResults(f.ctx, f.deps, f.wdeps, f.invocation, - ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Commit: head}) + // The unfilled authoring template: the H1 and the Outcome body are + // unfilled emitted template prompts (change 0414 — derived from the + // shipped template). + scaffold := "# <Change title> — Results\n\n## Outcome\n\n<The original problem, the delivered behavior, and any material departure from the\nagreed design — lead with observable effects. Explain unfamiliar Docket concepts when\nnecessary; include method names, stored fields, or internal identifiers only when they\nhelp the reader understand a consequence or take action.>\n" + tip := originTip(t, f.repo.origin, "docket") + res := ChangeAttachResults(f.ctx, f.deps, f.invocation, + ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Markdown: []byte(scaffold)}) if res.Result == ResultApplied { t.Fatalf("scaffold attach applied, want a refusal") } if res.Reason != ReasonAttachResultsContent { t.Fatalf("reason = %q, want %q (msg %q)", res.Reason, ReasonAttachResultsContent, res.Message) } - // A refusal opens no transaction: the remote record keeps no results field. - final, ok := originFile(t, f.repo.origin, "docket", f.recPath) - if ok && strings.Contains(final, "results: '") { - t.Errorf("a refused attach wrote the results field to the remote:\n%s", final) + // A refusal opens no transaction: the metadata remote did not move. + if got := originTip(t, f.repo.origin, "docket"); got != tip { + t.Errorf("a refused attach moved the metadata branch %s -> %s", tip, got) } }) t.Run("truthful in-progress artifact attaches", func(t *testing.T) { f := attachSetup(t) - // Title + backlink + one real Outcome paragraph, no other sections: a - // checkpoint artifact must not be held to the final content contract. - artifact := attachBacklinkBlock(f.id, "A change", f.recPath) + - "\n# Widget — Results\n\n## Outcome\n\nDelivered the in-progress slice; behavior X now refuses Y.\n" - head := f.commitArtifact(t, map[string]string{resultsPath: artifact}) - res := ChangeAttachResults(f.ctx, f.deps, f.wdeps, f.invocation, - ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Commit: head}) + // Title + one real Outcome paragraph, no other sections: a checkpoint + // artifact must not be held to the final content contract. + body := "# Widget — Results\n\n## Outcome\n\nDelivered the in-progress slice; behavior X now refuses Y.\n" + res := ChangeAttachResults(f.ctx, f.deps, f.invocation, + ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Markdown: []byte(body)}) if res.Result != ResultApplied { t.Fatalf("checkpoint attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) } @@ -285,9 +280,50 @@ func TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent(t *testing. if !strings.Contains(final, "results: '"+resultsPath+"'") { t.Errorf("committed record missing the results field:\n%s", final) } + stored, ok := originFile(t, f.repo.origin, "docket", resultsPath) + if want := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n" + body; !ok || stored != want { + t.Errorf("stored results =\n%q\nwant\n%q", stored, want) + } + // One metadata commit: the results file and the record (the board too + // when inline) — never a feature-branch path. + for _, p := range originCommitPaths(t, f.repo.origin, res.Revision) { + if p != resultsPath && p != f.recPath && p != "docs/changes/BOARD.md" { + t.Errorf("results attach commit changed unexpected path %q", p) + } + } }) } +// TestIntegrationWorkflowRepoChangeAttachResultsFeatureHeadNeverConsulted proves +// a results attach neither reads nor writes the feature branch: with a +// DIFFERENT (and checkpoint-invalid) results file committed at the feature head +// under the same path, the attach still applies, the metadata branch stores +// exactly the submitted body behind its backlink, and the feature head and the +// feature-head file are untouched. +func TestIntegrationWorkflowRepoChangeAttachResultsFeatureHeadNeverConsulted(t *testing.T) { + const resultsPath = "docs/results/2026-08-17-widget-results.md" + f := attachSetup(t) + headFile := "# <Change title> — Results\n\n## Outcome\n\n<unfilled>\n" + head := f.commitArtifact(t, map[string]string{resultsPath: headFile}) + + body := "# Widget — Results\n\n## Outcome\n\nDelivered the slice the metadata branch records.\n" + res := ChangeAttachResults(f.ctx, f.deps, f.invocation, + ChangeAttachRequest{ID: f.id, Revision: f.revision, Path: resultsPath, Markdown: []byte(body)}) + if res.Result != ResultApplied { + t.Fatalf("attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) + } + stored, ok := originFile(t, f.repo.origin, "docket", resultsPath) + if want := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n" + body; !ok || stored != want { + t.Errorf("stored results =\n%q\nwant\n%q", stored, want) + } + if got := runGit(t, f.wp, "rev-parse", "HEAD"); got != head { + t.Errorf("a results attach moved the feature head %s -> %s", head, got) + } + if got := runGit(t, f.wp, "show", "HEAD:"+resultsPath); got+"\n" != headFile { + t.Errorf("a results attach changed the feature-head file:\n%q", got) + } +} + // TestClaimRaceLosesCleanly proves a claimant working from a context revision that // the origin has since diverged past loses cleanly: its claim is refused as // `contended` against fresh origin state, the metadata remote holds exactly one diff --git a/internal/cli/change.go b/internal/cli/change.go index bc09e2440..f74ed5c4c 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -110,7 +110,7 @@ func newChangeCommand(setResult func(app.OperationResult)) *cobra.Command { setResult(app.ChangeReconcile(c.Context(), deps, repoDir, req)) }, EffectMetadataWrite) - attachPlan := changeAttachMarkdownSubcommand("attach-plan", + attachPlan := changeAttachSubcommand("attach-plan", "Write a plan on the metadata branch and link it to an in-progress change", "plan Markdown `file`, or - for stdin; the operation writes it on the metadata branch with its backlink (required)", func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest) { @@ -118,9 +118,10 @@ func newChangeCommand(setResult func(app.OperationResult)) *cobra.Command { }, EffectMetadataWrite) attachResults := changeAttachSubcommand("attach-results", - "Verify an authored results record from Git and link it to an in-progress change", - func(c *cobra.Command, deps app.PlanningDeps, wdeps app.WorkspaceDeps, repoDir string, req app.ChangeAttachRequest) { - setResult(app.ChangeAttachResults(c.Context(), deps, wdeps, repoDir, req)) + "Write a results record on the metadata branch and link it to an in-progress change", + "results Markdown `file`, or - for stdin; the operation writes it on the metadata branch with its backlink (required)", + func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest) { + setResult(app.ChangeAttachResults(c.Context(), deps, repoDir, req)) }, EffectMetadataWrite) halt := changeInputSubcommand("halt", @@ -343,13 +344,14 @@ func newMarkImplementedSubcommand(setResult func(app.OperationResult)) *cobra.Co return cmd } -// changeAttachMarkdownSubcommand builds one `change <verb>` command that writes -// an authored artifact on the metadata branch and links it: the (id, revision, -// path) scalars ride flags, and the artifact body is the raw Markdown read from -// `--markdown <file>` (or `-` for stdin) — a non-JSON file input (ADR-0138) the -// operation stores verbatim behind its rendered backlink. It needs only the -// planning seams: nothing reads the feature workspace. -func changeAttachMarkdownSubcommand(verb, short, markdownUsage string, run func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest), effects ...Effect) *cobra.Command { +// changeAttachSubcommand builds one `change <verb>` command that writes an +// authored artifact (a plan, or a results record) on the metadata branch and +// links it: the (id, revision, path) scalars ride flags, and the artifact body +// is the raw Markdown read from `--markdown <file>` (or `-` for stdin) — a +// non-JSON file input (ADR-0138) the operation stores verbatim behind its +// rendered backlink. It needs only the planning seams: nothing reads the feature +// workspace. +func changeAttachSubcommand(verb, short, markdownUsage string, run func(c *cobra.Command, deps app.PlanningDeps, repoDir string, req app.ChangeAttachRequest), effects ...Effect) *cobra.Command { cmd := &cobra.Command{ Use: verb, Short: short, @@ -388,47 +390,6 @@ func changeAttachMarkdownSubcommand(verb, short, markdownUsage string, run func( return cmd } -// changeAttachSubcommand builds one `change <verb>` command whose input is the -// (id, revision, path, commit) tuple: attach-results verifies an artifact -// committed at the feature head from Git and links it, so it takes scalar flags -// (Global Constraints: request files are for authored Markdown, never these). It -// builds the workspace-backed deps the attach operation needs to inspect the -// owned checkout. -func changeAttachSubcommand(verb, short string, run func(c *cobra.Command, deps app.PlanningDeps, wdeps app.WorkspaceDeps, repoDir string, req app.ChangeAttachRequest), effects ...Effect) *cobra.Command { - cmd := &cobra.Command{ - Use: verb, - Short: short, - Args: cobra.NoArgs, - Annotations: capability("change."+verb, effects...), - RunE: func(c *cobra.Command, _ []string) error { - repoDir, err := resolveRepoDir(c) - if err != nil { - return err - } - id, _ := c.Flags().GetInt("id") - revision, _ := c.Flags().GetString("revision") - artifactPath, _ := c.Flags().GetString("path") - commit, _ := c.Flags().GetString("commit") - deps, wdeps, err := newWorkspaceDeps(repoDir) - if err != nil { - return err - } - run(c, deps, wdeps, repoDir, app.ChangeAttachRequest{ID: id, Revision: revision, Path: artifactPath, Commit: commit}) - return nil - }, - } - cmd.Flags().Int("id", 0, "change `id` to attach the artifact to (required)") - cmd.Flags().String("revision", "", "exact record `revision` (the blob object id) from the authoritative context read (required)") - cmd.Flags().String("path", "", "canonical repository-relative artifact `path` (required)") - cmd.Flags().String("commit", "", "exact feature commit `sha` the writer reported (required)") - cmd.Flags().String("repo-dir", "", "repository `dir` to operate on (default: current directory)") - _ = cmd.MarkFlagRequired("id") - _ = cmd.MarkFlagRequired("revision") - _ = cmd.MarkFlagRequired("path") - _ = cmd.MarkFlagRequired("commit") - return cmd -} - // changeInputSubcommand builds one `change <verb>` command whose authored- // Markdown request rides in a JSON body read from `--input <request-file>` (or // `-` for stdin). It mirrors changeSubcommand but names the flag --input, the diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go index 41095bb5e..6df3c5f38 100644 --- a/internal/cli/change_test.go +++ b/internal/cli/change_test.go @@ -304,10 +304,10 @@ func TestChangeClaimCommandsReachOperation(t *testing.T) { } // TestChangeAttachCommandsRegistered proves attach-plan and attach-results are -// wired as change subcommands carrying their kind's flag set: attach-plan takes -// the scalar --id/--revision/--path plus the --markdown body file (the plan is -// written on the metadata branch) and no --commit; attach-results still names a -// verified feature commit with --commit. +// wired as change subcommands carrying one shared flag set: the scalar +// --id/--revision/--path plus the --markdown body file (both artifacts are +// written on the metadata branch) and no --commit (the feature head is never +// part of an attach). func TestChangeAttachCommandsRegistered(t *testing.T) { root := captureTree(t) cases := []struct { @@ -316,7 +316,7 @@ func TestChangeAttachCommandsRegistered(t *testing.T) { without []string }{ {"attach-plan", []string{"id", "revision", "path", "markdown", "repo-dir"}, []string{"commit"}}, - {"attach-results", []string{"id", "revision", "path", "commit", "repo-dir"}, []string{"markdown"}}, + {"attach-results", []string{"id", "revision", "path", "markdown", "repo-dir"}, []string{"commit"}}, } for _, c := range cases { cmd, _, err := root.Find([]string{"change", c.sub}) @@ -395,18 +395,24 @@ func TestChangeMarkImplementedReachesOperation(t *testing.T) { } } -// TestChangeAttachFlagsRequired proves the scalar flags are required: omitting -// them is an argument error (exit 2) before any operation runs. +// TestChangeAttachFlagsRequired proves the scalar flags and the artifact body +// are required on both attach verbs: omitting them is an argument error (exit 2) +// before any operation runs. func TestChangeAttachFlagsRequired(t *testing.T) { - _, errS, code := runCLI(t, "change", "attach-plan") - if code != 2 || errS == "" { - t.Fatalf("err=%q code=%d, want a required-flag argument error", errS, code) - } - // The plan body is required: every scalar present but no --markdown. - _, errS, code = runCLI(t, "change", "attach-plan", "--id", "7", - "--revision", "1234123412341234123412341234123412341234", "--path", "docs/superpowers/plans/x.md") - if code != 2 || !strings.Contains(errS, "markdown") { - t.Fatalf("err=%q code=%d, want a required --markdown argument error", errS, code) + for _, c := range []struct{ sub, path string }{ + {"attach-plan", "docs/superpowers/plans/x.md"}, + {"attach-results", "docs/results/x-results.md"}, + } { + _, errS, code := runCLI(t, "change", c.sub) + if code != 2 || errS == "" { + t.Fatalf("%s: err=%q code=%d, want a required-flag argument error", c.sub, errS, code) + } + // The artifact body is required: every scalar present but no --markdown. + _, errS, code = runCLI(t, "change", c.sub, "--id", "7", + "--revision", "1234123412341234123412341234123412341234", "--path", c.path) + if code != 2 || !strings.Contains(errS, "markdown") { + t.Fatalf("%s: err=%q code=%d, want a required --markdown argument error", c.sub, errS, code) + } } } @@ -420,12 +426,16 @@ func TestChangeAttachCommandsReachOperation(t *testing.T) { if err := os.WriteFile(planFile, []byte("# Plan\n\n## Task 1\n\nDo it.\n"), 0o644); err != nil { t.Fatalf("seed plan: %v", err) } + resultsFile := filepath.Join(dir, "results.md") + if err := os.WriteFile(resultsFile, []byte("# Widget — Results\n\n## Outcome\n\nDelivered.\n"), 0o644); err != nil { + t.Fatalf("seed results: %v", err) + } cases := []struct { sub, op string args []string }{ {"attach-plan", "change.attach-plan", []string{"--path", "docs/superpowers/plans/x.md", "--markdown", planFile}}, - {"attach-results", "change.attach-results", []string{"--path", "docs/results/x-results.md", "--commit", "1234123412341234123412341234123412341234"}}, + {"attach-results", "change.attach-results", []string{"--path", "docs/results/x-results.md", "--markdown", resultsFile}}, } for _, c := range cases { args := append([]string{"change", c.sub, "--id", "7", "--revision", "1234123412341234123412341234123412341234"}, c.args...) diff --git a/internal/cli/revision_rename_test.go b/internal/cli/revision_rename_test.go index 0975b06b5..deeee35e9 100644 --- a/internal/cli/revision_rename_test.go +++ b/internal/cli/revision_rename_test.go @@ -160,7 +160,7 @@ func TestRevisionFlagReachesRequest(t *testing.T) { {"", []string{"change", "reclaim", "--id", "1"}}, {"", []string{"change", "resume-halted", "--id", "1"}}, {"# Plan\n\nSteps.\n", []string{"change", "attach-plan", "--id", "1", "--path", "p.md", "--markdown", "-"}}, - {"", []string{"change", "attach-results", "--id", "1", "--path", "r.md", "--commit", head}}, + {"# R\n\n## Outcome\n\nDone.\n", []string{"change", "attach-results", "--id", "1", "--path", "r.md", "--markdown", "-"}}, {"", []string{"change", "mark-implemented", "--id", "1", "--head", head, "--pr", "https://github.com/o/r/pull/1", "--evidence", ev}}, {"{}", []string{"change", "halt", "--id", "1", "--input", "-"}}, {"{}", []string{"finalize", "block", "--id", "1", "--pr-number", "1", "--attempt", "x", "--reason", "rebase-stuck", "--head", head, "--input", "-"}}, From a3190f669c7ad9327a70b4b8c5827efcdb1e091a Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 01:52:51 -0400 Subject: [PATCH 10/28] refactor: retire artifact.backlink; the attach operations stamp their own backlinks --- internal/app/artifact_backlink.go | 379 ------------------ internal/app/artifact_backlink_test.go | 299 -------------- internal/app/change_attach.go | 10 + .../app/change_attach_git_helpers_test.go | 27 +- internal/app/finding_codes.go | 6 +- internal/app/schema_registry.go | 1 - internal/cli/artifact.go | 62 --- internal/cli/artifact_test.go | 89 ---- internal/cli/install.go | 2 - internal/cli/root.go | 3 +- 10 files changed, 40 insertions(+), 838 deletions(-) delete mode 100644 internal/app/artifact_backlink.go delete mode 100644 internal/app/artifact_backlink_test.go delete mode 100644 internal/cli/artifact.go delete mode 100644 internal/cli/artifact_test.go diff --git a/internal/app/artifact_backlink.go b/internal/app/artifact_backlink.go deleted file mode 100644 index 1bf9f135b..000000000 --- a/internal/app/artifact_backlink.go +++ /dev/null @@ -1,379 +0,0 @@ -package app - -import ( - "context" - "fmt" - "os" - "path" - "path/filepath" - "strings" - - "github.com/danielhanold/docket/internal/document" - "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/render" - "github.com/danielhanold/docket/internal/repository" -) - -// This file is the `artifact backlink` operation: it stamps the deterministic -// docket:backlink managed block at the TOP of a spec/plan/results artifact in the -// FEATURE worktree, pointing home to its change record. It is the one operation -// in this slice that writes a working-tree file rather than driving a metadata -// transaction — the plan-writer commits the file afterward. It renders through -// render.BacklinkContent and rewrites the managed block through internal/document, -// so a malformed backlink marker (dangling/out-of-order/nested) fails the -// document parse and the file is left untouched. The write target is contained: -// an absolute path, a ../ escape, and a symlink escaping the worktree are each -// rejected before any byte is written, and every symlink hop is canonicalised so -// one physical file cannot answer to two names (learning -// canonicalise-every-symlink-hop). Same inputs yield a byte-identical file. - -// OperationArtifactBacklink is the operation key `artifact backlink` records in -// its result envelope. -const OperationArtifactBacklink = "artifact.backlink" - -// backlinkBlockName and backlinkBlockAnnotation are the managed block's marker -// identity. They mirror render.BacklinkContent's marker spelling exactly, so a -// block this operation inserts round-trips through render on the next run and the -// write is idempotent. -const ( - backlinkBlockName = "backlink" - backlinkBlockAnnotation = "generated — do not hand-edit" -) - -// The stable machine reasons `artifact backlink` reports for its typed refusals. -// Message text is explanatory and must not be parsed. -const ( - // ReasonBacklinkAbsolutePath: the artifact or change path is absolute; - // paths crossing the CLI are canonical repository-relative (Global - // Constraints). - ReasonBacklinkAbsolutePath = "absolute-path" - // ReasonBacklinkPathEscape: the artifact path escapes the worktree with a - // `..` traversal, or the change path is empty, escaping, or a - // non-canonical spelling. - ReasonBacklinkPathEscape = "path-escape" - // ReasonBacklinkSymlinkEscape: a symlink hop on the artifact path resolves to - // a physical location outside the worktree. - ReasonBacklinkSymlinkEscape = "symlink-escape" - // ReasonBacklinkArtifactNotFound: the artifact file does not exist. Backlinks - // are stamped onto an authored artifact, never created from nothing. - ReasonBacklinkArtifactNotFound = "artifact-not-found" - // ReasonBacklinkMalformedMarkers: the artifact's managed-block population is - // malformed (dangling/out-of-order/nested markers); the block is not rewritten - // and the file is left untouched. - ReasonBacklinkMalformedMarkers = "malformed-markers" - // ReasonBacklinkUnknownChange: the well-formed --change path names no - // record in the corpus, so no backlink can be rendered. - ReasonBacklinkUnknownChange = "unknown-change" - // ReasonBacklinkRepoUnreadable: the worktree root cannot be canonicalised. - ReasonBacklinkRepoUnreadable = "repo-unreadable" - // ReasonBacklinkArtifactUnreadable: the artifact path cannot be resolved on - // disk for a reason other than plain absence. - ReasonBacklinkArtifactUnreadable = "artifact-unreadable" -) - -// The closed set of dispositions a successful result carries. -const ( - backlinkDispositionRendered = "rendered" - backlinkDispositionUnchanged = "unchanged" -) - -// ArtifactBacklinkRequest is the closed request: the artifact to stamp and the -// change to point home to, both canonical repository-relative paths. ArtifactPath -// is resolved inside the feature worktree (repoDir); ChangePath keys the change -// record in the pinned corpus. -type ArtifactBacklinkRequest struct { - ArtifactPath string `json:"artifact_path"` - ChangePath string `json:"change_path"` -} - -// ArtifactBacklinkResult is the protocol-v1 document the operation returns. It -// names only paths and a disposition — never the artifact's authored bytes -// (redaction). -type ArtifactBacklinkResult struct { - Envelope - Artifact string `json:"artifact,omitempty"` - Change string `json:"change,omitempty"` - Disposition string `json:"disposition,omitempty"` - Reason string `json:"reason,omitempty"` - Message string `json:"message,omitempty"` -} - -// HumanText renders the one-line human summary. It carries paths and disposition -// only, never an authored document body (redaction constraint). -func (r ArtifactBacklinkResult) HumanText() string { - switch r.Result { - case ResultApplied: - return fmt.Sprintf("artifact backlink: rendered into %s (change %s)", r.Artifact, r.Change) - case ResultNoOp: - return fmt.Sprintf("artifact backlink: %s already current (change %s)", r.Artifact, r.Change) - default: - if r.Reason != "" { - return fmt.Sprintf("%s: %s (%s)", r.Operation, r.Result, r.Reason) - } - return fmt.Sprintf("%s: %s", r.Operation, r.Result) - } -} - -// newBacklinkResult stamps the envelope. -func newBacklinkResult(result Result, r ArtifactBacklinkResult) ArtifactBacklinkResult { - r.Envelope = NewEnvelope(OperationArtifactBacklink, result) - return r -} - -// backlinkRefusal builds a refusing result carrying a stable reason and message. -func backlinkRefusal(result Result, reason, message string) ArtifactBacklinkResult { - return newBacklinkResult(result, ArtifactBacklinkResult{Reason: reason, Message: message}) -} - -// ArtifactBacklink stamps the docket:backlink block onto the artifact at -// req.ArtifactPath, pointing home to the change at req.ChangePath. It reads the -// change from one pinned corpus, renders the block deterministically, rewrites -// the managed block through the marker-validating document layer, and writes the -// file only when its bytes change. Every refusal predates the write, so a refused -// call leaves the file byte-identical. -func ArtifactBacklink(ctx context.Context, deps PlanningDeps, repoDir string, req ArtifactBacklinkRequest) ArtifactBacklinkResult { - // 1. Resolve and contain the write target inside the worktree root. - root := repoDir - if strings.TrimSpace(root) == "" { - wd, err := os.Getwd() - if err != nil { - return backlinkRefusal(ResultInternalError, ReasonBacklinkRepoUnreadable, err.Error()) - } - root = wd - } - target, reason := containedArtifactPath(root, req.ArtifactPath) - if reason != "" { - result := ResultInvalidInput - if reason == ReasonBacklinkSymlinkEscape || reason == ReasonBacklinkRepoUnreadable { - result = ResultInvalidState - } - return backlinkRefusal(result, reason, - fmt.Sprintf("artifact path %q is not a contained repository-relative path", req.ArtifactPath)) - } - - // 2. Read the authored artifact. It must already exist — a backlink is stamped - // onto an authored file, never conjured from nothing. - original, err := os.ReadFile(target) - if err != nil { - if os.IsNotExist(err) { - return backlinkRefusal(ResultInvalidInput, ReasonBacklinkArtifactNotFound, - fmt.Sprintf("artifact %q does not exist", req.ArtifactPath)) - } - return backlinkRefusal(ResultInvalidState, ReasonBacklinkArtifactUnreadable, err.Error()) - } - - // 3. Parse the artifact — the whole-population marker validation. A malformed - // backlink marker (dangling/out-of-order/nested) fails here and the file is - // left untouched. - doc, err := document.Parse(original) - if err != nil { - return backlinkRefusal(ResultInvalidState, ReasonBacklinkMalformedMarkers, - fmt.Sprintf("artifact %q has a malformed managed-block population: %v", req.ArtifactPath, err)) - } - - // 4. Validate --change lexically before the corpus read: a malformed - // spelling is refused by form, so unknown-change is reached only by a - // well-formed path that names no record. - if reason, msg := validateBacklinkChangePath(req.ChangePath); reason != "" { - return backlinkRefusal(ResultInvalidInput, reason, msg) - } - - // 5. Resolve the target change from one pinned corpus read. - change, refusal := resolveBacklinkChange(ctx, deps, repoDir, req.ChangePath) - if refusal != nil { - return *refusal - } - - // 6. Render the deterministic backlink block and reduce it to the interior the - // document layer manages between the markers it owns. - block, err := render.BacklinkContent(change.change, change.link) - if err != nil { - return backlinkRefusal(ResultInternalError, ReasonStatusInternalError, err.Error()) - } - interior := backlinkInterior(block) - - // 7. Rewrite (or insert) the managed block. - var ps document.PatchSet - if _, ok := doc.Block(backlinkBlockName); ok { - ps.ReplaceBlock(backlinkBlockName, interior) - } else { - at := document.AtDocumentStart - if doc.HasFrontmatter() { - at = document.AfterFrontmatter - } - ps.InsertBlock(backlinkBlockName, backlinkBlockAnnotation, interior, at) - } - updated, err := doc.Apply(ps) - if err != nil { - return backlinkRefusal(ResultInvalidState, ReasonBacklinkMalformedMarkers, - fmt.Sprintf("rewriting the backlink block in %q: %v", req.ArtifactPath, err)) - } - - applied := ArtifactBacklinkResult{Artifact: req.ArtifactPath, Change: change.change.Path()} - - // 8. Idempotent write: unchanged bytes are a no-op, so a re-run yields a - // byte-identical file and no needless mtime churn. - if string(updated) == string(original) { - applied.Disposition = backlinkDispositionUnchanged - return newBacklinkResult(ResultNoOp, applied) - } - mode := os.FileMode(0o644) - if fi, statErr := os.Stat(target); statErr == nil { - mode = fi.Mode().Perm() - } - if err := os.WriteFile(target, updated, mode); err != nil { - return backlinkRefusal(ResultInvalidState, ReasonBacklinkArtifactUnreadable, err.Error()) - } - applied.Disposition = backlinkDispositionRendered - return newBacklinkResult(ResultApplied, applied) -} - -// backlinkChange bundles a resolved change with the link context its backlink -// renders under. -type backlinkChange struct { - change domain.Change - link render.LinkContext -} - -// resolveBacklinkChange pins context once, reads the corpus once, builds the -// snapshot, and returns the change whose canonical path equals changePath. An -// absent change is a typed unknown-change refusal — never a fabricated backlink. -func resolveBacklinkChange(ctx context.Context, deps PlanningDeps, repoDir, changePath string) (backlinkChange, *ArtifactBacklinkResult) { - pin, err := deps.Reader.PinContext(ctx, repoDir) - if err != nil { - result, reason := classifyStatusError(ctx, err) - r := backlinkRefusal(result, reason, err.Error()) - return backlinkChange{}, &r - } - blobs, err := deps.Reader.ReadCorpus(ctx, pin) - if err != nil { - result, reason := classifyStatusError(ctx, err) - r := backlinkRefusal(result, reason, err.Error()) - return backlinkChange{}, &r - } - inputs, _ := parseCorpus(blobs) - build, err := repository.BuildSnapshot(repository.BuildInput{Config: pin.Config.Effective, Documents: inputs}) - if err != nil { - r := backlinkRefusal(ResultInternalError, ReasonStatusInternalError, err.Error()) - return backlinkChange{}, &r - } - for _, c := range build.Snapshot.Changes() { - if c.Path() == changePath { - return backlinkChange{ - change: c, - link: linkContextOf(pin), - }, nil - } - } - r := backlinkRefusal(ResultInvalidInput, ReasonBacklinkUnknownChange, - fmt.Sprintf("no change record is present at %q in the corpus", changePath)) - return backlinkChange{}, &r -} - -// containedArtifactPath resolves rel against the worktree root and proves the -// physical write target stays inside it. It returns the absolute target on -// success, or a stable refusal reason. Every symlink hop is canonicalised -// (learning canonicalise-every-symlink-hop): an absolute symlink target is still -// a spelling that can traverse another link, so identity is decided by physical -// location, never by a trusted string. -func containedArtifactPath(root, rel string) (string, string) { - if strings.TrimSpace(rel) == "" { - return "", ReasonBacklinkPathEscape - } - if filepath.IsAbs(rel) { - return "", ReasonBacklinkAbsolutePath - } - // IsLocal rejects any path that escapes the directory it is evaluated in — a - // leading or interior `..` that climbs out, or an absolute path. - if !filepath.IsLocal(filepath.FromSlash(rel)) { - return "", ReasonBacklinkPathEscape - } - rootReal, err := filepath.EvalSymlinks(root) - if err != nil { - return "", ReasonBacklinkRepoUnreadable - } - target := filepath.Join(rootReal, filepath.FromSlash(rel)) - - // A symlink leaf is resolved explicitly and must stay contained; a broken or - // unresolvable symlink leaf fails closed. This closes the gap resolveEveryHop - // leaves for a symlink whose target does not yet exist. - if li, lerr := os.Lstat(target); lerr == nil && li.Mode()&os.ModeSymlink != 0 { - resolved, rerr := filepath.EvalSymlinks(target) - if rerr != nil || !withinRoot(rootReal, resolved) { - return "", ReasonBacklinkSymlinkEscape - } - } - - // Canonicalise every hop of the deepest existing prefix; an escaping - // intermediate directory or existing leaf surfaces in the physical path. - real, err := resolveEveryHop(target) - if err != nil { - return "", ReasonBacklinkArtifactUnreadable - } - if !withinRoot(rootReal, real) { - return "", ReasonBacklinkSymlinkEscape - } - return target, "" -} - -// withinRoot reports whether physical path p is the root itself or lies beneath -// it, comparing canonicalised absolute paths. -func withinRoot(rootReal, p string) bool { - return p == rootReal || strings.HasPrefix(p, rootReal+string(os.PathSeparator)) -} - -// resolveEveryHop canonicalises p by following every symlink hop. It resolves the -// deepest existing prefix with filepath.EvalSymlinks (which follows every hop of -// what it resolves) and re-appends the not-yet-existing tail, so a fresh leaf -// under a real directory resolves to a contained path while any symlink hop that -// escapes the tree — intermediate directory or existing leaf — surfaces in the -// returned physical path. -func resolveEveryHop(p string) (string, error) { - if real, err := filepath.EvalSymlinks(p); err == nil { - return real, nil - } - parent := filepath.Dir(p) - if parent == p { - return "", fmt.Errorf("no existing ancestor for %q", p) - } - parentReal, err := resolveEveryHop(parent) - if err != nil { - return "", err - } - return filepath.Join(parentReal, filepath.Base(p)), nil -} - -// validateBacklinkChangePath proves the --change value is a canonical -// repository-relative path — the one rule every path flag crossing the CLI -// follows (--artifact above, verifyAttachPath in change_attach.go). The check -// is purely lexical: the change record lives in the pinned git corpus, not on -// the feature worktree's filesystem, so there is no containment root to -// resolve against and no symlink to canonicalise. It mirrors verifyAttachPath -// deliberately (learning duplicated-gate-copies-the-whole-predicate: all four -// checks, not just the absolute-path threshold) without factoring it out, so -// the attach operations' observable reasons and messages stay untouched. It -// returns a stable refusal reason and a message naming the flag and the -// expected form, or ("", "") for a well-formed path. -func validateBacklinkChangePath(changePath string) (string, string) { - const form = "pass the canonical repository-relative change path (e.g. docs/changes/active/<id>-<slug>.md)" - if strings.TrimSpace(changePath) == "" { - return ReasonBacklinkPathEscape, - fmt.Sprintf("--change path is empty; %s", form) - } - if filepath.IsAbs(changePath) { - return ReasonBacklinkAbsolutePath, - fmt.Sprintf("--change path %q is absolute; %s", changePath, form) - } - if !filepath.IsLocal(filepath.FromSlash(changePath)) { - return ReasonBacklinkPathEscape, - fmt.Sprintf("--change path %q escapes the repository root; %s", changePath, form) - } - // Clean is a no-op for a canonical path; an input that changes under Clean - // is non-canonical (a `./`, `//`, interior `..`, or trailing-slash - // spelling) and is refused as an escape, matching verifyAttachPath. - if clean := path.Clean(changePath); clean != changePath { - return ReasonBacklinkPathEscape, - fmt.Sprintf("--change path %q is not in canonical repository-relative form; %s", changePath, form) - } - return "", "" -} diff --git a/internal/app/artifact_backlink_test.go b/internal/app/artifact_backlink_test.go deleted file mode 100644 index 1cf1d08cd..000000000 --- a/internal/app/artifact_backlink_test.go +++ /dev/null @@ -1,299 +0,0 @@ -package app - -import ( - "context" - "github.com/danielhanold/docket/internal/testsupport" - "os" - "path/filepath" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/render" - "github.com/danielhanold/docket/internal/reposetup" - "github.com/danielhanold/docket/internal/repository" -) - -// backlinkDeps wraps a fake reader as the read-only deps the operation consumes. -// ArtifactBacklink reads the change from the corpus and writes a working-tree -// file; it opens no transaction, so a nil Engine is deliberate. -func backlinkDeps(fake *fakeReader) PlanningDeps { - return PlanningDeps{Reader: fake, Clock: testClock()} -} - -// changeByPath rebuilds the snapshot from the same corpus the operation reads -// and returns the change at path, so a test's golden ties to render.Backlink -// Content rather than a hand-copied string. -func changeByPath(t *testing.T, pin StatusPin, corpus []StatusBlob, path string) domain.Change { - t.Helper() - inputs, _ := parseCorpus(corpus) - build, err := repository.BuildSnapshot(repository.BuildInput{Config: pin.Config.Effective, Documents: inputs}) - if err != nil { - t.Fatalf("build snapshot: %v", err) - } - for _, c := range build.Snapshot.Changes() { - if c.Path() == path { - return c - } - } - t.Fatalf("no change at %q in corpus", path) - return domain.Change{} -} - -// backlinkGolden is the exact managed block render.BacklinkContent produces for -// the change at changePath, in the pin's repo-relative link mode. -func backlinkGolden(t *testing.T, pin StatusPin, corpus []StatusBlob, changePath string) string { - t.Helper() - c := changeByPath(t, pin, corpus, changePath) - block, err := render.BacklinkContent(c, render.LinkContext{MetadataBranch: reposetup.MetadataBranchName}) - if err != nil { - t.Fatalf("render backlink golden: %v", err) - } - return block -} - -const backlinkChangePath = "docs/changes/active/0315-claim.md" - -// backlinkCorpus is one change the backlink can target. -func backlinkCorpus() []StatusBlob { - return []StatusBlob{changeBlob(315, "claim", "feat", "high", "")} -} - -// TestArtifactBacklinkRendersBlock: a fresh artifact (content, no backlink block) -// gains the managed block at its top, byte-matching render.BacklinkContent. -func TestArtifactBacklinkRendersBlock(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - root := testsupport.TempDir(t) - artifact := filepath.Join(root, "plan.md") - if err := os.WriteFile(artifact, []byte("# Plan\n\nAuthored body.\n"), 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: backlinkChangePath}) - if got.Result != ResultApplied { - t.Fatalf("result=%q reason=%q message=%q", got.Result, got.Reason, got.Message) - } - - out, err := os.ReadFile(artifact) - if err != nil { - t.Fatalf("read back: %v", err) - } - golden := backlinkGolden(t, pin, corpus, backlinkChangePath) - if !strings.HasPrefix(string(out), golden) { - t.Fatalf("artifact does not open with the golden block:\n got %q\nwant prefix %q", out, golden) - } - if !strings.Contains(string(out), "# Plan\n\nAuthored body.\n") { - t.Fatalf("authored body not preserved: %q", out) - } -} - -// TestArtifactBacklinkIdempotent: a second run yields a byte-identical file. -func TestArtifactBacklinkIdempotent(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - root := testsupport.TempDir(t) - artifact := filepath.Join(root, "plan.md") - if err := os.WriteFile(artifact, []byte("# Plan\n\nBody.\n"), 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - req := ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: backlinkChangePath} - deps := backlinkDeps(&fakeReader{pin: pin, corpus: corpus}) - - if got := ArtifactBacklink(context.Background(), deps, root, req); got.Result != ResultApplied { - t.Fatalf("first run result=%q reason=%q", got.Result, got.Reason) - } - first, err := os.ReadFile(artifact) - if err != nil { - t.Fatalf("read first: %v", err) - } - - got := ArtifactBacklink(context.Background(), deps, root, req) - if got.Result != ResultNoOp { - t.Fatalf("second run result=%q, want no-op (idempotent)", got.Result) - } - second, err := os.ReadFile(artifact) - if err != nil { - t.Fatalf("read second: %v", err) - } - if string(first) != string(second) { - t.Fatalf("second run not byte-identical:\nfirst %q\nsecond %q", first, second) - } -} - -// TestArtifactBacklinkRefusesMalformedMarkers: a dangling backlink start marker -// is a typed refusal and the file bytes are left untouched. -func TestArtifactBacklinkRefusesMalformedMarkers(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - root := testsupport.TempDir(t) - artifact := filepath.Join(root, "plan.md") - // A start marker with no partner end marker: document.Parse must reject the - // whole population, and the operation must refuse without a write. - original := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n# Plan\n\nBody.\n" - if err := os.WriteFile(artifact, []byte(original), 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - - fake := &fakeReader{pin: pin, corpus: corpus} - got := ArtifactBacklink(context.Background(), backlinkDeps(fake), root, - ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: backlinkChangePath}) - if got.Result == ResultApplied || got.Result == ResultNoOp { - t.Fatalf("malformed markers accepted: result=%q", got.Result) - } - if got.Reason != ReasonBacklinkMalformedMarkers { - t.Fatalf("reason=%q, want %q", got.Reason, ReasonBacklinkMalformedMarkers) - } - // The refusal must precede any authoritative read: a malformed artifact is - // rejected before the corpus is pinned. This isolates the up-front marker - // guard from document.Apply's phase-3 reparse backstop — removing the guard - // lets the operation read the corpus before failing, reddening this assert. - if fake.pinCount != 0 { - t.Fatalf("malformed artifact pinned context %d times; want the refusal before any read", fake.pinCount) - } - out, err := os.ReadFile(artifact) - if err != nil { - t.Fatalf("read back: %v", err) - } - if string(out) != original { - t.Fatalf("file mutated on refusal:\n got %q\nwant %q", out, original) - } -} - -// TestArtifactBacklinkPathContainment: absolute paths, ../ escapes, and a symlink -// leaf pointing outside the repo are each rejected with a stable reason, and no -// out-of-root file is written. -func TestArtifactBacklinkPathContainment(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - - t.Run("absolute", func(t *testing.T) { - root := testsupport.TempDir(t) - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "/etc/hosts", ChangePath: backlinkChangePath}) - if got.Reason != ReasonBacklinkAbsolutePath { - t.Fatalf("reason=%q, want %q (result=%q)", got.Reason, ReasonBacklinkAbsolutePath, got.Result) - } - }) - - t.Run("dotdot-escape", func(t *testing.T) { - root := testsupport.TempDir(t) - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "../escape.md", ChangePath: backlinkChangePath}) - if got.Reason != ReasonBacklinkPathEscape { - t.Fatalf("reason=%q, want %q (result=%q)", got.Reason, ReasonBacklinkPathEscape, got.Result) - } - }) - - t.Run("symlink-escape", func(t *testing.T) { - root := testsupport.TempDir(t) - outside := filepath.Join(testsupport.TempDir(t), "target.md") - if err := os.WriteFile(outside, []byte("SECRET — outside the repo\n"), 0o644); err != nil { - t.Fatalf("seed outside target: %v", err) - } - link := filepath.Join(root, "link.md") - if err := os.Symlink(outside, link); err != nil { - t.Fatalf("symlink: %v", err) - } - - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "link.md", ChangePath: backlinkChangePath}) - if got.Reason != ReasonBacklinkSymlinkEscape { - t.Fatalf("reason=%q, want %q (result=%q)", got.Reason, ReasonBacklinkSymlinkEscape, got.Result) - } - // The out-of-root target must be untouched — the write must not follow - // the symlink. - body, err := os.ReadFile(outside) - if err != nil { - t.Fatalf("read outside target: %v", err) - } - if string(body) != "SECRET — outside the repo\n" { - t.Fatalf("write followed the escaping symlink: %q", body) - } - }) -} - -// TestArtifactBacklinkUnknownChange: a --change path naming no record in the -// corpus is a typed refusal, never a fabricated backlink. -func TestArtifactBacklinkUnknownChange(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - root := testsupport.TempDir(t) - artifact := filepath.Join(root, "plan.md") - if err := os.WriteFile(artifact, []byte("# Plan\n"), 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: "docs/changes/active/0999-absent.md"}) - if got.Reason != ReasonBacklinkUnknownChange { - t.Fatalf("reason=%q, want %q (result=%q)", got.Reason, ReasonBacklinkUnknownChange, got.Result) - } - out, _ := os.ReadFile(artifact) - if string(out) != "# Plan\n" { - t.Fatalf("file mutated for an unknown change: %q", out) - } -} - -// TestArtifactBacklinkChangePathValidation: --change is validated as a -// canonical repository-relative path before the corpus read — the same rule -// --artifact and the attach operations enforce. A malformed spelling is a -// typed refusal naming the flag and the expected form, never unknown-change, -// and the artifact is left byte-identical. -func TestArtifactBacklinkChangePathValidation(t *testing.T) { - pin := docketPin(t) - corpus := backlinkCorpus() - - cases := []struct { - name string - changePath string - reason string - }{ - // The 0458 shape: an absolute spelling of a path whose repo-relative - // tail names a real record must refuse, never resolve. - {"absolute", "/work/repo/" + backlinkChangePath, ReasonBacklinkAbsolutePath}, - {"dotdot-escape", "../" + backlinkChangePath, ReasonBacklinkPathEscape}, - {"non-canonical-dot", "./" + backlinkChangePath, ReasonBacklinkPathEscape}, - {"interior-dotdot", "docs/changes/active/../active/0315-claim.md", ReasonBacklinkPathEscape}, - {"trailing-slash", backlinkChangePath + "/", ReasonBacklinkPathEscape}, - {"empty", "", ReasonBacklinkPathEscape}, - {"whitespace-only", " ", ReasonBacklinkPathEscape}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - root := testsupport.TempDir(t) - artifact := filepath.Join(root, "plan.md") - original := []byte("# Plan\n\nAuthored body.\n") - if err := os.WriteFile(artifact, original, 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - - got := ArtifactBacklink(context.Background(), backlinkDeps(&fakeReader{pin: pin, corpus: corpus}), root, - ArtifactBacklinkRequest{ArtifactPath: "plan.md", ChangePath: tc.changePath}) - - if got.Result != ResultInvalidInput { - t.Fatalf("result=%q, want %q (reason=%q message=%q)", got.Result, ResultInvalidInput, got.Reason, got.Message) - } - if got.Reason != tc.reason { - t.Fatalf("reason=%q, want %q (message=%q)", got.Reason, tc.reason, got.Message) - } - // The message must name the flag and the expected form — the 0458 - // failure was precisely a message that named neither. - if !strings.Contains(got.Message, "--change") { - t.Fatalf("message does not name the --change flag: %q", got.Message) - } - if !strings.Contains(got.Message, "repository-relative") { - t.Fatalf("message does not name the expected form: %q", got.Message) - } - // Refusal predates any write. - out, err := os.ReadFile(artifact) - if err != nil { - t.Fatalf("read back: %v", err) - } - if string(out) != string(original) { - t.Fatalf("file mutated on refusal:\n got %q\nwant %q", out, original) - } - }) - } -} diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index f9d64f23b..af719d42c 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -58,6 +58,16 @@ const ( attachKindResults = "results" ) +// backlinkBlockName and backlinkBlockAnnotation are the docket:backlink managed +// block's marker identity, shared by every writer of an artifact's or PR body's +// backlink (attach, groom, kill, close-out, PR publish, the spec copy). They +// mirror render.BacklinkContent's marker spelling exactly, so an inserted block +// round-trips through render on the next write and the write is idempotent. +const ( + backlinkBlockName = "backlink" + backlinkBlockAnnotation = "generated — do not hand-edit" +) + // plansPlanningRoot is the allowed repository-relative directory a plan artifact // lives under. It is the superpowers plan-writer convention (agents/ // docket-plan-writer.md), not a configured field; the results root IS configured diff --git a/internal/app/change_attach_git_helpers_test.go b/internal/app/change_attach_git_helpers_test.go index f15d5f068..a859e5110 100644 --- a/internal/app/change_attach_git_helpers_test.go +++ b/internal/app/change_attach_git_helpers_test.go @@ -4,7 +4,32 @@ package app // tests (change_implemented_test.go, claim_workflow_git_test.go) still use, kept // untagged so every build compiles them. -import "fmt" +import ( + "fmt" + "testing" + + "github.com/danielhanold/docket/internal/domain" + "github.com/danielhanold/docket/internal/repository" +) + +// changeByPath rebuilds the snapshot from corpus and returns the change at path, +// so a test's golden ties to render.BacklinkContent rather than a hand-copied +// string. +func changeByPath(t *testing.T, pin StatusPin, corpus []StatusBlob, path string) domain.Change { + t.Helper() + inputs, _ := parseCorpus(corpus) + build, err := repository.BuildSnapshot(repository.BuildInput{Config: pin.Config.Effective, Documents: inputs}) + if err != nil { + t.Fatalf("build snapshot: %v", err) + } + for _, c := range build.Snapshot.Changes() { + if c.Path() == path { + return c + } + } + t.Fatalf("no change at %q in corpus", path) + return domain.Change{} +} // attachBacklinkBlock renders the docket:backlink block the operation expects at // the head of an artifact, targeting change id/title at recPath. It mirrors diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index cf2d4021e..111427c8f 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -175,9 +175,9 @@ const ( // ctorLit, plus the composite-literal and FindingCode("…") backstops) reddens // on any unregistered mint. // -// KNOWN GAPS (still deferred): the app-local ReasonBacklink*/ReasonCloseout* -// reason families surface through fail.Reason rather than a literal Code:/ -// constructor mint the shape guard reaches, so they are not enumerated here. +// KNOWN GAPS (still deferred): the app-local ReasonCloseout* reason family +// surfaces through fail.Reason rather than a literal Code:/ +// constructor mint the shape guard reaches, so it is not enumerated here. // AllFindingCodes is authoritative for the const-backed vocabulary the AST // completeness guard covers. var AllFindingCodes = []FindingCode{ diff --git a/internal/app/schema_registry.go b/internal/app/schema_registry.go index 3ae742633..21755cc65 100644 --- a/internal/app/schema_registry.go +++ b/internal/app/schema_registry.go @@ -47,7 +47,6 @@ var operationBindings = []OperationBinding{ {ID: "adr.reverse", Request: ADRReplaceRequest{}, Result: ADRResult{}}, // ADRReverse {ID: "adr.supersede", Request: ADRReplaceRequest{}, Result: ADRResult{}}, // ADRSupersede {ID: "agent.enter", Request: nil, Result: AgentEnterResult{}}, // AgentEnter - {ID: "artifact.backlink", Request: nil, Result: ArtifactBacklinkResult{}}, // ArtifactBacklink {ID: "capabilities", Request: nil, Result: CapabilitiesResult{}}, // Capabilities {ID: "change.attach-plan", Request: nil, Result: ChangeAttachResult{}}, // ChangeAttachPlan {ID: "change.attach-results", Request: nil, Result: ChangeAttachResult{}}, // ChangeAttachResults diff --git a/internal/cli/artifact.go b/internal/cli/artifact.go deleted file mode 100644 index 4e12f9562..000000000 --- a/internal/cli/artifact.go +++ /dev/null @@ -1,62 +0,0 @@ -package cli - -import ( - "errors" - - "github.com/spf13/cobra" - - "github.com/danielhanold/docket/internal/app" -) - -// This file is the `docket artifact` command family: thin adapters that read -// their flags, hand them to the matching internal/app operation, and let the -// presenter own the outcome. No body here branches on repository content — path -// containment, marker validation, and change resolution all belong to -// internal/app. - -// newArtifactCommand builds the `artifact` command group. setResult is the -// closure that hands a computed operation result back to Run's single -// presentation point, mirroring newContextCommand. -func newArtifactCommand(setResult func(app.OperationResult)) *cobra.Command { - artifactCmd := &cobra.Command{ - Use: "artifact", - Short: "Render docket-managed blocks into workflow artifacts", - // A command group resolves its subcommand before Args runs, so anything - // reaching here named no subcommand; NoArgs names an offending token and - // the bare `docket artifact` falls through to RunE's missing-command error. - Args: cobra.NoArgs, - RunE: func(_ *cobra.Command, _ []string) error { - return errors.New("missing command") - }, - } - - backlink := &cobra.Command{ - Use: "backlink", - Short: "Stamp the managed backlink block onto an artifact, pointing home to its change", - Args: cobra.NoArgs, - Annotations: capability("artifact.backlink", EffectLocalWrite), - RunE: func(c *cobra.Command, _ []string) error { - repoDir, err := resolveRepoDir(c) - if err != nil { - return err - } - artifact, _ := c.Flags().GetString("artifact") - change, _ := c.Flags().GetString("change") - deps, err := newPlanningDeps() - if err != nil { - return err - } - setResult(app.ArtifactBacklink(c.Context(), deps, repoDir, - app.ArtifactBacklinkRequest{ArtifactPath: artifact, ChangePath: change})) - return nil - }, - } - backlink.Flags().String("repo-dir", "", "worktree `dir` the artifact lives in (default: current directory)") - backlink.Flags().String("artifact", "", "canonical repository-relative `path` of the artifact to stamp (required)") - backlink.Flags().String("change", "", "canonical repository-relative `path` of the change to point home to (required)") - _ = backlink.MarkFlagRequired("artifact") - _ = backlink.MarkFlagRequired("change") - - artifactCmd.AddCommand(backlink) - return artifactCmd -} diff --git a/internal/cli/artifact_test.go b/internal/cli/artifact_test.go deleted file mode 100644 index 6d6d38a2c..000000000 --- a/internal/cli/artifact_test.go +++ /dev/null @@ -1,89 +0,0 @@ -package cli - -import ( - "os" - "path/filepath" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/testsupport" -) - -// TestArtifactCommandsRegistered: `docket artifact backlink` is registered under -// the `artifact` group with its three flags, and the bare group reports a missing -// command. -func TestArtifactCommandsRegistered(t *testing.T) { - root := captureTree(t) - - cmd, _, err := root.Find([]string{"artifact", "backlink"}) - if err != nil || cmd == nil || cmd.Name() != "backlink" { - t.Fatalf("artifact backlink not registered: cmd=%v err=%v", cmd, err) - } - for _, flag := range []string{"artifact", "change", "repo-dir"} { - if cmd.Flags().Lookup(flag) == nil { - t.Errorf("artifact backlink: missing --%s flag", flag) - } - } - - grp, _, err := root.Find([]string{"artifact"}) - if err != nil || grp == nil || grp.Name() != "artifact" { - t.Fatalf("artifact group not registered: grp=%v err=%v", grp, err) - } -} - -// TestArtifactBacklinkEmitsOneDocument: `docket artifact backlink --json` reaches -// app.ArtifactBacklink and emits exactly one protocol-v1 document naming the -// operation. Against an existing artifact in a non-repository directory the pin -// fails, which is still one well-formed document. -func TestArtifactBacklinkEmitsOneDocument(t *testing.T) { - root := testsupport.TempDir(t) - if err := os.WriteFile(filepath.Join(root, "plan.md"), []byte("# Plan\n"), 0o644); err != nil { - t.Fatalf("seed artifact: %v", err) - } - out, errS, code := runCLI(t, "artifact", "backlink", - "--artifact", "plan.md", "--change", "docs/changes/active/0315-claim.md", - "--repo-dir", root, "--json") - if errS != "" { - t.Fatalf("unexpected stderr %q (code=%d)", errS, code) - } - if !strings.Contains(out, `"operation":"artifact.backlink"`) { - t.Fatalf("document did not name the operation: %q", out) - } - if !strings.Contains(out, `"protocol_version":1`) { - t.Fatalf("missing protocol version: %q", out) - } - if strings.Count(out, "\n") != 1 || !strings.HasSuffix(out, "\n") { - t.Fatalf("must be exactly one newline-terminated document, got %q", out) - } -} - -// TestArtifactBacklinkRequiredFlags: --artifact and --change are required, so -// omitting one is an argument error (exit 2). -func TestArtifactBacklinkRequiredFlags(t *testing.T) { - _, errS, code := runCLI(t, "artifact", "backlink", "--change", "docs/changes/active/0315-claim.md") - if code != 2 || !strings.Contains(errS, "artifact") { - t.Fatalf("missing --artifact not rejected as an argument error: err=%q code=%d", errS, code) - } -} - -// TestArtifactBacklinkHumanMode: without --json the presenter writes the result's -// HumanText() to stdout and names the operation. -func TestArtifactBacklinkHumanMode(t *testing.T) { - root := testsupport.TempDir(t) - out, _, _ := runCLI(t, "artifact", "backlink", - "--artifact", "plan.md", "--change", "docs/changes/active/0315-claim.md", "--repo-dir", root) - if !strings.Contains(out, "artifact") { - t.Fatalf("human stdout did not carry the operation text: %q", out) - } -} - -// TestArtifactCommandsAssetIndependent guards the install.go registration: the -// artifact commands read the repository and a working-tree file, never installed -// assets, so they must be asset-independent. -func TestArtifactCommandsAssetIndependent(t *testing.T) { - for _, key := range []string{"artifact", "artifact backlink"} { - if !assetIndependent[key] { - t.Errorf("%q is not registered asset-independent", key) - } - } -} diff --git a/internal/cli/install.go b/internal/cli/install.go index 245f45945..284977ac0 100644 --- a/internal/cli/install.go +++ b/internal/cli/install.go @@ -56,8 +56,6 @@ var assetIndependent = map[string]bool{ "context": true, // the group itself; it reports a missing command "context implementation": true, "context finalize": true, - "artifact": true, // the group itself; it reports a missing command - "artifact backlink": true, "workspace": true, // the group itself; it reports a missing command "workspace prepare": true, "workspace commit-spec": true, diff --git a/internal/cli/root.go b/internal/cli/root.go index 421c2f444..eed1b3bee 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -405,7 +405,6 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, info buildinf // operation, and assigns the outcome to the shared result for the presenter. changeCmd := newChangeCommand(func(r app.OperationResult) { result = r }) contextCmd := newContextCommand(func(r app.OperationResult) { result = r }) - artifactCmd := newArtifactCommand(func(r app.OperationResult) { result = r }) workspaceCmd := newWorkspaceCommand(func(r app.OperationResult) { result = r }) evidenceCmd := newEvidenceCommand(func(r app.OperationResult) { result = r }) prCmd := newPRCommand(func(r app.OperationResult) { result = r }) @@ -421,7 +420,7 @@ func run(args []string, stdin io.Reader, stdout, stderr io.Writer, info buildinf installCmd.AddCommand(installCheckCmd, installCollectCmd) developmentCmd.AddCommand(developmentInstallCmd, developmentTestCmd) diagnosticCmd.AddCommand(runtimeCmd, configCmd) - root.AddCommand(capabilitiesCmd, schemaCmd, versionCmd, statusCmd, changeCmd, contextCmd, artifactCmd, workspaceCmd, evidenceCmd, prCmd, runCmd, learningCmd, adrCmd, gateCmd, finalizeCmd, maintenanceCmd, repositoryCmd, agentCmd, diagnosticCmd, installCmd, uninstallCmd, developmentCmd) + root.AddCommand(capabilitiesCmd, schemaCmd, versionCmd, statusCmd, changeCmd, contextCmd, workspaceCmd, evidenceCmd, prCmd, runCmd, learningCmd, adrCmd, gateCmd, finalizeCmd, maintenanceCmd, repositoryCmd, agentCmd, diagnosticCmd, installCmd, uninstallCmd, developmentCmd) root.AddCommand(extra...) // The asset-dependence guard. Commands that do not read installed assets From e7ab162f26ab746f8c6c79d5833be24290db8b1d Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:19:38 -0400 Subject: [PATCH 11/28] feat(render): same-branch artifact links and backlinks are relative --- internal/app/adr_ops_test.go | 5 +- internal/app/change_attach.go | 35 +- .../app/change_attach_git_helpers_test.go | 21 +- internal/app/change_attach_test.go | 18 +- internal/app/change_create_test.go | 2 +- internal/app/change_groom.go | 19 +- internal/app/change_groom_test.go | 3 +- internal/app/change_implemented.go | 6 +- internal/app/change_implemented_test.go | 10 +- internal/app/change_kill.go | 49 +-- internal/app/change_kill_test.go | 49 ++- internal/app/finalize_closeout.go | 35 +- .../app/finalize_closeout_integration_test.go | 10 +- internal/app/pr_publish.go | 69 +++- internal/app/pr_publish_body_test.go | 51 +++ .../repocheck_corpus_link_integration_test.go | 63 +++- .../app/repository_repair_integration_test.go | 134 +++++++ internal/app/workflow_integration_test.go | 17 +- internal/render/artifacts.go | 150 +++++--- internal/render/artifacts_test.go | 347 +++++++++++------- internal/render/link.go | 11 +- internal/render/relpath.go | 38 ++ internal/render/relpath_test.go | 26 ++ .../render/testdata/artifacts/PROVENANCE.md | 38 +- .../artifacts/block-spec-adrs.github.golden | 4 +- .../artifacts/block-spec-adrs.relative.golden | 4 +- .../block-spec-plan-results.github.golden | 6 +- .../block-spec-plan-results.relative.golden | 6 +- 28 files changed, 869 insertions(+), 357 deletions(-) create mode 100644 internal/app/pr_publish_body_test.go create mode 100644 internal/render/relpath.go create mode 100644 internal/render/relpath_test.go diff --git a/internal/app/adr_ops_test.go b/internal/app/adr_ops_test.go index 48eb1debb..6491ab7fd 100644 --- a/internal/app/adr_ops_test.go +++ b/internal/app/adr_ops_test.go @@ -236,7 +236,8 @@ func TestADRRecordPlanWithProducingChange(t *testing.T) { if !strings.Contains(change, "updated: '2026-08-16'") { t.Errorf("producing change updated date not bumped:\n%s", change) } - if !strings.Contains(change, "| ADRs |") || !strings.Contains(change, adrPath("0002", "record-the-widget-decision")) { + // The ADR row links the ADR relatively from the record (docs/changes/active). + if row := "| ADRs | [ADR-0002](../../adrs/0002-record-the-widget-decision.md) |"; !strings.Contains(change, row) { t.Errorf("producing change artifact block missing the new ADR row:\n%s", change) } @@ -546,7 +547,7 @@ func TestADRSupersedePlanWithProducingChange(t *testing.T) { if !strings.Contains(change, "adrs: [2]") { t.Errorf("producing change adrs not updated to [2]:\n%s", change) } - if !strings.Contains(change, "| ADRs |") || !strings.Contains(change, adrPath("0002", "supersede-the-widget-decision")) { + if row := "| ADRs | [ADR-0002](../../adrs/0002-supersede-the-widget-decision.md) |"; !strings.Contains(change, row) { t.Errorf("producing change artifact block missing the new ADR row:\n%s", change) } } diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index af719d42c..4bfd7416a 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -61,7 +61,8 @@ const ( // backlinkBlockName and backlinkBlockAnnotation are the docket:backlink managed // block's marker identity, shared by every writer of an artifact's or PR body's // backlink (attach, groom, kill, close-out, PR publish, the spec copy). They -// mirror render.BacklinkContent's marker spelling exactly, so an inserted block +// mirror the marker spelling render.BacklinkContent and +// render.ArtifactBacklinkContent emit exactly, so an inserted block // round-trips through render on the next write and the write is idempotent. const ( backlinkBlockName = "backlink" @@ -259,10 +260,7 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string // (6) Assemble the stored artifact: the submitted body with any backlink // block it carries replaced by the freshly rendered one for this change. - backlink, err := render.BacklinkContent(ac.change, ac.link) - if err != nil { - return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, err.Error()) - } + backlink := render.ArtifactBacklinkContent(ac.change, req.Path) artifact, err := metadataArtifactBytes(req.Markdown, backlink) if err != nil { // Unreachable after checkAttachMarkdown parsed the same body; kept so a @@ -398,17 +396,15 @@ func attachOpKey(kind string) string { return OperationChangeAttachPlan } -// attachChange bundles the resolved change with its current record path and the -// link context its backlink renders under. +// attachChange bundles the resolved change with its current record path. type attachChange struct { change domain.Change recPath string - link render.LinkContext } // resolveAttachChange pins-adjacent: it reads the corpus once, builds the // snapshot, and returns the change named by id (a typed unknown/ambiguous refusal -// otherwise) with its current path and backlink link context. +// otherwise) with its current path. func resolveAttachChange(ctx context.Context, deps PlanningDeps, pin StatusPin, eff config.Effective, id int, opKey, kind string) (attachChange, *ChangeAttachResult) { blobs, err := deps.Reader.ReadCorpus(ctx, pin) if err != nil { @@ -436,7 +432,6 @@ func resolveAttachChange(ctx context.Context, deps PlanningDeps, pin StatusPin, return attachChange{ change: c, recPath: c.Path(), - link: linkContextOf(pin), }, nil } @@ -492,15 +487,16 @@ func withinPlanningRoot(root, p string) bool { return strings.HasPrefix(p, root+"/") } -// backlinkTargets reports whether artifactBytes carries a balanced docket:backlink -// managed block whose interior equals the backlink rendered for ch under link. It +// backlinkTargets reports whether artifactBytes, the bytes of the metadata-branch +// file at artifactPath, carries a balanced docket:backlink managed block whose +// interior equals the backlink rendered for ch from that path. It // is the shared backlink-identity check the attach transaction (does a file // already at the path point home to this change?) and change.mark-implemented // (verifyImplementedResults) apply to an artifact's own bytes. A malformed -// managed-block population or a backlink-render failure is -// returned as the error (the caller classifies it); an artifact with no backlink -// block, or one whose interior names a DIFFERENT change, is (false, nil). -func backlinkTargets(artifactBytes []byte, ch domain.Change, link render.LinkContext) (bool, error) { +// managed-block population is returned as the error (the caller classifies it); +// an artifact with no backlink block, or one whose interior names a DIFFERENT +// change, is (false, nil). +func backlinkTargets(artifactBytes []byte, ch domain.Change, artifactPath string) (bool, error) { doc, err := document.Parse(artifactBytes) if err != nil { return false, err @@ -509,10 +505,7 @@ func backlinkTargets(artifactBytes []byte, ch domain.Change, link render.LinkCon if !ok { return false, nil } - expected, err := render.BacklinkContent(ch, link) - if err != nil { - return false, err - } + expected := render.ArtifactBacklinkContent(ch, artifactPath) got := strings.TrimRight(string(artifactBytes[block.Interior.Start:block.Interior.End]), "\n") return got == backlinkInterior(expected), nil } @@ -620,7 +613,7 @@ func (o changeAttachOp) Plan(ctx context.Context, st transaction.AttemptState) ( return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change attach: %w", err) } if exists && linked != o.artifact { - if home, berr := backlinkTargets(existing, c, o.link); berr != nil || !home { + if home, berr := backlinkTargets(existing, c, o.artifact); berr != nil || !home { return refuseLifecycle(FindingCode(ReasonAttachPathOccupied), fmt.Sprintf("a file already exists at %q on the metadata branch and its backlink does not point to change %04d; refusing to overwrite it", o.artifact, o.changeID)) } diff --git a/internal/app/change_attach_git_helpers_test.go b/internal/app/change_attach_git_helpers_test.go index a859e5110..2b087a43e 100644 --- a/internal/app/change_attach_git_helpers_test.go +++ b/internal/app/change_attach_git_helpers_test.go @@ -6,6 +6,7 @@ package app import ( "fmt" + "path/filepath" "testing" "github.com/danielhanold/docket/internal/domain" @@ -32,12 +33,18 @@ func changeByPath(t *testing.T, pin StatusPin, corpus []StatusBlob, path string) } // attachBacklinkBlock renders the docket:backlink block the operation expects at -// the head of an artifact, targeting change id/title at recPath. It mirrors -// render.BacklinkContent's repo-relative shape exactly (no RepoWebURL is -// configured in these fixtures), so a happy plan round-trips through verification. -func attachBacklinkBlock(id int, title, recPath string) string { +// the head of the metadata-branch artifact at artifactPath, targeting change +// id/title at recPath: the link is recPath relative to the artifact's own +// directory (render.ArtifactBacklinkContent's shape). The relative path comes +// from the standard library's filepath.Rel, an oracle independent of the +// renderer, so a happy plan round-trips through verification. +func attachBacklinkBlock(id int, title, recPath, artifactPath string) string { + rel, err := filepath.Rel(filepath.Dir(artifactPath), recPath) + if err != nil { + panic(fmt.Sprintf("attachBacklinkBlock: %v", err)) + } return "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + - fmt.Sprintf("> ↩ **Change %04d — %s** — `%s`\n", id, title, recPath) + + fmt.Sprintf("> ↩ **[Change %04d — %s](%s)**\n", id, title, filepath.ToSlash(rel)) + "<!-- docket:backlink:end -->\n" } @@ -46,8 +53,8 @@ func attachBacklinkBlock(id int, title, recPath string) string { // acceptance — a plan that instructs about a token, and the human-approved // ambiguous decision sentence, both attach; only a whole-slot bare-token filler // refuses). Every slot here holds substantive content. -func attachHappyPlan(id int, title, recPath string) string { - return attachBacklinkBlock(id, title, recPath) + "\n" + attachHappyPlanBody() +func attachHappyPlan(id int, title, recPath, planPath string) string { + return attachBacklinkBlock(id, title, recPath, planPath) + "\n" + attachHappyPlanBody() } // attachHappyPlanBody is attachHappyPlan's authored body without the backlink diff --git a/internal/app/change_attach_test.go b/internal/app/change_attach_test.go index 7ff989fab..ec69f00ee 100644 --- a/internal/app/change_attach_test.go +++ b/internal/app/change_attach_test.go @@ -57,7 +57,7 @@ const attachTestPlanMarkdown = "# Plan\n\n## Task 1\n\nDo it.\n" func planAttachOp(t *testing.T, surfaces []string, markdown string) changeAttachOp { t.Helper() op := baseAttachOp(surfaces, 3, attachKindPlan, attachTestPlanPath) - artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))) + artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"), attachTestPlanPath)) if err != nil { t.Fatalf("metadataArtifactBytes: %v", err) } @@ -123,7 +123,7 @@ func TestAttachPlanWritesFileFieldAndBacklink(t *testing.T) { if strings.Contains(rec, "results: '") { t.Errorf("attach-plan wrote a results field:\n%s", rec) } - want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath), attachTestPlanMarkdown) + want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath, attachTestPlanPath), attachTestPlanMarkdown) if got := plannedFile(t, plan, attachTestPlanPath).Bytes; string(got) != string(want) { t.Errorf("plan file bytes =\n%q\nwant\n%q", got, want) } @@ -134,8 +134,8 @@ func TestAttachPlanWritesFileFieldAndBacklink(t *testing.T) { // exactly one backlink block, the freshly rendered one — and that re-assembling // the stored bytes is a fixed point, so a read-back resubmission is no change. func TestAttachPlanStripsSubmittedBacklink(t *testing.T) { - fresh := attachBacklinkBlock(3, "A change", groomPath(3, "widget")) - stale := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + fresh := attachBacklinkBlock(3, "A change", groomPath(3, "widget"), attachTestPlanPath) + stale := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md", attachTestPlanPath) got, err := metadataArtifactBytes([]byte(stale+"\n"+attachTestPlanMarkdown), fresh) if err != nil { t.Fatalf("metadataArtifactBytes: %v", err) @@ -163,7 +163,7 @@ func TestAttachPlanStripsSubmittedBacklink(t *testing.T) { // or engine work — nothing is declared. func TestAttachPlanRefusesMalformedBacklink(t *testing.T) { bad := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n> ↩ dangling\n\n# Plan\n\nSteps.\n" - if _, err := metadataArtifactBytes([]byte(bad), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))); err == nil { + if _, err := metadataArtifactBytes([]byte(bad), attachBacklinkBlock(3, "A change", groomPath(3, "widget"), attachTestPlanPath)); err == nil { t.Errorf("metadataArtifactBytes accepted malformed backlink markers") } res, engine, reader := runAttachPlanUnit(t, ChangeAttachRequest{ID: 3, Revision: blobV, Path: attachTestPlanPath, Markdown: []byte(bad)}) @@ -222,7 +222,7 @@ func TestAttachPlanRefusesOccupiedPath(t *testing.T) { t.Run("another change's file refuses", func(t *testing.T) { files := map[string]string{ recPath: lifecycleChange(3, "widget", "in-progress"), - attachTestPlanPath: attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + "\n# Other plan\n", + attachTestPlanPath: attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md", attachTestPlanPath) + "\n# Other plan\n", } _, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) if code := refusalCode(t, opRes); code != ReasonAttachPathOccupied { @@ -242,7 +242,7 @@ func TestAttachPlanRefusesOccupiedPath(t *testing.T) { t.Run("this change's own file is replaced", func(t *testing.T) { files := map[string]string{ recPath: lifecycleChange(3, "widget", "in-progress"), - attachTestPlanPath: attachBacklinkBlock(3, "A change", recPath) + "\n# Older plan\n", + attachTestPlanPath: attachBacklinkBlock(3, "A change", recPath, attachTestPlanPath) + "\n# Older plan\n", } plan, opRes := attachPlanFor(t, files, planAttachOp(t, nil, attachTestPlanMarkdown)) if opRes.Refused { @@ -393,7 +393,7 @@ const attachTestResultsMarkdown = "# Widget — Results\n\n## Outcome\n\nDeliver func resultsAttachOp(t *testing.T, surfaces []string, markdown string) changeAttachOp { t.Helper() op := baseAttachOp(surfaces, 3, attachKindResults, attachTestResultsPath) - artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"))) + artifact, err := metadataArtifactBytes([]byte(markdown), attachBacklinkBlock(3, "A change", groomPath(3, "widget"), attachTestResultsPath)) if err != nil { t.Fatalf("metadataArtifactBytes: %v", err) } @@ -423,7 +423,7 @@ func TestAttachResultsWritesFileAndField(t *testing.T) { if strings.Contains(rec, "plan: '") { t.Errorf("attach-results wrote a plan field:\n%s", rec) } - want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath), attachTestResultsMarkdown) + want := assembleSpecFile(attachBacklinkBlock(3, "A change", recPath, attachTestResultsPath), attachTestResultsMarkdown) if got := plannedFile(t, plan, attachTestResultsPath).Bytes; string(got) != string(want) { t.Errorf("results file bytes =\n%q\nwant\n%q", got, want) } diff --git a/internal/app/change_create_test.go b/internal/app/change_create_test.go index bbd4a9f99..ff7977a89 100644 --- a/internal/app/change_create_test.go +++ b/internal/app/change_create_test.go @@ -330,7 +330,7 @@ func TestChangeCreatePlanFillsArtifactBlockForADRs(t *testing.T) { if !strings.Contains(body, "| ADRs |") { t.Errorf("artifact block missing ADRs row:\n%s", body) } - if !strings.Contains(body, "docs/adrs/0001-a-decision.md") { + if !strings.Contains(body, "| ADRs | [ADR-0001](../../adrs/0001-a-decision.md) |") { t.Errorf("ADR reference not resolved into the artifact block:\n%s", body) } } diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index 8fe221dc8..fd5e1855a 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -765,10 +765,7 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t } if o.req.Outcome == GroomSpec { - backlink, err := render.BacklinkContent(gc, o.link) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change groom: rendering spec backlink: %w", err) - } + backlink := render.ArtifactBacklinkContent(gc, specPath) specBytes := assembleSpecFile(backlink, o.req.SpecMarkdown) files = append(files, transaction.FileMutation{ Path: gitcli.RepoPath(specPath), Kind: transaction.MutationCreate, Bytes: specBytes, @@ -777,10 +774,7 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t if reviseSpec { // Whole-body replace at the change's existing linked spec path; the // backlink block is re-rendered exactly as the spec outcome writes it. - backlink, err := render.BacklinkContent(gc, o.link) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change groom: rendering spec backlink: %w", err) - } + backlink := render.ArtifactBacklinkContent(gc, c.Spec().Value) // An identical spec body is not an actual change; skip the declaration. if specBytes := assembleSpecFile(backlink, o.req.SpecMarkdown); !bytes.Equal(specBytes, existingSpec) { files = append(files, transaction.FileMutation{ @@ -794,7 +788,7 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t // actually changed, the change links a spec, and nothing else writes that // spec in this plan — declaring the spec path at most once. if gc.Title() != c.Title() && c.Spec().Value != "" && o.req.Outcome != GroomSpec && !reviseSpec { - updated, changed, code, msg, err := restampSpecBacklink(ctx, st.Tree, c.Spec().Value, gc, o.link) + updated, changed, code, msg, err := restampSpecBacklink(ctx, st.Tree, c.Spec().Value, gc) if err != nil { return transaction.MutationPlan{}, transaction.OperationResult{}, err } @@ -897,7 +891,7 @@ func assembleSpecFile(backlink, markdown string) []byte { // spec-backlink-malformed; a spec without the block refuses // spec-backlink-missing — a block is never silently inserted. changed reports // whether the bytes differ, so an unchanged spec is never declared. -func restampSpecBacklink(ctx context.Context, tree transaction.Tree, specPath string, gc domain.Change, link render.LinkContext) (updated []byte, changed bool, refuseCode, refuseMsg string, err error) { +func restampSpecBacklink(ctx context.Context, tree transaction.Tree, specPath string, gc domain.Change) (updated []byte, changed bool, refuseCode, refuseMsg string, err error) { blob, _, exists, err := treeBlob(ctx, tree, specPath) if err != nil { return nil, false, "", "", err @@ -915,10 +909,7 @@ func restampSpecBacklink(ctx context.Context, tree transaction.Tree, specPath st return nil, false, "spec-backlink-missing", fmt.Sprintf("spec %q has no docket:backlink block to re-stamp with the new title; revise the spec body (send spec_markdown with the current body plus spec_revision and title together) to rebuild the backlink block in one transaction", specPath), nil } - block, err := render.BacklinkContent(gc, link) - if err != nil { - return nil, false, "", "", fmt.Errorf("change groom: rendering spec backlink: %w", err) - } + block := render.ArtifactBacklinkContent(gc, specPath) var ps document.PatchSet ps.ReplaceBlock(backlinkBlockName, backlinkInterior(block)) out, aerr := doc.Apply(ps) diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index 8ed3ff5d8..f5a7e5492 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -268,7 +268,8 @@ func TestChangeGroomPlanSpecOutcomeFileSet(t *testing.T) { if !strings.Contains(spec, "The design body.") { t.Errorf("spec file missing the submitted markdown:\n%s", spec) } - if !strings.Contains(spec, groomPath(2, "add-a-widget")) { + // The spec backlink links the record relatively from the spec's directory. + if !strings.Contains(spec, "](../../changes/active/0002-add-a-widget.md)**") { t.Errorf("spec backlink does not target the change record path:\n%s", spec) } } diff --git a/internal/app/change_implemented.go b/internal/app/change_implemented.go index 13b87a46d..7034df3e3 100644 --- a/internal/app/change_implemented.go +++ b/internal/app/change_implemented.go @@ -308,7 +308,7 @@ func ChangeMarkImplemented(ctx context.Context, deps PlanningDeps, wdeps Workspa return implementedRefusal(ResultInvalidState, ReasonImplementedResultsMissing, fmt.Sprintf("change %04d has no attached results artifact; author and attach the final results before marking implemented", req.ID), req.ID) } - if r := verifyImplementedResults(ctx, deps, pin, resultsPath, c, linkContextOf(pin), req.ID); r != nil { + if r := verifyImplementedResults(ctx, deps, pin, resultsPath, c, req.ID); r != nil { return *r } @@ -412,7 +412,7 @@ func resolveImplementedChange(ctx context.Context, deps PlanningDeps, pin Status // the first finding). The presence reprobe catches a record whose results file // was deleted or never written; the backlink and content reprobes catch results // edited away from the checkpoint attach-results validated. -func verifyImplementedResults(ctx context.Context, deps PlanningDeps, pin StatusPin, resultsPath string, ch domain.Change, link render.LinkContext, id int) *ChangeLifecycleResult { +func verifyImplementedResults(ctx context.Context, deps PlanningDeps, pin StatusPin, resultsPath string, ch domain.Change, id int) *ChangeLifecycleResult { art, err := deps.Reader.ReadArtifact(ctx, pin, sourceMetadata, resultsPath) if err != nil { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, err.Error(), id) @@ -427,7 +427,7 @@ func verifyImplementedResults(ctx context.Context, deps PlanningDeps, pin Status artifactBytes := art.Data // The artifact must still carry this change's backlink. - targets, err := backlinkTargets(artifactBytes, ch, link) + targets, err := backlinkTargets(artifactBytes, ch, resultsPath) if err != nil { r := implementedRefusal(ResultInvalidState, ReasonImplementedResultsIdentity, fmt.Sprintf("the attached results artifact %q has a malformed or unreadable backlink on the metadata branch: %v", resultsPath, err), id) diff --git a/internal/app/change_implemented_test.go b/internal/app/change_implemented_test.go index bc415d2ae..bc5442649 100644 --- a/internal/app/change_implemented_test.go +++ b/internal/app/change_implemented_test.go @@ -148,12 +148,12 @@ const ( ) // miResultsArtifact is a FINAL-valid results artifact for change 3: the correct -// repo-relative backlink (mainPin carries no RepoWebURL, so the block matches -// render.BacklinkContent's repo-relative form via attachBacklinkBlock) fronting a +// relative backlink from a docs/results file (every mi results path sits there, +// so one rendering serves them all) fronting a // title and a substantive ## Outcome. It passes ValidateResultsContent at both // phases. func miResultsArtifact() string { - return attachBacklinkBlock(3, "A change", groomPath(3, miSlug)) + + return attachBacklinkBlock(3, "A change", groomPath(3, miSlug), miResultsPath) + "\n# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nDelivered the widget; behavior X now refuses Y.\n" } @@ -161,7 +161,7 @@ func miResultsArtifact() string { // whole-section filler body (## Findings and limitations → None.): it passes the // checkpoint phase yet fails the FINAL content contract (results-content-invalid). func miResultsFinalInvalid() string { - return attachBacklinkBlock(3, "A change", groomPath(3, miSlug)) + + return attachBacklinkBlock(3, "A change", groomPath(3, miSlug), miResultsInvalidPath) + "\n# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nReal outcome prose.\n\n## Findings and limitations\n\nNone.\n" } @@ -169,7 +169,7 @@ func miResultsFinalInvalid() string { // change id, so its results identity is broken (results-identity-broken) // even though its prose would satisfy the final content contract. func miResultsBacklinkMismatch() string { - return attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + + return attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md", miResultsMismatchPath) + "\n# Widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nReal outcome prose.\n" } diff --git a/internal/app/change_kill.go b/internal/app/change_kill.go index a05b9c107..4922239d0 100644 --- a/internal/app/change_kill.go +++ b/internal/app/change_kill.go @@ -320,41 +320,15 @@ func (o changeKillOp) Plan(ctx context.Context, st transaction.AttemptState) (tr {Path: gitcli.RepoPath(o.path), Kind: transaction.MutationDelete}, } - // Retarget a metadata-resident linked spec's backlink to the archive path. A - // spec outside the metadata tree or absent from it yields no spec mutation and - // no failure. - if specPath := gc.Spec().Value; specPath != "" { - specBytes, present, err := readTreeBlob(ctx, st.Tree, specPath) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, err - } - if present { - specDoc, err := document.Parse(specBytes) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change kill: parsing linked spec %q: %w", specPath, err) - } - // A spec present but carrying no docket:backlink managed block (a - // hand-authored or Bash-era spec) has no block to retarget: - // ReplaceBlock would fail KindMissingPatchTarget, surfacing as a - // misleading internal-error. Skip the spec mutation instead, matching - // the absent-spec contract — no spec mutation, no failure. The kill - // still archives the change and updates the board. - if _, ok := specDoc.Block("backlink"); ok { - backlink, err := render.BacklinkContent(gc, o.link) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change kill: rendering spec backlink: %w", err) - } - var sps document.PatchSet - sps.ReplaceBlock("backlink", backlinkInterior(backlink)) - specFinal, err := specDoc.Apply(sps) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("change kill: retargeting spec backlink in %q: %w", specPath, err) - } - files = append(files, transaction.FileMutation{ - Path: gitcli.RepoPath(specPath), Kind: transaction.MutationReplace, Bytes: specFinal, - }) - } - } + // Retarget the backlink of every metadata-resident linked artifact — spec, + // plan, and results — to the archive path, through the same per-artifact + // loop close-out uses. An artifact absent from the metadata tree, or present + // but carrying no docket:backlink block (a hand-authored or Bash-era file), + // yields no mutation and no failure; the kill still archives the change and + // updates the board. + files, err = retargetArtifactBacklinks(ctx, st.Tree, gc, files) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err } if o.inline { @@ -436,8 +410,9 @@ func readTreeBlob(ctx context.Context, tree transaction.Tree, p string) ([]byte, // backlinkInterior extracts the interior of a rendered docket:backlink block — // the "> ↩ ..." line(s) between the two marker lines — so it can feed -// PatchSet.ReplaceBlock, which owns the markers. render.BacklinkContent emits the -// complete block; ReplaceBlock rewrites only the interior. +// PatchSet.ReplaceBlock, which owns the markers. render.BacklinkContent and +// render.ArtifactBacklinkContent emit the complete block; ReplaceBlock rewrites +// only the interior. func backlinkInterior(block string) string { lines := strings.Split(strings.TrimRight(block, "\n"), "\n") if len(lines) < 2 { diff --git a/internal/app/change_kill_test.go b/internal/app/change_kill_test.go index ec5d3a5c3..ddee17abc 100644 --- a/internal/app/change_kill_test.go +++ b/internal/app/change_kill_test.go @@ -201,10 +201,10 @@ func TestChangeKillPlanRetargetsSpecBacklink(t *testing.T) { }) spec := killRecordBytes(t, plan, specPath) - if !strings.Contains(spec, archivePath) { - t.Errorf("spec backlink not retargeted to the archive path:\n%s", spec) + if want := "> ↩ **[Change 0003 — A change](../../changes/archive/2026-08-16-0003-widget.md)**"; !strings.Contains(spec, want) { + t.Errorf("spec backlink not retargeted to the archive path (want %q):\n%s", want, spec) } - if strings.Contains(spec, recPath) { + if strings.Contains(spec, "0003-widget.md`") || strings.Contains(spec, "active/") { t.Errorf("spec backlink still points at the active path:\n%s", spec) } // The authored spec body survives byte-identically. @@ -213,6 +213,49 @@ func TestChangeKillPlanRetargetsSpecBacklink(t *testing.T) { } } +// TestChangeKillPlanRetargetsPlanAndResultsBacklinks: a killed change's plan and +// results live on the metadata branch beside its spec, so the kill retargets +// their backlinks to the archive path in the same plan, each relative to its own +// file's directory; an artifact whose block is already current is not declared. +func TestChangeKillPlanRetargetsPlanAndResultsBacklinks(t *testing.T) { + recPath := groomPath(3, "widget") + planPath := "docs/superpowers/plans/2026-08-01-widget.md" + resultsPath := "docs/results/2026-08-01-widget-results.md" + src := lifecycleChange(3, "widget", "in-progress") + src = strings.Replace(src, "plan:\n", "plan: '"+planPath+"'\n", 1) + src = strings.Replace(src, "results:\n", "results: '"+resultsPath+"'\n", 1) + + activeBlock := func(rel string) string { + return "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + + "> ↩ **[Change 0003 — A change](" + rel + ")**\n" + + "<!-- docket:backlink:end -->\n" + } + files := map[string]string{ + recPath: src, + planPath: activeBlock("../../changes/active/0003-widget.md") + "\n# Plan\n\nSteps.\n", + resultsPath: activeBlock("../changes/active/0003-widget.md") + "\n# Results\n\nOutcome.\n", + } + plan, opRes := killPlanFor(t, files, baseKillOp([]string{}, 3, recPath, "Superseded.\n")) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + archivePath := killArchivePath(3, "widget") + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + archivePath: transaction.MutationCreate, + recPath: transaction.MutationDelete, + planPath: transaction.MutationReplace, + resultsPath: transaction.MutationReplace, + }) + for p, want := range map[string]string{ + planPath: activeBlock("../../changes/archive/2026-08-16-0003-widget.md") + "\n# Plan\n\nSteps.\n", + resultsPath: activeBlock("../changes/archive/2026-08-16-0003-widget.md") + "\n# Results\n\nOutcome.\n", + } { + if got := killRecordBytes(t, plan, p); got != want { + t.Errorf("%s after kill =\n%q\nwant\n%q", p, got, want) + } + } +} + func TestChangeKillPlanNoSpecMutationWhenSpecAbsent(t *testing.T) { recPath := groomPath(3, "widget") specPath := "docs/superpowers/specs/2026-08-01-widget-design.md" diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index cc12c7eb3..92382d91a 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -990,8 +990,10 @@ func closeoutBacklinkTargets(cc *closeoutContext, targets []closeoutTarget) ([]c // archivedBacklinkInterior renders the docket:backlink interior a merged // artifact must carry after closeout: it points at the change's ARCHIVE path. It // builds a one-record snapshot at the archive path from the record bytes already -// in hand so render.BacklinkContent renders the canonical line — the exact form -// the metadata-ref spec retarget uses, so both legs stay consistent. +// in hand so render.BacklinkContent renders the canonical line. The line is the +// absolute metadata-branch form: a legacy merged artifact on the integration +// branch and a PR description have no metadata record beside them to link +// relatively (the metadata-ref retarget uses render.ArtifactBacklinkContent). func archivedBacklinkInterior(eff config.Effective, archivePath string, srcBytes []byte, link render.LinkContext) (string, error) { doc, err := document.Parse(srcBytes) if err != nil { @@ -1146,7 +1148,7 @@ func (o closeoutArchiveOp) Plan(ctx context.Context, st transaction.AttemptState transaction.FileMutation{Path: gitcli.RepoPath(p.tg.archivePath), Kind: transaction.MutationCreate, Bytes: finalBytes}, transaction.FileMutation{Path: gitcli.RepoPath(p.tg.activePath), Kind: transaction.MutationDelete}, ) - files, err = retargetArtifactBacklinks(ctx, st.Tree, gc, o.link, files) + files, err = retargetArtifactBacklinks(ctx, st.Tree, gc, files) if err != nil { return transaction.MutationPlan{}, transaction.OperationResult{}, err } @@ -1451,19 +1453,14 @@ func buildInPlaceCandidate(eff config.Effective, docs map[string]document.Docume } // retargetArtifactBacklinks retargets, on the metadata tree, the docket:backlink -// block of each of the archived change's spec/plan/results artifacts that is -// present ON that tree and carries a block. A metadata-resident artifact (the -// spec) is retargeted here; an integration-resident one (the plan/results) is -// absent from st.Tree and -// left to the follow-up leg. It never conjures a block a hand-authored artifact -// lacks, matching the kill path's spec-retarget contract. -func retargetArtifactBacklinks(ctx context.Context, tree transaction.Tree, gc domain.Change, link render.LinkContext, files []transaction.FileMutation) ([]transaction.FileMutation, error) { - backlink, err := render.BacklinkContent(gc, link) - if err != nil { - return nil, fmt.Errorf("closeout: rendering backlink for %04d: %w", int(gc.ID()), err) - } - interior := backlinkInterior(backlink) - +// block of each of the relocated change's spec/plan/results artifacts that is +// present ON that tree and carries a block, so its relative link names gc's +// CURRENT record path. Each block is rendered from the artifact's own path. A +// legacy plan/results file on the integration branch is absent from the tree +// and skipped. It never conjures a block a hand-authored artifact lacks, and an +// artifact whose bytes would not change is not declared. Close-out and kill +// both relocate a record and share this loop. +func retargetArtifactBacklinks(ctx context.Context, tree transaction.Tree, gc domain.Change, files []transaction.FileMutation) ([]transaction.FileMutation, error) { for _, p := range artifactPathsOf(gc) { bytesAt, present, err := readTreeBlob(ctx, tree, p) if err != nil { @@ -1474,16 +1471,16 @@ func retargetArtifactBacklinks(ctx context.Context, tree transaction.Tree, gc do } doc, err := document.Parse(bytesAt) if err != nil { - return nil, fmt.Errorf("closeout: parsing linked artifact %q: %w", p, err) + return nil, fmt.Errorf("backlink retarget: parsing linked artifact %q: %w", p, err) } if _, ok := doc.Block(backlinkBlockName); !ok { continue } var ps document.PatchSet - ps.ReplaceBlock(backlinkBlockName, interior) + ps.ReplaceBlock(backlinkBlockName, backlinkInterior(render.ArtifactBacklinkContent(gc, p))) updated, err := doc.Apply(ps) if err != nil { - return nil, fmt.Errorf("closeout: retargeting backlink in %q: %w", p, err) + return nil, fmt.Errorf("backlink retarget: rewriting the backlink in %q: %w", p, err) } if string(updated) == string(bytesAt) { continue diff --git a/internal/app/finalize_closeout_integration_test.go b/internal/app/finalize_closeout_integration_test.go index e730b93e9..020feca9b 100644 --- a/internal/app/finalize_closeout_integration_test.go +++ b/internal/app/finalize_closeout_integration_test.go @@ -5,6 +5,7 @@ package app import ( "context" "errors" + "path/filepath" "sort" "strings" "testing" @@ -504,9 +505,14 @@ func TestIntegrationFinalizeArchiveOrdinary(t *testing.T) { t.Errorf("archived record dropped its historical PR field:\n%s", archived) } - // The spec backlink (metadata ref) retargets to the archive path. + // The spec backlink (metadata ref) retargets to the archive path, + // relative to the spec's own directory. spec, _ := originFile(t, f.repo.origin, f.branch, f.specPath) - if !strings.Contains(spec, archivePath) || strings.Contains(spec, "`"+recPath+"`") { + relArchive, err := filepath.Rel(filepath.Dir(f.specPath), archivePath) + if err != nil { + t.Fatalf("relative archive path: %v", err) + } + if !strings.Contains(spec, "]("+filepath.ToSlash(relArchive)+")**") || strings.Contains(spec, "`"+recPath+"`") { t.Errorf("spec backlink not retargeted to the archive path:\n%s", spec) } diff --git a/internal/app/pr_publish.go b/internal/app/pr_publish.go index 4acb84695..d4a2958dc 100644 --- a/internal/app/pr_publish.go +++ b/internal/app/pr_publish.go @@ -236,8 +236,8 @@ func PRPublish(ctx context.Context, deps PlanningDeps, wdeps WorkspaceDeps, gdep } // (7) Assemble the PR body: authored prose preserved byte-for-byte, only the - // backlink block inserted/replaced. - body, err := assemblePRBody([]byte(req.Body), backlink) + // 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) } @@ -350,31 +350,66 @@ func resolvePRChange(ctx context.Context, deps PlanningDeps, repoDir string, id return c, linkContextOf(pin), nil } -// assemblePRBody weaves the Docket-owned backlink block into the authored PR -// prose: inserted at the top (or replaced in place if already present) through -// the loss-preserving document patch API, so every authored byte outside that -// block — including any build-evidence block the author wrote — is preserved -// exactly. A malformed managed-block population in the authored body fails the -// parse and returns no bytes. -func assemblePRBody(authored []byte, backlink string) ([]byte, error) { +// assemblePRBody weaves the Docket-owned blocks into the authored PR prose +// through the loss-preserving document patch API: the backlink block at the top +// (or replaced in place if already present), followed by the docket:artifacts +// block carrying artifacts — the absolute plan/results links — when it is not +// "" (an existing artifacts block is replaced in place). Every authored byte +// outside those blocks is preserved exactly. A malformed managed-block +// population in the authored body fails the parse and returns no bytes. +// +// The patch API inserts only at the document's top, so a new artifacts block +// lands there first and the backlink is then inserted above it; a body that +// already carries a backlink but no artifacts block has its backlink lifted +// back to the top above the new artifacts block. +func assemblePRBody(authored []byte, backlink, artifacts string) ([]byte, error) { doc, err := document.Parse(authored) if err != nil { return nil, err } + top := document.AtDocumentStart + if doc.HasFrontmatter() { + top = document.AfterFrontmatter + } interior := backlinkInterior(backlink) + _, hasBacklink := doc.Block(backlinkBlockName) + _, hasArtifacts := doc.Block(prArtifactsBlockName) + insertArtifacts := !hasArtifacts && artifacts != "" + var ps document.PatchSet - if _, ok := doc.Block(backlinkBlockName); ok { - ps.ReplaceBlock(backlinkBlockName, interior) - } else { - at := document.AtDocumentStart - if doc.HasFrontmatter() { - at = document.AfterFrontmatter + switch { + case hasArtifacts: + ps.ReplaceBlock(prArtifactsBlockName, artifacts) + case insertArtifacts: + ps.InsertBlock(prArtifactsBlockName, backlinkBlockAnnotation, artifacts, top) + } + insertBacklink := !hasBacklink + if hasBacklink { + if insertArtifacts { + ps.RemoveBlock(backlinkBlockName) + insertBacklink = true + } else { + ps.ReplaceBlock(backlinkBlockName, interior) } - ps.InsertBlock(backlinkBlockName, backlinkBlockAnnotation, interior, at) } - return doc.Apply(ps) + out, err := doc.Apply(ps) + if err != nil || !insertBacklink { + return out, err + } + + doc2, err := document.Parse(out) + if err != nil { + return nil, err + } + var ps2 document.PatchSet + ps2.InsertBlock(backlinkBlockName, backlinkBlockAnnotation, interior, top) + return doc2.Apply(ps2) } +// prArtifactsBlockName is the PR description's managed block that links the +// change's plan and results on the metadata branch (render.PRArtifactLinksContent). +const prArtifactsBlockName = "artifacts" + // prResultFromEnsure maps a value disposition onto the protocol taxonomy, carrying // the verified PR snapshot's canonical fields (never its body). created/updated are // applied work; adopted/unchanged are idempotent no-ops; contended is a lost race; diff --git a/internal/app/pr_publish_body_test.go b/internal/app/pr_publish_body_test.go new file mode 100644 index 000000000..8b696c8bd --- /dev/null +++ b/internal/app/pr_publish_body_test.go @@ -0,0 +1,51 @@ +package app + +import "testing" + +// TestAssemblePRBodyArtifactsBlock pins the PR description's Docket-owned +// blocks: the backlink at the top, the docket:artifacts plan/results links right +// after it when there are any, every authored byte preserved, and re-assembling +// an assembled body is a fixed point. +func TestAssemblePRBodyArtifactsBlock(t *testing.T) { + const ( + backlinkStart = "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + backlinkEnd = "<!-- docket:backlink:end -->\n" + artifactsStart = "<!-- docket:artifacts:start (generated — do not hand-edit) -->\n" + artifactsEnd = "<!-- docket:artifacts:end -->\n" + authored = "## Summary\n\nDoes the thing.\n" + ) + backlink := backlinkStart + "> ↩ **[Change 0007 — X](https://example.test/blob/docket/docs/changes/active/0007-x.md)**\n" + backlinkEnd + staleBacklink := backlinkStart + "> ↩ **[Change 0007 — X](https://example.test/old)**\n" + backlinkEnd + links := "- Plan: [p.md](https://example.test/blob/docket/docs/superpowers/plans/p.md)\n" + staleLinks := "- Plan: [old.md](https://example.test/old)\n" + wantFull := backlink + artifactsStart + links + artifactsEnd + authored + + cases := []struct { + name, body, artifacts, want string + }{ + {"fresh body gains both blocks", authored, links, wantFull}, + {"no plan or results: backlink only", authored, "", backlink + authored}, + {"both present are replaced in place", + staleBacklink + artifactsStart + staleLinks + artifactsEnd + authored, links, wantFull}, + {"a backlink-only body gains the artifacts block after its backlink", + staleBacklink + authored, links, wantFull}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got, err := assemblePRBody([]byte(tc.body), backlink, tc.artifacts) + if err != nil { + t.Fatalf("assemblePRBody: %v", err) + } + if string(got) != tc.want { + t.Fatalf("body =\n%q\nwant\n%q", got, tc.want) + } + again, err := assemblePRBody(got, backlink, tc.artifacts) + if err != nil { + t.Fatalf("re-assemble: %v", err) + } + if string(again) != string(got) { + t.Fatalf("re-assembling is not a fixed point:\n%q\nthen\n%q", got, again) + } + }) + } +} diff --git a/internal/app/repocheck_corpus_link_integration_test.go b/internal/app/repocheck_corpus_link_integration_test.go index 6b25b8d8d..ea7eb2ebd 100644 --- a/internal/app/repocheck_corpus_link_integration_test.go +++ b/internal/app/repocheck_corpus_link_integration_test.go @@ -13,10 +13,11 @@ import ( "github.com/danielhanold/docket/internal/testsupport" ) -// doneCorpusChangeRecord is a `done` archived change carrying Spec, Plan, and -// Results rows. Under change 0417 a done change's Plan/Results rows pin the -// INTEGRATION branch while its Spec row stays on the metadata branch — the -// distinction the corpus link context (readCheckCorpus) must carry. +// doneCorpusChangeRecord is a LEGACY `done` archived change (no "## Build +// evidence" section) carrying Spec, Plan, and Results rows. Its plan and results +// merged to the INTEGRATION branch, so their rows stay absolute there while its +// Spec row is relative on the metadata branch — the distinction the corpus link +// context (readCheckCorpus) must carry. const doneCorpusChangeRecord = "---\n" + "id: 1\n" + "slug: example\n" + @@ -34,6 +35,27 @@ const doneCorpusChangeRecord = "---\n" + "reconciled: true\n" + "---\n\n## Why\n\nbody\n" +// doneWithEvidenceCorpusChangeRecord is a `done` archived change built on the +// metadata-branch flow: it carries the "## Build evidence" section, so its plan +// and results rows are relative and its merged spec copy gets an absolute +// "Spec (merged)" row on the integration branch. +const doneWithEvidenceCorpusChangeRecord = "---\n" + + "id: 2\n" + + "slug: modern\n" + + "title: Modern change\n" + + "status: done\n" + + "priority: medium\n" + + "type: feature\n" + + "created: 2026-10-06\n" + + "updated: 2026-10-06\n" + + "spec: docs/superpowers/specs/2026-10-06-modern-design.md\n" + + "plan: docs/superpowers/plans/2026-10-06-modern.md\n" + + "results: docs/results/2026-10-06-modern-results.md\n" + + "branch: feat/modern\n" + + "pr: https://github.com/acme/widgets/pull/8\n" + + "reconciled: true\n" + + "---\n\n## Why\n\nbody\n\n## Build evidence\n\nevidence\n" + // TestIntegrationRepoCheckCorpusPinsDoneChangeToIntegrationBranch guards the // readCheckCorpus link-context wiring against a destructive regression (change // 0417). readCheckCorpus builds corpus.link from the setup context's integration @@ -59,6 +81,7 @@ func TestIntegrationRepoCheckCorpusPinsDoneChangeToIntegrationBranch(t *testing. runGit(t, dir, "remote", "add", "origin", "https://github.com/acme/widgets.git") writeRepoFile(t, dir, "docs/changes/archive/2026-08-30-0001-example.md", doneCorpusChangeRecord) + writeRepoFile(t, dir, "docs/changes/archive/2026-10-06-0002-modern.md", doneWithEvidenceCorpusChangeRecord) runGit(t, dir, "add", "-A") runGit(t, dir, "commit", "-q", "-m", "corpus with a done change") tip := runGit(t, dir, "rev-parse", "HEAD") @@ -114,14 +137,38 @@ func TestIntegrationRepoCheckCorpusPinsDoneChangeToIntegrationBranch(t *testing. t.Errorf("Results row does not resolve onto the integration branch (want %q); rendered block:\n%s", resultsURL, body) } - // The Spec row stays on the metadata branch, where the record lives. - specURL := "/blob/" + reposetup.MetadataBranchName + "/docs/superpowers/specs/2026-08-30-example-design.md" - if !strings.Contains(body, specURL) { - t.Errorf("Spec row does not resolve onto the metadata branch (want %q); rendered block:\n%s", specURL, body) + // The Spec row links the spec relatively on the metadata branch, where the + // record lives; a legacy record gets no Spec (merged) row. + specRow := "| Spec | [2026-08-30-example-design.md](../../superpowers/specs/2026-08-30-example-design.md) |" + if !strings.Contains(body, specRow) { + t.Errorf("Spec row is not the relative metadata-branch link (want %q); rendered block:\n%s", specRow, body) + } + if strings.Contains(body, "Spec (merged)") { + t.Errorf("a legacy done record rendered a Spec (merged) row; rendered block:\n%s", body) } // Guard the assertion itself: Plan/Results must not sit on the metadata branch. if strings.Contains(body, "/blob/"+reposetup.MetadataBranchName+"/docs/superpowers/plans/") || strings.Contains(body, "/blob/"+reposetup.MetadataBranchName+"/docs/results/") { t.Errorf("Plan/Results resolved onto the metadata branch; rendered block:\n%s", body) } + // A done change built on the metadata-branch flow: Plan/Results relative, + // and the merged spec copy absolute on the integration branch. + modern, outcome := snap.Change(2) + if outcome != 0 { + t.Fatalf("done-with-evidence change absent from snapshot (lookup outcome %d)", outcome) + } + modernBody, err := render.ArtifactBlockContent(modern, snap, corpus.link) + if err != nil { + t.Fatalf("render artifact block (done with evidence): %v", err) + } + for _, row := range []string{ + "| Spec | [2026-10-06-modern-design.md](../../superpowers/specs/2026-10-06-modern-design.md) |", + "| Spec (merged) | [2026-10-06-modern-design.md](https://github.com/acme/widgets/blob/main/docs/superpowers/specs/2026-10-06-modern-design.md) |", + "| Plan | [2026-10-06-modern.md](../../superpowers/plans/2026-10-06-modern.md) |", + "| Results | [2026-10-06-modern-results.md](../../results/2026-10-06-modern-results.md) |", + } { + if !strings.Contains(modernBody, row) { + t.Errorf("done-with-evidence block missing row %q; rendered block:\n%s", row, modernBody) + } + } } diff --git a/internal/app/repository_repair_integration_test.go b/internal/app/repository_repair_integration_test.go index 07f90a83c..bc8488844 100644 --- a/internal/app/repository_repair_integration_test.go +++ b/internal/app/repository_repair_integration_test.go @@ -211,6 +211,140 @@ func TestIntegrationRepoRepairAppliesOneDescendantCommit(t *testing.T) { } } +// --- one-time relative-link conversion -------------------------------------- + +const ( + convertActivePath = "docs/changes/active/0001-example.md" + convertLegacyPath = "docs/changes/archive/2026-01-02-0002-legacy.md" + convertADRPath = "docs/adrs/0001-first-decision.md" + convertWebBase = "https://github.com/acme/widgets/blob/" +) + +// convertActiveRecord is an active change whose ## Artifacts block was written +// before same-branch links became relative: its spec and ADR rows are absolute +// metadata-branch URLs. +func convertActiveRecord() string { + return "---\n" + + "id: 1\nslug: example\ntitle: Example change\nstatus: proposed\npriority: medium\n" + + "type: feature\ncreated: 2026-08-30\nupdated: 2026-08-30\nadrs: [1]\n" + + "spec: docs/superpowers/specs/2026-08-30-example-design.md\n" + + "---\n\n## Artifacts\n\n" + + "<!-- docket:artifacts:start (generated — do not hand-edit) -->\n" + + "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-30-example-design.md](" + convertWebBase + "docket/docs/superpowers/specs/2026-08-30-example-design.md) |\n" + + "| ADRs | [ADR-0001](" + convertWebBase + "docket/docs/adrs/0001-first-decision.md) |\n" + + "<!-- docket:artifacts:end -->\n\n## Why\n\n" + repairAuthoredSentinel + "\n" +} + +// convertLegacyPlanRow and convertLegacyResultsRow are a legacy done record's +// Plan/Results rows: its files merged to the integration branch, so the rows are +// absolute there and must survive the conversion byte-identical. +const ( + convertLegacyPlanRow = "| Plan | [2026-01-02-legacy.md](" + convertWebBase + "main/docs/superpowers/plans/2026-01-02-legacy.md) |" + convertLegacyResultsRow = "| Results | [2026-01-02-legacy-results.md](" + convertWebBase + "main/docs/results/2026-01-02-legacy-results.md) |" +) + +// convertLegacyRecord is a legacy done archived record (no "## Build evidence" +// section) whose block carries the old absolute spec/ADR rows and its +// integration-branch Plan/Results rows. +func convertLegacyRecord() string { + return "---\n" + + "id: 2\nslug: legacy\ntitle: Legacy change\nstatus: done\npriority: medium\n" + + "type: feature\ncreated: 2026-01-01\nupdated: 2026-01-02\nadrs: [1]\n" + + "spec: docs/superpowers/specs/2026-01-02-legacy-design.md\n" + + "plan: docs/superpowers/plans/2026-01-02-legacy.md\n" + + "results: docs/results/2026-01-02-legacy-results.md\n" + + "---\n\n## Artifacts\n\n" + + "<!-- docket:artifacts:start (generated — do not hand-edit) -->\n" + + "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-01-02-legacy-design.md](" + convertWebBase + "docket/docs/superpowers/specs/2026-01-02-legacy-design.md) |\n" + + convertLegacyPlanRow + "\n" + + convertLegacyResultsRow + "\n" + + "| ADRs | [ADR-0001](" + convertWebBase + "docket/docs/adrs/0001-first-decision.md) |\n" + + "<!-- docket:artifacts:end -->\n\n## Why\n\nLegacy body.\n" +} + +// TestIntegrationRepoRepairConvertsAbsoluteSameBranchLinksOnce is the one-time +// conversion (spec acceptance 6) against real git with a GitHub-shaped origin: +// check reports both records' absolute same-branch rows as artifact-links-stale, +// one authorized repair lands exactly one metadata commit that makes the active +// record's rows relative and the legacy record's spec/ADR rows relative while +// its Plan/Results rows keep pointing at the integration branch, and a second +// repair is a clean no-op. +func TestIntegrationRepoRepairConvertsAbsoluteSameBranchLinksOnce(t *testing.T) { + r := newHealthyRepo(t) + // origin's CONFIGURED url is the GitHub spelling (so the link context carries + // a web URL); insteadOf routes every fetch/push to the local bare origin. + runGit(t, r.invocation, "remote", "set-url", "origin", "https://github.com/acme/widgets.git") + runGit(t, r.invocation, "config", "url."+r.origin+".insteadOf", "https://github.com/acme/widgets.git") + + dotDocket := filepath.Join(r.invocation, ".docket") + writeRepoFile(t, dotDocket, convertADRPath, "---\nid: 1\nslug: first-decision\nstatus: Accepted\ntitle: First decision\n---\nContext.\n") + writeRepoFile(t, dotDocket, convertActivePath, convertActiveRecord()) + writeRepoFile(t, dotDocket, convertLegacyPath, convertLegacyRecord()) + runGit(t, dotDocket, "add", "--", convertADRPath, convertActivePath, convertLegacyPath) + runGit(t, dotDocket, "commit", "-q", "-m", "publish records with absolute same-branch links") + runGit(t, dotDocket, "push", "-q", "origin", string(reposetup.MetadataBranchName)) + + stale := map[string]bool{} + for _, f := range r.runCheck(t).Findings { + if f.Code == reposetup.CodeArtifactLinksStale { + stale[f.Ref] = true + } + } + for _, p := range []string{convertActivePath, convertLegacyPath} { + if !stale[p] { + t.Errorf("check did not report artifact-links-stale for %s; stale refs: %v", p, keysOf(stale)) + } + } + + before := currentDocketTip(t, r) + res := r.runRepair(t, RepairOptions{Authorized: true}) + if res.Result != ResultApplied { + t.Fatalf("repair = %q (%s), want applied", res.Result, res.HumanText()) + } + after := currentDocketTip(t, r) + if parent := runGit(t, r.invocation, "rev-parse", after+"^"); parent != before { + t.Errorf("repair commit parent = %s, want the pinned tip %s (exactly one metadata commit)", parent, before) + } + + active := showDocketFile(t, r, convertActivePath) + for _, row := range []string{ + "| Spec | [2026-08-30-example-design.md](../../superpowers/specs/2026-08-30-example-design.md) |", + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md) |", + } { + if !strings.Contains(active, row) { + t.Errorf("active record missing relative row %q:\n%s", row, active) + } + } + if strings.Contains(active, "/blob/") || !strings.Contains(active, repairAuthoredSentinel) { + t.Errorf("active record must carry no absolute row and its untouched prose:\n%s", active) + } + + legacy := showDocketFile(t, r, convertLegacyPath) + for _, row := range []string{ + "| Spec | [2026-01-02-legacy-design.md](../../superpowers/specs/2026-01-02-legacy-design.md) |", + convertLegacyPlanRow, + convertLegacyResultsRow, + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md) |", + } { + if !strings.Contains(legacy, row) { + t.Errorf("legacy record missing row %q:\n%s", row, legacy) + } + } + if strings.Contains(legacy, convertWebBase+"docket/") { + t.Errorf("legacy record still carries an absolute metadata-branch row:\n%s", legacy) + } + + second := r.runRepair(t, RepairOptions{Authorized: true}) + if second.Result != ResultNoOp { + t.Errorf("second repair = %q (%s), want no-op", second.Result, second.HumanText()) + } + if got := currentDocketTip(t, r); got != after { + t.Errorf("the second repair moved the docket branch %s -> %s", after, got) + } +} + // TestIntegrationRepoRepairMovedTipIsContended proves an authorized run pinned to // a preview's tip, after a concurrent write moved the docket branch, is contended // and overwrites nothing. diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index da56445fc..a96243a75 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -83,7 +83,7 @@ func attachOutcomeCode(res ChangeAttachResult) string { // and leaves the plan unlinked and the plan path untouched on the remote. func TestIntegrationWorkflowRepoChangeAttachPlanMetadataRefusals(t *testing.T) { otherPlan := "docs/superpowers/plans/2026-08-17-decoy.md" - foreign := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md") + "\n# Other plan\n\nSteps.\n" + foreign := attachBacklinkBlock(9, "Another change", "docs/changes/active/0009-other.md", otherPlan) + "\n# Other plan\n\nSteps.\n" rows := []struct { name string @@ -213,7 +213,7 @@ func TestIntegrationWorkflowRepoChangeAttachPlanMetadataHappyPath(t *testing.T) if !ok { t.Fatalf("plan file missing at the metadata remote tip") } - if want := attachHappyPlan(f.id, "A change", f.recPath); plan != want { + if want := attachHappyPlan(f.id, "A change", f.recPath, f.planPath); plan != want { t.Errorf("stored plan =\n%q\nwant\n%q", plan, want) } if got := runGit(t, f.wp, "rev-parse", "HEAD"); got != headBefore { @@ -281,7 +281,7 @@ func TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent(t *testing. t.Errorf("committed record missing the results field:\n%s", final) } stored, ok := originFile(t, f.repo.origin, "docket", resultsPath) - if want := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n" + body; !ok || stored != want { + if want := attachBacklinkBlock(f.id, "A change", f.recPath, resultsPath) + "\n" + body; !ok || stored != want { t.Errorf("stored results =\n%q\nwant\n%q", stored, want) } // One metadata commit: the results file and the record (the board too @@ -313,7 +313,7 @@ func TestIntegrationWorkflowRepoChangeAttachResultsFeatureHeadNeverConsulted(t * t.Fatalf("attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) } stored, ok := originFile(t, f.repo.origin, "docket", resultsPath) - if want := attachBacklinkBlock(f.id, "A change", f.recPath) + "\n" + body; !ok || stored != want { + if want := attachBacklinkBlock(f.id, "A change", f.recPath, resultsPath) + "\n" + body; !ok || stored != want { t.Errorf("stored results =\n%q\nwant\n%q", stored, want) } if got := runGit(t, f.wp, "rev-parse", "HEAD"); got != head { @@ -1006,10 +1006,15 @@ func TestIntegrationWorkflowLifecyclePlanningKillEndToEnd(t *testing.T) { if !ok { t.Fatalf("spec file vanished") } - if !strings.Contains(specFinal, archivePath) { + // The link is the archive path relative to the spec's own directory. + relArchive, err := filepath.Rel(filepath.Dir(specPath), archivePath) + if err != nil { + t.Fatalf("relative archive path: %v", err) + } + if !strings.Contains(specFinal, "]("+filepath.ToSlash(relArchive)+")**") { t.Errorf("spec backlink not retargeted to the archive path:\n%s", specFinal) } - if strings.Contains(specFinal, "`"+widgetPath+"`") { + if strings.Contains(specFinal, "`"+widgetPath+"`") || strings.Contains(specFinal, "changes/active/0003-widget.md)") { t.Errorf("spec backlink still points at the vacated active path:\n%s", specFinal) } diff --git a/internal/render/artifacts.go b/internal/render/artifacts.go index 98243b031..dd3da1f47 100644 --- a/internal/render/artifacts.go +++ b/internal/render/artifacts.go @@ -21,9 +21,9 @@ const ( // ArtifactBlockContent renders the body of the managed docket:artifacts block — // the table BETWEEN the markers, no marker lines — for one change, from its // typed fields: Spec, Plan, Results paths and the ADRs list. Rows appear in the -// fixed order Spec, Plan, Results, ADRs; a row is omitted when its field is -// unset/empty. Empty content ("") means "no artifacts" — the caller still -// writes the (empty) block via document.ReplaceBlock, which supplies the +// fixed order Spec, Spec (merged), Plan, Results, ADRs; a row is omitted when +// its field is unset/empty. Empty content ("") means "no artifacts" — the caller +// still writes the (empty) block via document.ReplaceBlock, which supplies the // markers this function omits. // // snap resolves the change's ADR ids to their canonical repo-relative paths; @@ -31,30 +31,45 @@ const ( // references against the candidate snapshot before rendering), so it surfaces // as an error rather than a degraded link. // -// This reproduces scripts/render-change-links.sh's block body for the four -// typed rows. The Bash renderer's PR row and derived "Stacked children" row are -// out of scope for the v1 typed renderer (PR is a later slice; stacked children -// is a render-time directory scan) and are not emitted here. +// The spec, plan, results, and ADRs all live on the metadata branch with the +// record, so their rows link relatively from the record file (RelativeLink). +// docs/changes/active and docs/changes/archive are siblings, so the block is +// byte-identical before and after archiving, and it renders the same with or +// without a web URL. Two rows are absolute on the integration branch, because +// their files are there: // -// Spec and ADR rows link the metadata branch, where those records live. Plan -// and Results rows are lifecycle-pinned (change 0417): the feature branch -// while the change is not yet done, the integration branch once it is — -// see lifecycleBranch. +// - Spec (merged): the spec copy that merged with the PR, for a done change +// that carries the "## Build evidence" section (built on the +// metadata-branch flow). +// - Plan and Results of a legacy record (see legacyIntegrationArtifacts), +// whose files merged to the integration branch before plan and results +// moved to the metadata branch. func ArtifactBlockContent(c domain.Change, snap domain.Snapshot, link LinkContext) (string, error) { var rows []string + recPath := c.Path() + legacy := legacyIntegrationArtifacts(c) if p := c.Spec().Value; p != "" { - rows = append(rows, pathRow("Spec", p, link.MetadataBranch, link)) - } - if p := c.Plan().Value; p != "" { - rows = append(rows, pathRow("Plan", p, lifecycleBranch(c, link), link)) + rows = append(rows, relativeRow("Spec", recPath, p)) + if c.Status() == domain.StatusDone && c.HasBuildEvidence() { + rows = append(rows, absoluteRow("Spec (merged)", p, link.IntegrationBranch, link)) + } } - if p := c.Results().Value; p != "" { - rows = append(rows, pathRow("Results", p, lifecycleBranch(c, link), link)) + for _, a := range []struct{ label, path string }{ + {"Plan", c.Plan().Value}, + {"Results", c.Results().Value}, + } { + switch { + case a.path == "": + case legacy: + rows = append(rows, absoluteRow(a.label, a.path, link.IntegrationBranch, link)) + default: + rows = append(rows, relativeRow(a.label, recPath, a.path)) + } } if adrs := c.ADRs(); len(adrs) > 0 { - cell, err := adrCell(adrs, snap, link) + cell, err := adrCell(adrs, snap, recPath) if err != nil { return "", err } @@ -74,56 +89,85 @@ func ArtifactBlockContent(c domain.Change, snap domain.Snapshot, link LinkContex return b.String(), nil } -// lifecycleBranch resolves the blob ref for a Plan/Results row (change 0417). -// Those files never live on the metadata branch: they live on the change's -// feature branch until the PR merges, and on the integration branch once the -// change is done. done => IntegrationBranch; every other status — including -// stacked-merged and killed, which get no special handling by design — uses -// the feature branch. An unresolvable ref (unset branch:, or an empty -// IntegrationBranch) returns "" and BlobURLOnBranch falls back to the -// metadata branch: today's behavior, never a malformed URL. -func lifecycleBranch(c domain.Change, link LinkContext) string { - if c.Status() == domain.StatusDone { - return link.IntegrationBranch - } - return c.Branch().Value +// legacyIntegrationArtifacts reports whether a change's plan and results live +// on the integration branch: a done change WITHOUT the "## Build evidence" +// section closed out before plan and results moved to the metadata branch, so +// its files merged with its PR and never move again. Every other change's plan +// and results are on the metadata branch. +func legacyIntegrationArtifacts(c domain.Change) bool { + return c.Status() == domain.StatusDone && !c.HasBuildEvidence() } -// pathRow renders a Spec/Plan/Results row. In GitHub mode the link text is the -// path basename and the URL is the full repo-relative path on the given -// branch ("" falls back to the metadata branch); in repo-relative mode the -// cell is the backtick-quoted path. -func pathRow(label, repoRelPath, branch string, link LinkContext) string { - if url := link.BlobURLOnBranch(repoRelPath, branch); url != "" { - return fmt.Sprintf("| %s | [%s](%s) |", label, path.Base(repoRelPath), url) +// relativeRow renders a same-branch Spec/Plan/Results row: the link text is the +// path basename and the target is the path relative to the record file. +func relativeRow(label, recPath, target string) string { + return fmt.Sprintf("| %s | [%s](%s) |", label, path.Base(target), RelativeLink(recPath, target)) +} + +// absoluteRow renders a row for a file on another branch. With a web URL the +// link text is the path basename and the URL is the full repo-relative path on +// branch ("" falls back to the metadata branch at BlobURLOnBranch); without +// one the cell is the backtick-quoted path. +func absoluteRow(label, target, branch string, link LinkContext) string { + if url := link.BlobURLOnBranch(target, branch); url != "" { + return fmt.Sprintf("| %s | [%s](%s) |", label, path.Base(target), url) } - return fmt.Sprintf("| %s | `%s` |", label, repoRelPath) + return fmt.Sprintf("| %s | `%s` |", label, target) } -// adrCell renders the comma-separated ADR cell. In GitHub mode each entry is -// [ADR-NNNN](blob-url-to-the-resolved-file); in repo-relative mode the ADR-NNNN -// label is dropped and the entry is the backtick-quoted repo-relative path -// (matching scripts/render-change-links.sh). -func adrCell(adrs []domain.ADRID, snap domain.Snapshot, link LinkContext) (string, error) { +// adrCell renders the comma-separated ADR cell: each entry is +// [ADR-NNNN](path-relative-to-the-record) for the resolved ADR file. +func adrCell(adrs []domain.ADRID, snap domain.Snapshot, recPath string) (string, error) { entries := make([]string, len(adrs)) for i, id := range adrs { adr, outcome := snap.ADR(id) if outcome != domain.LookupFound { return "", fmt.Errorf("render: cannot resolve ADR-%04d to a record path (lookup outcome %d)", int(id), outcome) } - relPath := adr.Path() - if url := link.BlobURL(relPath); url != "" { - entries[i] = fmt.Sprintf("[ADR-%04d](%s)", int(id), url) - } else { - entries[i] = fmt.Sprintf("`%s`", relPath) - } + entries[i] = fmt.Sprintf("[ADR-%04d](%s)", int(id), RelativeLink(recPath, adr.Path())) } return strings.Join(entries, ", "), nil } -// BacklinkContent renders the full docket:backlink block for a spec/plan/results -// file — the two marker lines plus the "> ↩ **[Change NNNN — Title](url)**" -// line — targeting the change's CURRENT canonical metadata path (c.Path()). In +// ArtifactBacklinkContent renders the full docket:backlink block for a spec, +// plan, or results file on the metadata branch — the two marker lines plus +// "> ↩ **[Change NNNN — Title](<relative path to the record>)**" — targeting +// the change's CURRENT canonical record path (c.Path()) relative to +// artifactPath, the file's own repo-relative path. The link is the same with +// or without a web URL. The result ends with a trailing newline. +func ArtifactBacklinkContent(c domain.Change, artifactPath string) string { + line := fmt.Sprintf("> ↩ **[Change %04d — %s](%s)**", int(c.ID()), c.Title(), RelativeLink(artifactPath, c.Path())) + return backlinkStartMarker + "\n" + line + "\n" + backlinkEndMarker + "\n" +} + +// PRArtifactLinksContent renders the interior of the PR description's +// docket:artifacts block: absolute metadata-branch links to the plan and +// results ("- Plan: [<base>](<url>)", "- Results: [<base>](<url>)"), since a +// PR body has no branch to be relative to. "" when RepoWebURL is empty or +// neither field is set. +func PRArtifactLinksContent(c domain.Change, link LinkContext) string { + var b strings.Builder + for _, a := range []struct{ label, path string }{ + {"Plan", c.Plan().Value}, + {"Results", c.Results().Value}, + } { + if a.path == "" { + continue + } + url := link.BlobURL(a.path) + if url == "" { + return "" + } + fmt.Fprintf(&b, "- %s: [%s](%s)\n", a.label, path.Base(a.path), url) + } + return b.String() +} + +// BacklinkContent renders the full docket:backlink block for a PR description — +// the two marker lines plus the "> ↩ **[Change NNNN — Title](url)**" line — +// targeting the change's CURRENT canonical metadata path (c.Path()) with an +// absolute metadata-branch URL, since a PR body has no branch to be relative +// to. (A file on the metadata branch uses ArtifactBacklinkContent.) In // repo-relative mode (empty RepoWebURL) the link becomes // "> ↩ **Change NNNN — Title** — `relpath`", mirroring // scripts/render-artifact-backlink.sh. The result ends with a trailing newline. diff --git a/internal/render/artifacts_test.go b/internal/render/artifacts_test.go index 1cf5ae069..1b1789717 100644 --- a/internal/render/artifacts_test.go +++ b/internal/render/artifacts_test.go @@ -3,6 +3,7 @@ package render_test import ( "bytes" "os" + "path" "path/filepath" "strings" "testing" @@ -11,10 +12,9 @@ import ( "github.com/danielhanold/docket/internal/render" ) -// readArtifactGolden loads a frozen block/backlink snapshot from -// testdata/artifacts. These are historical snapshots of the Bash renderers -// (see testdata/artifacts/PROVENANCE.md); the byte-equality asserts below are -// their drift guard. +// readArtifactGolden loads a block/backlink snapshot from testdata/artifacts +// (see testdata/artifacts/PROVENANCE.md for each golden's source); the +// byte-equality asserts below are their drift guard. func readArtifactGolden(t *testing.T, name string) []byte { t.Helper() b, err := os.ReadFile(filepath.Join("testdata", "artifacts", name)) @@ -126,14 +126,15 @@ func TestArtifactBlockContentEmpty(t *testing.T) { } // TestArtifactBlockContentADRLinksCommaSeparated pins the comma-separated ADR -// cell in GitHub mode independently of the golden. +// cell independently of the golden: each entry keeps its ADR-NNNN label and +// links the ADR relatively from the record. func TestArtifactBlockContentADRLinksCommaSeparated(t *testing.T) { got, err := render.ArtifactBlockContent(alphaChange(), adrSnapshot(), githubLink) if err != nil { t.Fatalf("ArtifactBlockContent: %v", err) } - wantCell := "| ADRs | [ADR-0001](https://github.com/danielhanold/docket/blob/docket/docs/adrs/0001-first-decision.md), " + - "[ADR-0002](https://github.com/danielhanold/docket/blob/docket/docs/adrs/0002-second-decision.md) |" + wantCell := "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), " + + "[ADR-0002](../../adrs/0002-second-decision.md) |" if !strings.Contains(got, wantCell) { t.Fatalf("ADR cell not comma-separated as expected:\n%s", got) } @@ -218,148 +219,242 @@ func TestBacklinkContentDeterministic(t *testing.T) { } } -// gammaChange is fixture C: beta's spec/plan/results paths, plus the in-flight -// lifecycle state 0417 pins on — status implemented, feature branch set. -func gammaChange() domain.Change { +// fullChange is a change carrying every artifact kind — spec, plan, results, +// and two ADRs — at the given lifecycle state, location, and record path. +func fullChange(status domain.Status, evidence bool, loc domain.RecordLocation, recPath string) domain.Change { return domain.NewChange(domain.ChangeSpec{ - ID: 9, - Slug: "gamma-change", - Title: "Gamma change", - Status: domain.StatusImplemented, - Branch: optString("fix/gamma-change"), - Spec: optString("docs/superpowers/specs/2026-08-16-beta-change-design.md"), - Plan: optString("docs/superpowers/plans/2026-08-16-beta-change.md"), - Results: optString("docs/results/2026-08-16-beta-change-results.md"), - Location: domain.LocationActive, - Path: "docs/changes/active/0009-gamma-change.md", + ID: 10, + Slug: "delta-change", + Title: "Delta change", + Status: status, + Branch: optString("fix/delta-change"), + ADRs: adrIDs(1, 2), + Spec: optString("docs/superpowers/specs/2026-08-16-delta-change-design.md"), + Plan: optString("docs/superpowers/plans/2026-08-16-delta-change.md"), + Results: optString("docs/results/2026-08-16-delta-change-results.md"), + HasBuildEvidence: evidence, + Location: loc, + Path: recPath, }) } -// deltaChange is fixture D: gamma after merge — done, archived. -func deltaChange() domain.Change { - return domain.NewChange(domain.ChangeSpec{ - ID: 10, - Slug: "delta-change", - Title: "Delta change", - Status: domain.StatusDone, - Branch: optString("fix/delta-change"), - Spec: optString("docs/superpowers/specs/2026-08-16-beta-change-design.md"), - Plan: optString("docs/superpowers/plans/2026-08-16-beta-change.md"), - Results: optString("docs/results/2026-08-16-beta-change-results.md"), - Location: domain.LocationArchive, - Path: "docs/changes/archive/2026-09-14-0010-delta-change.md", - }) +const ( + deltaActivePath = "docs/changes/active/0010-delta-change.md" + deltaArchivePath = "docs/changes/archive/2026-09-14-0010-delta-change.md" +) + +// markdownLinkTargets returns every Markdown link target "(...)" following a +// "](" in s, in order. +func markdownLinkTargets(s string) []string { + var out []string + for { + i := strings.Index(s, "](") + if i < 0 { + return out + } + s = s[i+2:] + j := strings.IndexByte(s, ')') + if j < 0 { + return out + } + out = append(out, s[:j]) + s = s[j+1:] + } } -// TestArtifactBlockLifecyclePinsPlanResults is 0417's core assert and its -// mutation-tested guard (guards-are-code): the Plan/Results rows of a -// not-yet-done change resolve onto the FEATURE branch, of a done change onto -// the INTEGRATION branch, while the Spec row stays on the metadata branch in -// every state. Asserts pin the exact produced URL, positive and negative -// (assert-pins-outcome-not-mechanism / assert-detects-removal-not-replacement): -// each case also proves the docket-pinned spelling is GONE for Plan/Results. -// -// Mutation probes (run with -count=1; cp-backup artifacts.go first): -// -// (a) in ArtifactBlockContent, pass link.MetadataBranch instead of -// lifecycleBranch(c, link) for the Plan and Results rows (the pre-0417 -// hardcoding) -> the implemented and done cases must redden; -// (b) in lifecycleBranch, delete the StatusDone arm -> the done case must -// redden; -// (c) in lifecycleBranch, delete the empty-branch fallback -> the -// fallback case must redden. -func TestArtifactBlockLifecyclePinsPlanResults(t *testing.T) { - const base = "https://github.com/danielhanold/docket/blob/" - cases := []struct { - name string - c domain.Change - wantRef string // branch the Plan/Results URLs must use - }{ - {"implemented pins the feature branch", gammaChange(), "fix/gamma-change"}, - {"done pins the integration branch", deltaChange(), "main"}, +// TestArtifactBlockRelativeRowsSurviveArchive: every row of a change whose +// artifacts live on the metadata branch is a link relative to the record, and +// docs/changes/active and docs/changes/archive are siblings, so the block is +// byte-identical before and after archiving. Every link resolves, from the +// record's own directory, to the field's path; no row is an absolute blob URL; +// and the rows render the same with or without a web URL. +func TestArtifactBlockRelativeRowsSurviveArchive(t *testing.T) { + active := fullChange(domain.StatusInProgress, false, domain.LocationActive, deltaActivePath) + archived := fullChange(domain.StatusInProgress, false, domain.LocationArchive, deltaArchivePath) + + gotActive, err := render.ArtifactBlockContent(active, adrSnapshot(), githubLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(active): %v", err) } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - got, err := render.ArtifactBlockContent(tc.c, domain.Snapshot{}, githubLink) - if err != nil { - t.Fatalf("ArtifactBlockContent: %v", err) - } - wantPlan := "| Plan | [2026-08-16-beta-change.md](" + base + tc.wantRef + "/docs/superpowers/plans/2026-08-16-beta-change.md) |" - wantResults := "| Results | [2026-08-16-beta-change-results.md](" + base + tc.wantRef + "/docs/results/2026-08-16-beta-change-results.md) |" - wantSpec := "| Spec | [2026-08-16-beta-change-design.md](" + base + "docket/docs/superpowers/specs/2026-08-16-beta-change-design.md) |" - for _, want := range []string{wantPlan, wantResults, wantSpec} { - if !strings.Contains(got, want) { - t.Errorf("block missing row %q\ngot:\n%s", want, got) - } - } - for _, banned := range []string{ - base + "docket/docs/superpowers/plans/", - base + "docket/docs/results/", - } { - if strings.Contains(got, banned) { - t.Errorf("Plan/Results row still pinned to the metadata branch (%q present)\ngot:\n%s", banned, got) - } + gotArchived, err := render.ArtifactBlockContent(archived, adrSnapshot(), githubLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(archived): %v", err) + } + if gotActive != gotArchived { + t.Fatalf("block differs across the archive move:\n--- active ---\n%s\n--- archived ---\n%s", gotActive, gotArchived) + } + gotNoWeb, err := render.ArtifactBlockContent(active, adrSnapshot(), relativeLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(no web URL): %v", err) + } + if gotNoWeb != gotActive { + t.Fatalf("relative rows depend on the web URL:\n--- github ---\n%s\n--- none ---\n%s", gotActive, gotNoWeb) + } + if strings.Contains(gotActive, "/blob/") { + t.Fatalf("a same-branch row is still an absolute blob URL:\n%s", gotActive) + } + + want := map[string]bool{ + "docs/superpowers/specs/2026-08-16-delta-change-design.md": true, + "docs/superpowers/plans/2026-08-16-delta-change.md": true, + "docs/results/2026-08-16-delta-change-results.md": true, + "docs/adrs/0001-first-decision.md": true, + "docs/adrs/0002-second-decision.md": true, + } + for _, recPath := range []string{deltaActivePath, deltaArchivePath} { + targets := markdownLinkTargets(gotActive) + if len(targets) != len(want) { + t.Fatalf("got %d links, want %d:\n%s", len(targets), len(want), gotActive) + } + seen := map[string]bool{} + for _, link := range targets { + resolved := path.Join(path.Dir(recPath), link) + if !want[resolved] { + t.Errorf("link %q from %q resolves to %q, which is no artifact field", link, recPath, resolved) } - }) + seen[resolved] = true + } + if len(seen) != len(want) { + t.Errorf("links from %q resolve to %d distinct artifacts, want %d", recPath, len(seen), len(want)) + } } } -// TestArtifactBlockStackedMergedUsesFeatureBranch documents the spec's -// explicit choice: stacked-merged is NOT done, so it takes the feature-branch -// leg of the same test — no special handling. -func TestArtifactBlockStackedMergedUsesFeatureBranch(t *testing.T) { - spec := domain.ChangeSpec{ - ID: 11, Slug: "stacked-change", Title: "Stacked change", - Status: domain.StatusStackedMerged, - Branch: optString("fix/stacked-change"), - Plan: optString("docs/superpowers/plans/2026-08-16-beta-change.md"), - Location: domain.LocationActive, - Path: "docs/changes/active/0011-stacked-change.md", - } - got, err := render.ArtifactBlockContent(domain.NewChange(spec), domain.Snapshot{}, githubLink) +// TestArtifactBlockLegacyDoneKeepsIntegrationRows pins the legacy rule: a done +// change with no "## Build evidence" section predates the metadata-branch +// cutover, so its plan and results live on the integration branch and their +// rows stay absolute there. Its spec and ADR rows are relative like every +// other record's, and it gets no "Spec (merged)" row. +// +// Mutation probe (Step 7): make the legacy predicate always false -> the +// absolute Plan/Results asserts must redden. +func TestArtifactBlockLegacyDoneKeepsIntegrationRows(t *testing.T) { + c := fullChange(domain.StatusDone, false, domain.LocationArchive, deltaArchivePath) + + got, err := render.ArtifactBlockContent(c, adrSnapshot(), githubLink) if err != nil { t.Fatalf("ArtifactBlockContent: %v", err) } - want := "https://github.com/danielhanold/docket/blob/fix/stacked-change/docs/superpowers/plans/2026-08-16-beta-change.md" - if !strings.Contains(got, want) { - t.Errorf("stacked-merged Plan row does not use the feature branch\ngot:\n%s", got) + want := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | [2026-08-16-delta-change.md](https://github.com/danielhanold/docket/blob/main/docs/superpowers/plans/2026-08-16-delta-change.md) |\n" + + "| Results | [2026-08-16-delta-change-results.md](https://github.com/danielhanold/docket/blob/main/docs/results/2026-08-16-delta-change-results.md) |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if got != want { + t.Fatalf("legacy done block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) } -} -// TestArtifactBlockMissingBranchFallsBackToMetadata pins the defensive -// default: a Plan path present with no branch: renders today's metadata-branch -// URL, never a malformed one. (betaChange — no status, no branch — exercises -// the same path via the frozen golden; this case makes the implemented-state -// variant explicit.) -func TestArtifactBlockMissingBranchFallsBackToMetadata(t *testing.T) { - spec := domain.ChangeSpec{ - ID: 12, Slug: "branchless-change", Title: "Branchless change", - Status: domain.StatusImplemented, - Plan: optString("docs/superpowers/plans/2026-08-16-beta-change.md"), - Location: domain.LocationActive, - Path: "docs/changes/active/0012-branchless-change.md", - } - got, err := render.ArtifactBlockContent(domain.NewChange(spec), domain.Snapshot{}, githubLink) + gotNoWeb, err := render.ArtifactBlockContent(c, adrSnapshot(), relativeLink) if err != nil { - t.Fatalf("ArtifactBlockContent: %v", err) + t.Fatalf("ArtifactBlockContent(no web URL): %v", err) } - want := "https://github.com/danielhanold/docket/blob/docket/docs/superpowers/plans/2026-08-16-beta-change.md" - if !strings.Contains(got, want) { - t.Errorf("branchless Plan row did not fall back to the metadata branch\ngot:\n%s", got) + wantNoWeb := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | `docs/superpowers/plans/2026-08-16-delta-change.md` |\n" + + "| Results | `docs/results/2026-08-16-delta-change-results.md` |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if gotNoWeb != wantNoWeb { + t.Fatalf("legacy done block (no web URL) mismatch:\n--- got ---\n%s\n--- want ---\n%s", gotNoWeb, wantNoWeb) } } -// TestArtifactBlockRelativeModeIgnoresLifecycle: relative rendering embeds no -// branch, so an implemented change with a feature branch must produce the -// exact bytes of the frozen relative golden (which was generated from a -// lifecycle-free fixture with the same artifact paths). -func TestArtifactBlockRelativeModeIgnoresLifecycle(t *testing.T) { - got, err := render.ArtifactBlockContent(gammaChange(), domain.Snapshot{}, relativeLink) +// TestArtifactBlockDoneWithEvidenceAddsSpecMerged: a done change that carries +// the "## Build evidence" section was built after the cutover — its plan and +// results stay on the metadata branch (relative rows), and its spec copy +// merged with the PR, so a "Spec (merged)" row links that copy absolutely on +// the integration branch, right after the Spec row. A not-yet-done change with +// the section gets no "Spec (merged)" row: nothing has merged. +func TestArtifactBlockDoneWithEvidenceAddsSpecMerged(t *testing.T) { + c := fullChange(domain.StatusDone, true, domain.LocationArchive, deltaArchivePath) + + got, err := render.ArtifactBlockContent(c, adrSnapshot(), githubLink) if err != nil { t.Fatalf("ArtifactBlockContent: %v", err) } - want := readArtifactGolden(t, "block-spec-plan-results.relative.golden") - if !bytes.Equal([]byte(got), want) { - t.Errorf("relative-mode output diverged from frozen golden\ngot:\n%s\nwant:\n%s", got, want) + want := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Spec (merged) | [2026-08-16-delta-change-design.md](https://github.com/danielhanold/docket/blob/main/docs/superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | [2026-08-16-delta-change.md](../../superpowers/plans/2026-08-16-delta-change.md) |\n" + + "| Results | [2026-08-16-delta-change-results.md](../../results/2026-08-16-delta-change-results.md) |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if got != want { + t.Fatalf("done-with-evidence block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + + gotNoWeb, err := render.ArtifactBlockContent(c, adrSnapshot(), relativeLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(no web URL): %v", err) + } + if row := "| Spec (merged) | `docs/superpowers/specs/2026-08-16-delta-change-design.md` |\n"; !strings.Contains(gotNoWeb, row) { + t.Fatalf("no-web-URL Spec (merged) row missing %q:\n%s", row, gotNoWeb) + } + + implemented := fullChange(domain.StatusImplemented, true, domain.LocationActive, deltaActivePath) + gotImpl, err := render.ArtifactBlockContent(implemented, adrSnapshot(), githubLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(implemented): %v", err) + } + if strings.Contains(gotImpl, "Spec (merged)") { + t.Fatalf("a not-yet-done change rendered a Spec (merged) row:\n%s", gotImpl) + } +} + +// TestArtifactBacklinkContentRelative: a file on the metadata branch links back +// to its record relatively from the file's own directory, so the link follows +// the record when it is archived (the archive transaction re-stamps it). +func TestArtifactBacklinkContentRelative(t *testing.T) { + const plan = "docs/superpowers/plans/p.md" + cases := []struct { + name string + c domain.Change + want string + }{ + {"active", alphaChange(), "../../changes/active/0007-alpha-change.md"}, + {"archived", domain.NewChange(domain.ChangeSpec{ + ID: 7, Slug: "alpha-change", Title: "Alpha change", + Location: domain.LocationArchive, + Path: "docs/changes/archive/2026-08-16-0007-alpha-change.md", + }), "../../changes/archive/2026-08-16-0007-alpha-change.md"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := render.ArtifactBacklinkContent(tc.c, plan) + want := "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + + "> ↩ **[Change 0007 — Alpha change](" + tc.want + ")**\n" + + "<!-- docket:backlink:end -->\n" + if got != want { + t.Fatalf("backlink mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + if resolved := path.Join(path.Dir(plan), tc.want); resolved != tc.c.Path() { + t.Fatalf("backlink resolves to %q, not the record %q", resolved, tc.c.Path()) + } + }) + } +} + +// TestPRArtifactLinksContent: the PR description links the plan and results +// absolutely on the metadata branch (a PR body has no branch to be relative +// to); nothing renders without a web URL or without either field. +func TestPRArtifactLinksContent(t *testing.T) { + got := render.PRArtifactLinksContent(betaChange(), githubLink) + want := "- Plan: [2026-08-16-beta-change.md](https://github.com/danielhanold/docket/blob/docket/docs/superpowers/plans/2026-08-16-beta-change.md)\n" + + "- Results: [2026-08-16-beta-change-results.md](https://github.com/danielhanold/docket/blob/docket/docs/results/2026-08-16-beta-change-results.md)\n" + if got != want { + t.Fatalf("PR artifact links mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + if got := render.PRArtifactLinksContent(betaChange(), relativeLink); got != "" { + t.Fatalf("no web URL rendered %q, want empty", got) + } + if got := render.PRArtifactLinksContent(alphaChange(), githubLink); got != "" { + t.Fatalf("a change with neither plan nor results rendered %q, want empty", got) + } + planOnly := domain.NewChange(domain.ChangeSpec{ + ID: 8, Slug: "beta-change", Title: "Beta change", + Plan: optString("docs/superpowers/plans/2026-08-16-beta-change.md"), + Path: "docs/changes/active/0008-beta-change.md", + }) + wantPlan := "- Plan: [2026-08-16-beta-change.md](https://github.com/danielhanold/docket/blob/docket/docs/superpowers/plans/2026-08-16-beta-change.md)\n" + if got := render.PRArtifactLinksContent(planOnly, githubLink); got != wantPlan { + t.Fatalf("plan-only PR links = %q, want %q", got, wantPlan) } } diff --git a/internal/render/link.go b/internal/render/link.go index 15dd8c6eb..27cfba69c 100644 --- a/internal/render/link.go +++ b/internal/render/link.go @@ -16,9 +16,10 @@ type LinkContext struct { // records point at, e.g. "docket". MetadataBranch string // IntegrationBranch is the branch PR merges land on, e.g. "main". It is - // consulted only for rows whose file reaches the integration branch (the - // Plan/Results rows of a done change); empty falls back to MetadataBranch - // at the BlobURLOnBranch boundary, so a malformed URL is unrepresentable. + // consulted only for rows whose file is on the integration branch (a done + // change's "Spec (merged)" row, and a legacy done change's Plan/Results + // rows); empty falls back to MetadataBranch at the BlobURLOnBranch + // boundary, so a malformed URL is unrepresentable. IntegrationBranch string } @@ -31,8 +32,8 @@ func (l LinkContext) BlobURL(repoRelPath string) string { // BlobURLOnBranch returns RepoWebURL + "/blob/" + branch + "/" + repoRelPath, // or "" when RepoWebURL is empty. An empty branch falls back to -// MetadataBranch: the defensive default for callers whose lifecycle ref is -// unresolvable (change 0417), never a malformed "/blob//" URL. +// MetadataBranch: the defensive default for a caller whose branch is +// unresolvable, never a malformed "/blob//" URL. func (l LinkContext) BlobURLOnBranch(repoRelPath, branch string) string { if l.RepoWebURL == "" { return "" diff --git a/internal/render/relpath.go b/internal/render/relpath.go new file mode 100644 index 000000000..cfe521f2e --- /dev/null +++ b/internal/render/relpath.go @@ -0,0 +1,38 @@ +package render + +import ( + "path" + "strings" +) + +// RelativeLink returns the slash-separated relative path from the directory of +// fromFile to toFile, both canonical repo-relative paths. It is the link a +// Markdown file on one branch uses to reach another file on the same branch: +// it resolves both on GitHub (against the branch being viewed) and in a local +// checkout, and path.Join(path.Dir(fromFile), RelativeLink(fromFile, toFile)) +// is toFile. +func RelativeLink(fromFile, toFile string) string { + fromDir := splitDir(path.Dir(fromFile)) + toParts := strings.Split(toFile, "/") + toDir := toParts[:len(toParts)-1] + + common := 0 + for common < len(fromDir) && common < len(toDir) && fromDir[common] == toDir[common] { + common++ + } + out := make([]string, 0, len(fromDir)-common+len(toParts)-common) + for range fromDir[common:] { + out = append(out, "..") + } + out = append(out, toParts[common:]...) + return strings.Join(out, "/") +} + +// splitDir splits a repo-relative directory into its segments; the repository +// root (path.Dir's ".") has none. +func splitDir(dir string) []string { + if dir == "." || dir == "" { + return nil + } + return strings.Split(dir, "/") +} diff --git a/internal/render/relpath_test.go b/internal/render/relpath_test.go new file mode 100644 index 000000000..17e068ed8 --- /dev/null +++ b/internal/render/relpath_test.go @@ -0,0 +1,26 @@ +package render + +import ( + "path" + "testing" +) + +func TestRelativeLink(t *testing.T) { + cases := []struct{ from, to, want string }{ + {"docs/changes/active/0001-a.md", "docs/superpowers/specs/s.md", "../../superpowers/specs/s.md"}, + {"docs/changes/archive/2026-10-06-0001-a.md", "docs/superpowers/specs/s.md", "../../superpowers/specs/s.md"}, + {"docs/superpowers/plans/p.md", "docs/changes/active/0001-a.md", "../../changes/active/0001-a.md"}, + {"docs/results/r.md", "docs/changes/active/0001-a.md", "../changes/active/0001-a.md"}, + {"docs/changes/active/0001-a.md", "docs/changes/active/0002-b.md", "0002-b.md"}, + {"README.md", "docs/x.md", "docs/x.md"}, + } + for _, c := range cases { + got := RelativeLink(c.from, c.to) + if got != c.want { + t.Errorf("RelativeLink(%q, %q) = %q, want %q", c.from, c.to, got, c.want) + } + if resolved := path.Join(path.Dir(c.from), got); resolved != c.to { + t.Errorf("link %q from %q resolves to %q, not %q", got, c.from, resolved, c.to) + } + } +} diff --git a/internal/render/testdata/artifacts/PROVENANCE.md b/internal/render/testdata/artifacts/PROVENANCE.md index affe88ff9..142ab109c 100644 --- a/internal/render/testdata/artifacts/PROVENANCE.md +++ b/internal/render/testdata/artifacts/PROVENANCE.md @@ -1,6 +1,6 @@ # Provenance — artifact-block and spec-backlink goldens (change 0312, task 3) -These goldens are **historical snapshots** frozen once from the live Bash +These goldens were **historical snapshots** frozen once from the live Bash renderers `scripts/render-change-links.sh` and `scripts/render-artifact-backlink.sh`. Under docket's frozen-golden contract they must **NOT** track those scripts: the scripts die in the 0316+ cutover, and a human decides whether the canonical @@ -8,6 +8,26 @@ Go shape should change and updates the golden and its `internal/render` serializer together. The byte-equality tests in `artifacts_test.go` are the drift assert. +## The artifact-block goldens are now Go renderer output (change 0530) + +Change 0530 deliberately changed the canonical `## Artifacts` shape: plan, +results, spec, and ADRs all live on the metadata branch, so every row of a +non-legacy record is a link **relative to the record file** (spec acceptance 6 +— the links survive the active-to-archive move unchanged and resolve both on +GitHub and in a local checkout). + +The four `block-*.golden` files were therefore replaced with the Go +renderer's (`render.ArtifactBlockContent`) output for the same two fixtures, +and the Go renderer is now their source. Relative rows render identically +with or without a web URL, so each `.github.golden` / `.relative.golden` pair +is byte-identical; both are kept so the two link contexts stay pinned +separately. The fixture-to-golden mapping below is unchanged; the Bash +commands are kept as the record of the original freeze. The `backlink-*` +goldens are untouched: `render.BacklinkContent` keeps the absolute form for +PR descriptions (a PR body has no branch to be relative to), and the +metadata-branch file backlink is the separate `render.ArtifactBacklinkContent`, +pinned by explicit strings in `artifacts_test.go`. + ## Generating commit Frozen at commit `7ab36b6323871b45626ee810b5ae9c539e100aed` (change 0312, HEAD @@ -57,15 +77,17 @@ render-artifact-backlink.sh --artifact-file <spec> --change-file <change> [--rep shape: the link TARGET is the archive path). - `backlink-active.relative.golden` — repo-relative (empty `RepoWebURL`). -## Notable Bash behaviors these goldens pin +## Notable behaviors these goldens pin -- In GitHub mode an ADR cell is `[ADR-NNNN](blob-url-to-the-resolved-file)`, - comma-joined. In repo-relative mode the `ADR-NNNN` label is **dropped**: the - cell is the backtick-quoted repo-relative path, comma-joined. -- Spec/Plan/Results link **text** is the path basename; the URL is the full - repo-relative path. +- An ADR cell is `[ADR-NNNN](relative-path-to-the-resolved-file)`, + comma-joined, with or without a web URL. (The Bash renderer dropped the + `ADR-NNNN` label in repo-relative mode; the relative form keeps it.) +- Spec/Plan/Results link **text** is the path basename; the link is the path + relative to the record file. - Rows appear in fixed order Spec, Plan, Results, ADRs; a row is omitted when - its field is unset/empty. The `## Artifacts` block's PR row and the derived + its field is unset/empty. (The Go renderer's done-only `Spec (merged)` row + and the legacy absolute Plan/Results rows are pinned in `artifacts_test.go`, + not here.) The `## Artifacts` block's PR row and the derived "Stacked children" row are **out of scope** for the v1 typed renderer (PR is a later slice; stacked children is a render-time directory scan) and are not frozen here. diff --git a/internal/render/testdata/artifacts/block-spec-adrs.github.golden b/internal/render/testdata/artifacts/block-spec-adrs.github.golden index 98428e791..5cb273373 100644 --- a/internal/render/testdata/artifacts/block-spec-adrs.github.golden +++ b/internal/render/testdata/artifacts/block-spec-adrs.github.golden @@ -1,4 +1,4 @@ | Artifact | Link | |---|---| -| Spec | [2026-08-16-alpha-change-design.md](https://github.com/danielhanold/docket/blob/docket/docs/superpowers/specs/2026-08-16-alpha-change-design.md) | -| ADRs | [ADR-0001](https://github.com/danielhanold/docket/blob/docket/docs/adrs/0001-first-decision.md), [ADR-0002](https://github.com/danielhanold/docket/blob/docket/docs/adrs/0002-second-decision.md) | +| Spec | [2026-08-16-alpha-change-design.md](../../superpowers/specs/2026-08-16-alpha-change-design.md) | +| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) | diff --git a/internal/render/testdata/artifacts/block-spec-adrs.relative.golden b/internal/render/testdata/artifacts/block-spec-adrs.relative.golden index e8e3b5112..5cb273373 100644 --- a/internal/render/testdata/artifacts/block-spec-adrs.relative.golden +++ b/internal/render/testdata/artifacts/block-spec-adrs.relative.golden @@ -1,4 +1,4 @@ | Artifact | Link | |---|---| -| Spec | `docs/superpowers/specs/2026-08-16-alpha-change-design.md` | -| ADRs | `docs/adrs/0001-first-decision.md`, `docs/adrs/0002-second-decision.md` | +| Spec | [2026-08-16-alpha-change-design.md](../../superpowers/specs/2026-08-16-alpha-change-design.md) | +| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) | diff --git a/internal/render/testdata/artifacts/block-spec-plan-results.github.golden b/internal/render/testdata/artifacts/block-spec-plan-results.github.golden index 29550edba..6dbf83b08 100644 --- a/internal/render/testdata/artifacts/block-spec-plan-results.github.golden +++ b/internal/render/testdata/artifacts/block-spec-plan-results.github.golden @@ -1,5 +1,5 @@ | Artifact | Link | |---|---| -| Spec | [2026-08-16-beta-change-design.md](https://github.com/danielhanold/docket/blob/docket/docs/superpowers/specs/2026-08-16-beta-change-design.md) | -| Plan | [2026-08-16-beta-change.md](https://github.com/danielhanold/docket/blob/docket/docs/superpowers/plans/2026-08-16-beta-change.md) | -| Results | [2026-08-16-beta-change-results.md](https://github.com/danielhanold/docket/blob/docket/docs/results/2026-08-16-beta-change-results.md) | +| Spec | [2026-08-16-beta-change-design.md](../../superpowers/specs/2026-08-16-beta-change-design.md) | +| Plan | [2026-08-16-beta-change.md](../../superpowers/plans/2026-08-16-beta-change.md) | +| Results | [2026-08-16-beta-change-results.md](../../results/2026-08-16-beta-change-results.md) | diff --git a/internal/render/testdata/artifacts/block-spec-plan-results.relative.golden b/internal/render/testdata/artifacts/block-spec-plan-results.relative.golden index 31748858a..6dbf83b08 100644 --- a/internal/render/testdata/artifacts/block-spec-plan-results.relative.golden +++ b/internal/render/testdata/artifacts/block-spec-plan-results.relative.golden @@ -1,5 +1,5 @@ | Artifact | Link | |---|---| -| Spec | `docs/superpowers/specs/2026-08-16-beta-change-design.md` | -| Plan | `docs/superpowers/plans/2026-08-16-beta-change.md` | -| Results | `docs/results/2026-08-16-beta-change-results.md` | +| Spec | [2026-08-16-beta-change-design.md](../../superpowers/specs/2026-08-16-beta-change-design.md) | +| Plan | [2026-08-16-beta-change.md](../../superpowers/plans/2026-08-16-beta-change.md) | +| Results | [2026-08-16-beta-change-results.md](../../results/2026-08-16-beta-change-results.md) | From 2e2a7a805baf941a37ad5ca4025b814d4369dcba Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:33:19 -0400 Subject: [PATCH 12/28] refactor(finalize): no post-merge backlink commit to the integration branch Plan and results live on the metadata branch, so the archive transaction's retargetArtifactBacklinks already repoints every artifact backlink in the one close-out commit. The integration-ref legs are retired: closeoutBacklinkOp and runCloseoutBacklinkLeg (close-out), cleanupBacklinkOp and finalizeCleanupBacklinkRepair (cleanup), sweepAssessBacklinkLeg (sweep), the scoped backlinkArtifactLoader and backlinkLegDetail, backlinkLegRetarget / backlinkLegHasWork, OperationFinalizeCloseoutBacklink, and both final-backlink-pending reason constants. The PR-description backlink leg stays. finalize.cleanup no longer declares the metadata-write effect (it commits nothing). New guard internal/repoguard/integration_push_test.go (TestNoIntegrationPushOutsidePRMerge): every transaction.Request literal under internal/app must target MetadataBranchName, and every direct PushLease/PushCreateLease call must sit in an allowlisted function. Tests deleted, with reasons: - TestIntegrationFinalizeCloseoutBacklinkLegIgnoresUnrelatedCorpusErrors, TestIntegrationFinalizeCloseoutBacklinkPendingFindingNamesTheCause, TestIntegrationFinalizeCleanupBacklinkRepairIgnoresUnrelatedCorpusErrors: they pinned the scoped loader and pending finding of the retired integration-ref legs. - finalize_backlink_loader_test.go: its subject file is deleted. - TestAssessStaleBacklinkLegIsActionable, TestAssessMissingManifestWithStaleBacklinkIsActionable, TestAssessMalformedMarkersAreUnknownNeverNoWork: they assessed the retired sweep integration backlink leg. Converted: setupCloseoutFixture seeds plan/results on the metadata branch with relative backlinks; TestIntegrationFinalizeCloseoutBacklinkLegDocketMode became TestIntegrationFinalizeCloseoutArchiveRetargetsAllArtifactBacklinks (one metadata commit retargets spec/plan/results; origin/main stays the merge commit); matrixCloseout, TestIntegrationFinalizeStateBytePreservation and TestIntegrationFinalizeArchive* read plan/results on docket and assert no integration commit; the clean-tombstone sweep assessment now proves a stale integration-branch plan is no work; TestFinalLifecycleCodeSpellings drops the two retired constants; retired-vocabulary row 57 has no successor. --- internal/app/finalize_backlink_loader.go | 127 --------- internal/app/finalize_backlink_loader_test.go | 128 --------- internal/app/finalize_cleanup.go | 114 +------- .../app/finalize_cleanup_integration_test.go | 56 ---- internal/app/finalize_cleanup_test.go | 10 +- internal/app/finalize_closeout.go | 264 ++---------------- .../app/finalize_closeout_integration_test.go | 178 +++--------- internal/app/finalize_closeout_test.go | 46 +-- internal/app/finalize_git_test.go | 26 +- .../app/finalize_state_integration_test.go | 21 +- internal/app/maintenance_assess.go | 64 +---- internal/app/maintenance_assess_test.go | 101 ++----- internal/cli/finalize.go | 10 +- internal/cli/maintenance.go | 2 +- internal/repoguard/integration_push_test.go | 193 +++++++++++++ internal/repoguard/retired_vocabulary_test.go | 4 +- 16 files changed, 329 insertions(+), 1015 deletions(-) delete mode 100644 internal/app/finalize_backlink_loader.go delete mode 100644 internal/app/finalize_backlink_loader_test.go create mode 100644 internal/repoguard/integration_push_test.go diff --git a/internal/app/finalize_backlink_loader.go b/internal/app/finalize_backlink_loader.go deleted file mode 100644 index f6f978f68..000000000 --- a/internal/app/finalize_backlink_loader.go +++ /dev/null @@ -1,127 +0,0 @@ -package app - -import ( - "context" - "errors" - "fmt" - "strings" - - "github.com/danielhanold/docket/internal/document" - "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/repository/transaction" -) - -// backlinkArtifactLoader is the scoped StateLoader for the two integration-ref -// backlink legs (runCloseoutBacklinkLeg, finalizeCleanupBacklinkRepair). The -// legs patch only the docket:backlink block of merged plan/results artifacts — -// paths that are not corpus records — so validating the integration branch's -// full corpus (newPlanningLoader) was never the right gate: the integration -// branch legitimately holds a partial corpus, and a pre-existing error in a -// record the mutation cannot touch must not refuse the patch. This loader -// reads exactly the targeted artifact paths and reports a parse failure of a -// TARGETED artifact as the one in-scope error; an absent artifact is a clean -// skip (the operation's own benign no-op case). Snapshot is left zero-valued: -// both backlink operations read through st.Tree only and never consult -// st.State.Snapshot, and the legs declare no entity expectations. -type backlinkArtifactLoader struct { - paths []gitcli.RepoPath -} - -// newBacklinkArtifactLoader builds the scoped loader over every artifact path -// the targets patch. -func newBacklinkArtifactLoader(targets []closeoutBacklinkTarget) transaction.StateLoader { - var paths []gitcli.RepoPath - for _, tg := range targets { - for _, p := range tg.artifactPaths { - paths = append(paths, gitcli.RepoPath(p)) - } - } - return backlinkArtifactLoader{paths: paths} -} - -// Load reads the targeted artifacts through t. A tree/read failure is a Go -// error; a targeted artifact whose bytes fail document.Parse is an -// error-severity finding naming that path — the engine refuses on it at -// LoadBefore, and the leg's pending finding then carries the cause. -func (l backlinkArtifactLoader) Load(ctx context.Context, t transaction.Tree) (transaction.LoadedState, error) { - blobs, err := t.ReadBlobs(ctx, l.paths) - if err != nil { - return transaction.LoadedState{}, fmt.Errorf("backlink loader: reading artifacts: %w", err) - } - documents := make(map[string]document.Document, len(blobs)) - sources := make(map[string][]byte, len(blobs)) - var findings []domain.Finding - for _, b := range blobs { - if !b.Found { - continue // absent on the integration ref: the operation's benign skip - } - rel := string(b.Path) - doc, perr := document.Parse(b.Blob.Bytes) - if perr != nil { - findings = append(findings, backlinkParseFinding(rel, perr)) - continue - } - documents[rel] = doc - sources[rel] = append([]byte(nil), b.Blob.Bytes...) - } - return transaction.LoadedState{ - Report: domain.NewValidationReport(findings), - Documents: documents, - Sources: sources, - }, nil -} - -// ValidateEvolution: the corpus evolution rules govern records; the targeted -// plan/results artifacts are not records, so no before/after rule applies. -func (l backlinkArtifactLoader) ValidateEvolution(_, _ transaction.LoadedState) []domain.Finding { - return nil -} - -// backlinkParseFinding normalizes a document.Parse failure on a targeted -// artifact into an error-severity finding, mirroring planningParseFinding's -// code normalization; the entity kind is empty because the artifact is not a -// corpus record. -func backlinkParseFinding(rel string, err error) domain.Finding { - code := string(FCParseFailed) - var de *document.Error - if errors.As(err, &de) { - code = string(de.Kind) - } - return domain.Finding{ - Code: code, - Severity: domain.SeverityError, - Entity: domain.EntityRef{Path: rel}, - } -} - -// backlinkLegDetail renders the typed cause of a backlink leg that did not -// land, so the final-backlink-pending finding is self-diagnosing. A failed -// disposition renders the typed *transaction.Failure (stage/kind: detail); a -// refused disposition renders each refusal finding's code and path — after the -// scoped loader, the only refusals left are in-scope artifact-level ones. Any -// other disposition renders empty (the coarse token alone already says it). -func backlinkLegDetail(res transaction.Result, execErr error) string { - if fs := failureStatus(res, execErr); fs != nil { - out := fs.Kind - if fs.Stage != "" { - out = fs.Stage + "/" + fs.Kind - } - if fs.Detail != "" { - out += ": " + fs.Detail - } - return out - } - if res.Disposition == transaction.DispositionRefused && len(res.Findings) > 0 { - parts := make([]string, 0, len(res.Findings)) - for _, f := range res.Findings { - p := f.Code - if f.Entity.Path != "" { - p += " at " + f.Entity.Path - } - parts = append(parts, p) - } - return "refused: " + strings.Join(parts, "; ") - } - return "" -} diff --git a/internal/app/finalize_backlink_loader_test.go b/internal/app/finalize_backlink_loader_test.go deleted file mode 100644 index ff0c6ca7a..000000000 --- a/internal/app/finalize_backlink_loader_test.go +++ /dev/null @@ -1,128 +0,0 @@ -package app - -import ( - "context" - "strings" - "testing" - - "github.com/danielhanold/docket/internal/domain" - "github.com/danielhanold/docket/internal/gitcli" - "github.com/danielhanold/docket/internal/repository/transaction" -) - -// fakeBacklinkTree serves scripted blobs; a nil entry answers "absent". -type fakeBacklinkTree struct { - blobs map[string][]byte -} - -func (f fakeBacklinkTree) Revision() gitcli.Revision { return gitcli.Revision{} } - -func (f fakeBacklinkTree) ListTree(_ context.Context, _ []gitcli.RepoPath) ([]gitcli.TreeEntry, error) { - panic("backlink loader must not list the tree — it reads exact paths only") -} - -func (f fakeBacklinkTree) ReadBlobs(_ context.Context, paths []gitcli.RepoPath) ([]gitcli.BlobResult, error) { - out := make([]gitcli.BlobResult, len(paths)) - for i, p := range paths { - out[i].Path = p - if b, ok := f.blobs[string(p)]; ok { - out[i].Found = true - out[i].Blob.Bytes = append([]byte(nil), b...) - } - } - return out, nil -} - -const backlinkTestArtifact = "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + - "> line\n" + - "<!-- docket:backlink:end -->\n\n# Plan\n\nBody.\n" - -// A well-formed frontmatter file that fails document.Parse: unquoted colon-space -// in a scalar — the exact real-world trigger (ADR-0024 on docket's main). -const backlinkTestMalformed = "---\ntitle: uses `context: fork` dispatch\n---\n\n# Doc\n" - -func backlinkLoaderFor(paths ...string) transaction.StateLoader { - return newBacklinkArtifactLoader([]closeoutBacklinkTarget{{artifactPaths: paths, interior: "x"}}) -} - -// TestBacklinkLoaderScopedToArtifacts: the loader validates ONLY the targeted -// artifact paths — it never lists (fake panics on ListTree), a parse-clean -// artifact yields no error, and an absent artifact is a clean skip. -func TestBacklinkLoaderScopedToArtifacts(t *testing.T) { - tree := fakeBacklinkTree{blobs: map[string][]byte{ - "docs/superpowers/plans/p.md": []byte(backlinkTestArtifact), - }} - l := backlinkLoaderFor("docs/superpowers/plans/p.md", "docs/changes/results/r.md") - st, err := l.Load(context.Background(), tree) - if err != nil { - t.Fatalf("Load: %v", err) - } - if st.Report.HasErrors() { - t.Fatalf("clean artifacts reported errors: %+v", st.Report.Findings()) - } - if _, ok := st.Sources["docs/superpowers/plans/p.md"]; !ok { - t.Errorf("present artifact missing from Sources") - } - if _, ok := st.Sources["docs/changes/results/r.md"]; ok { - t.Errorf("absent artifact conjured Sources bytes") - } - if evo := l.ValidateEvolution(st, st); len(evo) != 0 { - t.Errorf("ValidateEvolution over artifacts returned findings: %+v", evo) - } -} - -// TestBacklinkLoaderRefusesOnTargetedParseFailure: a targeted artifact whose -// bytes fail document.Parse is an error-severity finding naming that path — -// the ONE in-scope condition the leg still gates on. -func TestBacklinkLoaderRefusesOnTargetedParseFailure(t *testing.T) { - tree := fakeBacklinkTree{blobs: map[string][]byte{ - "docs/superpowers/plans/p.md": []byte(backlinkTestMalformed), - }} - st, err := backlinkLoaderFor("docs/superpowers/plans/p.md").Load(context.Background(), tree) - if err != nil { - t.Fatalf("Load: %v", err) - } - if !st.Report.HasErrors() { - t.Fatalf("malformed targeted artifact did not report an error") - } - fds := st.Report.Findings() - found := false - for _, fd := range fds { - if fd.Entity.Path == "docs/superpowers/plans/p.md" && fd.Code != "" { - found = true - } - } - if !found { - t.Errorf("no finding names the malformed artifact path: %+v", fds) - } -} - -// TestBacklinkLegDetailRendersCauses: the shared renderer folds a refusal's -// findings (code + path) and a failure's typed stage/kind/detail into prose; -// a non-failed, non-refused result renders empty. -func TestBacklinkLegDetailRendersCauses(t *testing.T) { - refused := transaction.Result{Disposition: transaction.DispositionRefused} - refused.Findings = st0Findings() // helper below - got := backlinkLegDetail(refused, nil) - if !strings.Contains(got, "docs/adrs/0099-x.md") { - t.Errorf("refusal detail does not name the offending path: %q", got) - } - - failed := transaction.Result{Disposition: transaction.DispositionFailed} - ferr := &transaction.Failure{Stage: transaction.StagePush, Kind: transaction.KindExternal, Detail: "push rejected"} - got = backlinkLegDetail(failed, ferr) - if !strings.Contains(got, string(transaction.StagePush)) || !strings.Contains(got, "push rejected") { - t.Errorf("failure detail lost stage/detail: %q", got) - } - - if got := backlinkLegDetail(transaction.Result{Disposition: transaction.DispositionContended}, nil); got != "" { - t.Errorf("contended rendered a spurious detail: %q", got) - } -} - -func st0Findings() []domain.Finding { - return []domain.Finding{{ - Code: "parse-failed", Severity: domain.SeverityError, - Entity: domain.EntityRef{Path: "docs/adrs/0099-x.md"}, - }} -} diff --git a/internal/app/finalize_cleanup.go b/internal/app/finalize_cleanup.go index 4872530aa..93df101e9 100644 --- a/internal/app/finalize_cleanup.go +++ b/internal/app/finalize_cleanup.go @@ -10,7 +10,6 @@ import ( "github.com/danielhanold/docket/internal/domain" "github.com/danielhanold/docket/internal/gitcli" "github.com/danielhanold/docket/internal/githubcli" - "github.com/danielhanold/docket/internal/repository/transaction" "github.com/danielhanold/docket/internal/workspace" ) @@ -23,7 +22,7 @@ import ( // // `finalize cleanup` runs an ordered suffix over one final change: it reloads // the archived/stacked state and the verified merge destination; repairs the -// final backlinks (and the merged PR's description backlink) first when needed; +// merged PR's description backlink first when needed; // removes the feature checkout through the landed manifest-fact-driven // workspace.Cleanup (never a base recomputed from the now-archived record); deletes the LOCAL feature ref only when the exact // recorded tip is detached from every worktree AND contained in the verified @@ -110,7 +109,6 @@ const ( ReasonCleanupRemoteProbe = "remote-ref-probe-failed" ReasonCleanupRemoteMoved = "remote-ref-moved" ReasonCleanupLeaseRejected = "remote-lease-rejected" - ReasonCleanupBacklinkPending = "final-backlink-pending" ReasonCleanupReceiptRead = "receipt-read-failed" // gate cleanup reasons ReasonGateCleanupUnownable = "run-unownable" @@ -279,7 +277,7 @@ func finalizeCleanupDone(ctx context.Context, deps FinalizeDeps, cc *closeoutCon "the pull request is not merged; cleanup preserves the resources", id) } - // Leg 1b: repoint the merged PR's description backlink at the archived record + // Leg 1: repoint the merged PR's description backlink at the archived record // when close-out left it pending. Best-effort and independent: a failure is a // retryable pr-backlink-pending finding and never blocks the other legs. It is // non-destructive and needs only the PR number and the archived record, so it @@ -313,13 +311,6 @@ func finalizeCleanupDone(ctx context.Context, deps FinalizeDeps, cc *closeoutCon var removed []string - // Leg 1: repair the final backlinks when needed (docket mode). A - // failed/contended leg is a pending finding; it never blocks the independent - // ref-deletion legs. - if f := finalizeCleanupBacklinkRepair(ctx, deps, cc, facts); f != nil { - findings = append(findings, *f) - } - // Leg 2: remove the feature checkout through the landed manifest-fact-driven // Cleanup. A blocked workspace or an unanswerable inspection retains the // workspace and — because the branch may still be checked out — skips the ref @@ -414,107 +405,6 @@ func finalizeCleanupPRBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc return prBacklinkFinding(id, number, repointPRBacklink(ctx, ed, ghRepo, number, cc.change.Path(), interior)) } -// finalizeCleanupBacklinkRepair re-runs the integration-ref backlink retarget -// idempotently. In the normal flow (closeout already retargeted the blocks) it -// is a clean no-op; when closeout left the leg pending it lands the exact -// generated-only patch. A failed/contended leg is a retryable -// final-backlink-pending finding — the change stays truthfully done. -func finalizeCleanupBacklinkRepair(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, facts githubcli.MergedFacts) *StatusFinding { - archiveDate, ok := archiveDateFromMerge(facts.MergedAtUTC) - if !ok { - return nil - } - targets := []closeoutTarget{{ - id: int(cc.change.ID()), activePath: cc.change.Path(), slug: cc.change.Slug(), archivePath: cc.change.Path(), - }} - backlinkTargets, err := closeoutBacklinkTargets(cc, targets) - if err != nil { - f := cleanupWarning(ReasonCleanupBacklinkPending, "the final backlink interior could not be rendered; retry cleanup") - return &f - } - if len(backlinkTargets) == 0 { - return nil - } - op := cleanupBacklinkOp{rootID: int(cc.change.ID()), archiveDate: archiveDate, targets: backlinkTargets} - res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ - Repository: cc.repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + cc.integrationBranch), - Loader: newBacklinkArtifactLoader(backlinkTargets), - Operation: op, - }) - // Best-effort secondary leg: the change stays truthfully done and the sweep - // retries. The finding carries the transaction's typed cause — the failure's - // stage/kind/detail, or a refusal's finding codes and paths — so a stuck leg - // is self-diagnosing (change 0337); after the scoped loader, an in-scope - // artifact-level problem is the only refusal left, and this names it. - result, _ := mapOutcome(res, execErr, ResultInvalidState) - if result == ResultApplied || result == ResultNoOp { - return nil - } - msg := "the integration-ref backlink leg did not land (" + string(result) + ")" - if d := backlinkLegDetail(res, execErr); d != "" { - msg += ": " + d - } - msg += "; the sweep will retry it" - f := cleanupWarning(ReasonCleanupBacklinkPending, msg) - return &f -} - -// cleanupBacklinkOp patches the docket:backlink block of each merged -// plan/results artifact on the integration ref to point at the archive path. It -// mirrors closeoutBacklinkOp but returns a VALID no-op plan (a commit subject on -// an empty file set) so an already-retargeted replay reaches the engine's -// empty-plan no-op path rather than an empty-commit-subject refusal — the -// idempotent case a standalone cleanup replay exercises that closeout never does. -type cleanupBacklinkOp struct { - rootID int - archiveDate string - targets []closeoutBacklinkTarget -} - -func (o cleanupBacklinkOp) Key() transaction.OperationKey { - return transaction.OperationKey(OperationFinalizeCloseoutBacklink) -} - -func (o cleanupBacklinkOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { - var files []transaction.FileMutation - for _, tg := range o.targets { - for _, p := range tg.artifactPaths { - original, present, err := readTreeBlob(ctx, st.Tree, p) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, err - } - if !present { - continue - } - // The byte comparison lives once in backlinkLegRetarget; a missing block - // is never conjured and already-retargeted bytes are a no-op. - updated, hasBlock, changed, err := backlinkLegRetarget(original, tg.interior) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, err - } - if !hasBlock || !changed { - continue - } - files = append(files, transaction.FileMutation{Path: gitcli.RepoPath(p), Kind: transaction.MutationReplace, Bytes: updated}) - } - } - subject := "change " + itoa(o.rootID) + " final backlinks verified (cleanup)" - receipt, err := json.Marshal(closeoutBacklinkReceipt{ArchiveDate: o.archiveDate, Op: OperationFinalizeCloseoutBacklink, Root: o.rootID}) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, err - } - if len(files) == 0 { - // A valid no-op plan carries a commit subject AND a receipt (validatePlan - // requires both before the engine's len(Files)==0 no-op branch is reached), - // so an already-retargeted replay is a clean no-op rather than an - // empty-subject/empty-receipt refusal. - return transaction.MutationPlan{CommitSubject: subject, Receipt: receipt}, transaction.OperationResult{}, nil - } - return transaction.MutationPlan{Files: files, CommitSubject: subject, Receipt: receipt}, transaction.OperationResult{}, nil -} - // finalizeCleanupWorkspace removes the feature checkout through workspace.Cleanup. // It returns clean=true when the checkout is gone (cleaned or an already-clean // tombstone) and clean=false with a retryable finding when the base is diff --git a/internal/app/finalize_cleanup_integration_test.go b/internal/app/finalize_cleanup_integration_test.go index 074a26efe..f7a37defa 100644 --- a/internal/app/finalize_cleanup_integration_test.go +++ b/internal/app/finalize_cleanup_integration_test.go @@ -14,62 +14,6 @@ import ( "testing" ) -// TestCleanupBacklinkRepairIgnoresUnrelatedCorpusErrors is the 0337 sweep -// self-heal proof: a change closed out while the bug was live left stale -// active-path backlinks on the integration branch, which ALSO carries an -// unrelated malformed corpus record. The next cleanup must land the retarget -// anyway — the repair leg's gate is scoped to the artifacts it patches. -func TestIntegrationFinalizeCleanupBacklinkRepairIgnoresUnrelatedCorpusErrors(t *testing.T) { - requireRealGit(t) - f := setupCloseoutFixture(t, planRepoModeDocket()) - head, mergeCommit := f.archiveClosed(t) - - // archiveClosed advanced origin/main via the closeout backlink leg, leaving - // the writer clone's local main behind; sync it so the fixture advance below - // fast-forwards rather than being rejected as non-fast-forward. - runGit(t, f.repo.writer, "fetch", "-q", "origin", "main") - runGit(t, f.repo.writer, "reset", "--hard", "origin/main") - - // Recreate the stuck state on the integration branch: revert both artifacts - // to stale active-path backlinks AND plant the unrelated malformed record. - recPath := groomPath(f.id, f.slug) - f.repo.writerAdvance(t, "main", map[string]string{ - f.planPath: artifactWithBacklink(recPath, "Plan", "The widget plan."), - f.resultsPath: artifactWithBacklink(recPath, "Results", "The widget results."), - "docs/adrs/0099-malformed.md": "---\n" + - "id: 99\n" + - "title: uses `context: fork` dispatch\n" + - "status: Accepted\n" + - "date: 2026-08-22\n" + - "---\n\n# 99. Malformed on purpose\n", - }) - - gh := f.mergedCleanupFake(head, mergeCommit) - res := FinalizeCleanup(context.Background(), f.cleanupDeps(gh, f.deps.Client, f.svc), f.repo.invocation, f.id) - for _, fd := range res.Findings { - if fd.Code == ReasonCleanupBacklinkPending { - t.Fatalf("unrelated corpus error refused the cleanup repair leg: %+v", fd) - } - } - if res.Disposition != CleanupDispCleaned { - t.Fatalf("cleanup disposition = %q (reason %q msg %q), want %q", - res.Disposition, res.Reason, res.Message, CleanupDispCleaned) - } - // The stale backlinks were re-pointed at the archive path. - for _, p := range []string{f.planPath, f.resultsPath} { - got, ok := originFile(t, f.repo.origin, "main", p) - if !ok { - t.Fatalf("artifact %q vanished from main", p) - } - if strings.Contains(got, recPath) { - t.Errorf("artifact %q still backlinks the stale active path %q:\n%s", p, recPath, got) - } - if !strings.Contains(got, "docs/changes/archive/") { - t.Errorf("artifact %q does not backlink an archive path:\n%s", p, got) - } - } -} - func TestIntegrationFinalizeCleanupBranchDeletion(t *testing.T) { requireRealGit(t) diff --git a/internal/app/finalize_cleanup_test.go b/internal/app/finalize_cleanup_test.go index 4182fe77d..b33413c50 100644 --- a/internal/app/finalize_cleanup_test.go +++ b/internal/app/finalize_cleanup_test.go @@ -188,8 +188,6 @@ func (f *closeoutFixture) remoteBranchPresent(t *testing.T) bool { // constant names. func TestFinalLifecycleCodeSpellings(t *testing.T) { for _, c := range []struct{ name, got, want string }{ - {"ReasonCloseoutBacklinkPending", string(ReasonCloseoutBacklinkPending), "final-backlink-pending"}, - {"ReasonCleanupBacklinkPending", string(ReasonCleanupBacklinkPending), "final-backlink-pending"}, {"ReasonCloseoutNotesFrozen", string(ReasonCloseoutNotesFrozen), "final-notes-frozen"}, {"ReasonCleanupNotFinal", string(ReasonCleanupNotFinal), "not-final"}, {"ReasonCleanupKilledRetained", string(ReasonCleanupKilledRetained), "killed-retained"}, @@ -349,9 +347,9 @@ func gateRunLogsPresent(t *testing.T, dir string) bool { return err == nil } -// planRepoModeDocket returns the docket metadata mode (the mode where the plan -// and results live on a genuinely different integration ref, exercising the -// backlink-repair leg). +// planRepoModeDocket returns the docket metadata mode, the one supported +// topology, where the metadata branch is a genuinely different ref from the +// integration branch. func planRepoModeDocket() planRepoMode { for _, m := range planRepoModes() { if m.name == "docket" { @@ -361,8 +359,6 @@ func planRepoModeDocket() planRepoMode { panic("docket mode not found") } -// --- TestCleanupBacklinkRepairIgnoresUnrelatedCorpusErrors ---------------- - // TestCleanupHumanTextNamesWorkspaceRemnant: the human one-liner of a cleaned // result must still name a workspace-remnant warning and its leftover path. func TestCleanupHumanTextNamesWorkspaceRemnant(t *testing.T) { diff --git a/internal/app/finalize_closeout.go b/internal/app/finalize_closeout.go index 92382d91a..288a2c6f1 100644 --- a/internal/app/finalize_closeout.go +++ b/internal/app/finalize_closeout.go @@ -58,28 +58,20 @@ import ( // renders one board over the final population. A single unproven descendant // keeps the whole root recoverable — zero descendant writes. // -// Backlinks across the one topology: the metadata transaction lands first and -// retargets every backlink block resident ON the metadata ref (the spec). The -// merged plan and results live on the integration ref, so a FOLLOW-UP isolated -// integration-ref transaction patches only their existing docket:backlink blocks -// under the integration ref's exact lease. That second leg is generated-link maintenance, -// not terminal publishing: it copies no metadata record and edits no authored -// bytes. A failed or contended second leg leaves the change truthfully `done` and -// emits a typed final-backlink-pending finding; its idempotency is keyed on the -// remote block bytes (a block already pointing at the archive path is a no-op). A -// third, GitHub-side leg repoints each archived target's merged PR description -// block at its archive path (runCloseoutPRBacklinkLeg); a failed edit leaves the -// change truthfully `done` with a pr-backlink-pending warning that cleanup and -// the sweep retry. +// Backlinks: every spec, plan, and results file lives on the metadata branch, +// so the one metadata transaction retargets every backlink block resident ON the +// metadata ref (retargetArtifactBacklinks) — nothing is ever committed to the +// integration branch after the merge. A legacy plan or results file that rode an +// earlier PR onto the integration branch keeps the backlink it had. A second, +// GitHub-side leg repoints each archived target's merged PR description block at +// its archive path (runCloseoutPRBacklinkLeg); a failed edit leaves the change +// truthfully `done` with a pr-backlink-pending warning that cleanup and the sweep +// retry. // OperationFinalizeCloseout is the operation key the metadata closeout // transaction records in its result envelope and trailer. const OperationFinalizeCloseout = "finalize.closeout" -// OperationFinalizeCloseoutBacklink is the operation key the docket-mode -// integration-ref backlink leg records in its trailer. -const OperationFinalizeCloseoutBacklink = "finalize.closeout-backlink" - // The closed set of `finalize closeout` dispositions. const ( // CloseoutDispDoneArchived: the change was marked done and relocated to the @@ -146,10 +138,6 @@ const ( // ReasonCloseoutChildUnproven: a descendant's carry into the root could not be // proven; the root stays recoverable. ReasonCloseoutChildUnproven = "descendant-carry-unproven" - // ReasonCloseoutBacklinkPending: the metadata transaction landed (the change is - // done) but the follow-up integration-ref backlink leg did not; a retryable - // health/maintenance finding. - ReasonCloseoutBacklinkPending = "final-backlink-pending" // ReasonCloseoutNotesFrozen: the change is already final (or stacked-merged in // place) and the request // carries notes that differ from the archived record; refused — an archived @@ -161,7 +149,7 @@ const ( ReasonCloseoutStackedUnpreserved = "stacked-merge-not-preserved" ) -// closeoutBacklinkArtifactHeadings is the marker heading set the closeout record +// closeoutBlockedHeadingSet is the marker heading set the closeout record // splice manages when it clears a stale finalize-blocked section from a record it // is terminating. It reuses finalize_block.go's finalizeBlockedSectionHeading. var closeoutBlockedHeadingSet = []string{finalizeBlockedSectionHeading} @@ -170,7 +158,7 @@ var closeoutBlockedHeadingSet = []string{finalizeBlockedSectionHeading} // names identity, the closed disposition, the root archive path and any carried // descendant ids on a final archive, and — on a refusal — a stable reason and // message. Findings carries validation diagnostics and the retryable -// final-backlink-pending finding. It leaks no authored artifact bytes. +// pr-backlink-pending warning. It leaks no authored artifact bytes. type CloseoutResult struct { Envelope ID int `json:"id,omitempty"` @@ -228,15 +216,6 @@ type closeoutReceipt struct { Root int `json:"root"` } -// closeoutBacklinkReceipt is the receipt the docket-mode integration-ref backlink -// leg persists — the stable closeout request trailer that keys a lost-response -// recovery alongside the remote block bytes. -type closeoutBacklinkReceipt struct { - ArchiveDate string `json:"archive_date"` - Op string `json:"op"` - Root int `json:"root"` -} - // closeoutTarget is one change the closeout transaction terminates: its resolved // id, its active record path, its slug, and the archive path it relocates to. type closeoutTarget struct { @@ -249,8 +228,8 @@ type closeoutTarget struct { // FinalizeCloseout reloads the metadata, reprobes the recorded PR and its merge // destination, and applies the one verified closeout shape the destination // selects. It never asserts done without a merge-commit reachability proof, never -// leaves a remotely partial metadata outcome, and (in docket mode) retargets the -// merged plan/results backlinks in an isolated retryable follow-up leg. +// leaves a remotely partial metadata outcome, and repoints each archived +// target's merged PR description backlink in a retryable follow-up leg. func FinalizeCloseout(ctx context.Context, deps FinalizeDeps, repoDir string, id int, notes CloseoutNotes) CloseoutResult { if id <= 0 { return newCloseoutResult(ResultInvalidInput, CloseoutResult{ @@ -496,7 +475,7 @@ func reprobeMerged(ctx context.Context, deps FinalizeDeps, ghRepo githubcli.Repo // closeoutIntegrationDestination handles a merge whose destination is the // integration branch: it proves reachability, derives the stack-root closeout // set, and drives the archive transaction (ordinary or root carry) plus the -// docket-mode backlink leg. +// PR-description backlink leg. func closeoutIntegrationDestination(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, ghRepo githubcli.Repository, canonicalN int, facts githubcli.MergedFacts, notes CloseoutNotes) CloseoutResult { id := int(cc.change.ID()) @@ -617,12 +596,6 @@ func closeoutIntegrationDestination(ctx context.Context, deps FinalizeDeps, cc * return res } - // Follow-up: retarget the merged plan/results backlinks on the integration - // ref. A failed/contended leg leaves the change truthfully done and emits a - // retryable finding. - if finding := runCloseoutBacklinkLeg(ctx, deps, cc, targets, archiveDate); finding != nil { - res.Findings = append(res.Findings, *finding) - } // Follow-up: repoint each archived target's merged PR backlink block at its // archive path (a GitHub edit, best-effort). A failed edit leaves the change // truthfully done and emits a retryable pr-backlink-pending warning. @@ -861,55 +834,9 @@ func runCloseoutArchiveTransaction(ctx context.Context, deps FinalizeDeps, cc *c return r } -// runCloseoutBacklinkLeg retargets the merged plan/results backlinks on the -// integration ref in docket mode. It returns a final-backlink-pending finding -// when the leg did not land — the change stays truthfully done and the sweep -// retries the leg — or nil when the leg landed (or had nothing to do). -func runCloseoutBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeoutContext, targets []closeoutTarget, archiveDate string) *StatusFinding { - backlinkTargets, err := closeoutBacklinkTargets(cc, targets) - if err != nil { - return &StatusFinding{Code: ReasonCloseoutBacklinkPending, Severity: string(domain.SeverityWarning), Message: err.Error()} - } - if len(backlinkTargets) == 0 { - return nil - } - - op := closeoutBacklinkOp{ - rootID: cc.rootIDOf(targets), - archiveDate: archiveDate, - targets: backlinkTargets, - } - res, execErr := deps.Planning.Engine.Execute(ctx, transaction.Request{ - Repository: cc.repo, - Remote: originRemote, - TargetRef: gitcli.RefName(branchRefPrefix + cc.integrationBranch), - Loader: newBacklinkArtifactLoader(backlinkTargets), - Operation: op, - }) - // Best-effort secondary leg: the change stays truthfully done and the sweep - // retries. The finding carries the transaction's typed cause — the failure's - // stage/kind/detail, or a refusal's finding codes and paths — so a stuck leg - // is self-diagnosing (change 0337); after the scoped loader, an in-scope - // artifact-level problem is the only refusal left, and this names it. - result, _ := mapOutcome(res, execErr, ResultInvalidState) - if result == ResultApplied || result == ResultNoOp { - return nil - } - msg := fmt.Sprintf("the change is done, but the integration-ref backlink leg did not land (%s)", result) - if d := backlinkLegDetail(res, execErr); d != "" { - msg += ": " + d - } - msg += "; the sweep will retry it" - return &StatusFinding{ - Code: ReasonCloseoutBacklinkPending, - Severity: string(domain.SeverityWarning), - Message: msg, - } -} - // runCloseoutPRBacklinkLeg repoints, for every archived target (the root and // each carried descendant), its own merged PR's docket:backlink block at its own -// archive path. It is independent of the integration-ref backlink leg and never +// 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. @@ -945,55 +872,13 @@ func runCloseoutPRBacklinkLeg(ctx context.Context, deps FinalizeDeps, cc *closeo return out } -// rootIDOf returns the root target's id. -func (cc *closeoutContext) rootIDOf(targets []closeoutTarget) int { - if len(targets) > 0 { - return targets[0].id - } - return 0 -} - -// closeoutBacklinkTargets renders, per target, the archived backlink interior and -// the plan/results artifact paths that carry a backlink block. It is computed in -// the app layer so the integration-ref operation carries only the exact interior -// bytes and the exact paths, never a metadata record. -func closeoutBacklinkTargets(cc *closeoutContext, targets []closeoutTarget) ([]closeoutBacklinkTarget, error) { - out := make([]closeoutBacklinkTarget, 0, len(targets)) - for _, tg := range targets { - c, out2 := cc.snap.Change(domain.ChangeID(tg.id)) - if out2 != domain.LookupFound { - continue - } - var paths []string - if p := c.Plan().Value; p != "" { - paths = append(paths, p) - } - if p := c.Results().Value; p != "" { - paths = append(paths, p) - } - if len(paths) == 0 { - continue - } - src, ok := cc.sources[tg.activePath] - if !ok { - return nil, fmt.Errorf("closeout backlink: no source bytes for change %04d at %q", tg.id, tg.activePath) - } - interior, err := archivedBacklinkInterior(cc.eff, tg.archivePath, src, cc.link) - if err != nil { - return nil, err - } - out = append(out, closeoutBacklinkTarget{artifactPaths: paths, interior: interior}) - } - return out, nil -} - // archivedBacklinkInterior renders the docket:backlink interior a merged // artifact must carry after closeout: it points at the change's ARCHIVE path. It // builds a one-record snapshot at the archive path from the record bytes already // in hand so render.BacklinkContent renders the canonical line. The line is the -// absolute metadata-branch form: a legacy merged artifact on the integration -// branch and a PR description have no metadata record beside them to link -// relatively (the metadata-ref retarget uses render.ArtifactBacklinkContent). +// absolute metadata-branch form: a PR description has no metadata record beside +// it to link relatively (the metadata-ref retarget uses +// render.ArtifactBacklinkContent). func archivedBacklinkInterior(eff config.Effective, archivePath string, srcBytes []byte, link render.LinkContext) (string, error) { doc, err := document.Parse(srcBytes) if err != nil { @@ -1020,14 +905,6 @@ func archivedBacklinkInterior(eff config.Effective, archivePath string, srcBytes return "", fmt.Errorf("closeout backlink: archived record absent from its own one-record snapshot (%q)", archivePath) } -// closeoutBacklinkTarget is one change's share of the integration-ref backlink -// leg: the exact plan/results paths to patch and the exact backlink interior to -// write into each. -type closeoutBacklinkTarget struct { - artifactPaths []string - interior string -} - // archiveDateFromMerge parses an RFC3339 mergedAt into a UTC YYYY-MM-DD date. func archiveDateFromMerge(mergedAt string) (string, bool) { tm, err := time.Parse(time.RFC3339, mergedAt) @@ -1259,111 +1136,6 @@ func (o closeoutStackedOp) Plan(ctx context.Context, st transaction.AttemptState }, transaction.OperationResult{}, nil } -// --- closeoutBacklinkOp (integration-ref leg) ----------------------------- - -// closeoutBacklinkOp patches the docket:backlink block of each merged plan/results -// artifact on the integration ref to point at the archive path. It declares a -// file only when its block actually changes, so a replay against already-retargeted -// remote bytes is an empty-plan no-op (idempotency keyed on the promised state). -type closeoutBacklinkOp struct { - rootID int - archiveDate string - targets []closeoutBacklinkTarget -} - -func (o closeoutBacklinkOp) Key() transaction.OperationKey { - return transaction.OperationKey(OperationFinalizeCloseoutBacklink) -} - -func (o closeoutBacklinkOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { - var files []transaction.FileMutation - for _, tg := range o.targets { - for _, p := range tg.artifactPaths { - original, present, err := readTreeBlob(ctx, st.Tree, p) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, err - } - if !present { - // The merged artifact is not on the integration ref; nothing to patch. - continue - } - // The "would the final backlink block's bytes change" computation is - // the shared backlinkLegRetarget: a missing block is never conjured, and - // already-retargeted bytes are a no-op (the promised state). - updated, hasBlock, changed, err := backlinkLegRetarget(original, tg.interior) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("closeout backlink: retargeting artifact %q: %w", p, err) - } - if !hasBlock || !changed { - continue - } - files = append(files, transaction.FileMutation{Path: gitcli.RepoPath(p), Kind: transaction.MutationReplace, Bytes: updated}) - } - } - if len(files) == 0 { - return transaction.MutationPlan{}, transaction.OperationResult{}, nil - } - receipt, err := json.Marshal(closeoutBacklinkReceipt{ArchiveDate: o.archiveDate, Op: OperationFinalizeCloseoutBacklink, Root: o.rootID}) - if err != nil { - return transaction.MutationPlan{}, transaction.OperationResult{}, fmt.Errorf("closeout backlink: encoding receipt: %w", err) - } - return transaction.MutationPlan{ - Files: files, - CommitSubject: fmt.Sprintf("change %04d final backlinks retargeted to archive", o.rootID), - Receipt: receipt, - }, transaction.OperationResult{}, nil -} - -// backlinkLegRetarget is the single source of the per-artifact "would the final -// backlink block's bytes change" computation the final backlink legs share: the -// cleanup transaction (cleanupBacklinkOp.Plan), the closeout follow-up leg -// (closeoutBacklinkOp.Plan), and the maintenance sweep's snapshot assessment -// (backlinkLegHasWork) all read it, so no copy of the byte comparison drifts. It -// parses one artifact blob, retargets its docket:backlink block interior to -// renderedInterior, and reports: -// -// - present — whether the blob carries a managed docket:backlink block at all; a -// blob without one is never conjured a block, so present=false is "nothing to -// retarget", not an error; -// - changed — whether applying renderedInterior produces different bytes (false -// is the idempotent already-retargeted no-op keyed on the promised state); -// - updated — the retargeted bytes, valid only when present && changed. -// -// A parse or patch failure — malformed, unbalanced, or nested backlink markers, or -// an unrenderable interior — is a hard error, NEVER a silent no-work -// (learning probe-error-is-not-clean-absence): the assessment maps it to an -// unresolved leg and the transactions to a refusal, so a broken block is never -// mistaken for an already-clean one. -func backlinkLegRetarget(blob []byte, renderedInterior string) (updated []byte, present, changed bool, err error) { - doc, err := document.Parse(blob) - if err != nil { - return nil, false, false, err - } - if _, ok := doc.Block(backlinkBlockName); !ok { - return nil, false, false, nil - } - var ps document.PatchSet - ps.ReplaceBlock(backlinkBlockName, renderedInterior) - out, err := doc.Apply(ps) - if err != nil { - return nil, true, false, err - } - return out, true, string(out) != string(blob), nil -} - -// backlinkLegHasWork reports whether retargeting one artifact blob's -// docket:backlink block to renderedInterior would change its bytes — the sweep -// assessment's read of backlinkLegRetarget. A blob with no managed block is no -// work; already-retargeted bytes are no work; a malformed/unbalanced block is an -// error (an unresolved leg), never a false "no work". -func backlinkLegHasWork(blob []byte, renderedInterior string) (bool, error) { - _, present, changed, err := backlinkLegRetarget(blob, renderedInterior) - if err != nil { - return false, err - } - return present && changed, nil -} - // --- shared helpers ------------------------------------------------------- // relocation is one record's move from an active path to an archive path with diff --git a/internal/app/finalize_closeout_integration_test.go b/internal/app/finalize_closeout_integration_test.go index 020feca9b..9747c5e3f 100644 --- a/internal/app/finalize_closeout_integration_test.go +++ b/internal/app/finalize_closeout_integration_test.go @@ -6,7 +6,7 @@ import ( "context" "errors" "path/filepath" - "sort" + "slices" "strings" "testing" @@ -14,147 +14,52 @@ import ( "github.com/danielhanold/docket/internal/githubcli" ) -// TestCloseoutBacklinkLegDocketMode proves the docket-mode split: the metadata -// transaction lands the archived record + spec + board on the metadata ref, and a -// SEPARATE isolated integration-ref commit patches only the merged plan/results -// backlinks — never a metadata record, never an authored byte. -func TestIntegrationFinalizeCloseoutBacklinkLegDocketMode(t *testing.T) { +// TestIntegrationFinalizeCloseoutArchiveRetargetsAllArtifactBacklinks proves the +// one-commit close-out: the single archive commit on the metadata ref retargets +// the spec, plan, and results backlinks at the archived record, and the +// integration ref's tip after close-out is still the merge commit — nothing is +// committed to the integration branch after the merge. +func TestIntegrationFinalizeCloseoutArchiveRetargetsAllArtifactBacklinks(t *testing.T) { requireRealGit(t) - m := planRepoModes()[0] // docket - f := setupCloseoutFixture(t, m) + f := setupCloseoutFixture(t, planRepoModeDocket()) mergeCommit := f.mergeIntoBase(t) gh := f.baselineMergedFake(f.head, mergeCommit) - metaBefore := originTip(t, f.repo.origin, "docket") - mainBefore := originTip(t, f.repo.origin, "main") res := FinalizeCloseout(context.Background(), f.closeoutDeps(gh), f.repo.invocation, f.id, CloseoutNotes{}) if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { t.Fatalf("closeout = %q disp %q (reason %q)", res.Result, res.Disposition, res.Reason) } - // No final-backlink-pending finding: the leg landed. - for _, fd := range res.Findings { - if fd.Code == ReasonCloseoutBacklinkPending { - t.Fatalf("the backlink leg did not land: %+v", fd) - } - } - // Both refs advanced, on separate commits. - metaAfter := originTip(t, f.repo.origin, "docket") - mainAfter := originTip(t, f.repo.origin, "main") - if metaAfter == metaBefore { - t.Errorf("the metadata ref did not advance") - } - if mainAfter == mainBefore { - t.Errorf("the integration ref did not advance (no backlink leg)") - } - - // The integration-ref commit touched ONLY the merged plan/results — no metadata - // record crossed onto the integration branch. - got := originCommitPaths(t, f.repo.origin, mainAfter) - want := []string{f.planPath, f.resultsPath} - sort.Strings(want) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("integration-ref commit changed %v, want exactly %v", got, want) + // No integration commit: main's tip is exactly the merge commit. + if tip := originTip(t, f.repo.origin, "main"); tip != mergeCommit { + t.Errorf("origin/main = %q after close-out, want the merge commit %q (no integration commit)", tip, mergeCommit) } -} - -// TestCloseoutBacklinkLegIgnoresUnrelatedCorpusErrors is the 0337 regression: -// the integration branch carries a pre-existing corpus record the mutation -// never touches whose bytes fail document.Parse (an ADR with an unquoted -// colon-space title — the live ADR-0024 trigger). The backlink-only patch must -// LAND anyway: the leg's gate is scoped to the artifacts it patches, not the -// health of the integration branch's partial corpus. -func TestIntegrationFinalizeCloseoutBacklinkLegIgnoresUnrelatedCorpusErrors(t *testing.T) { - requireRealGit(t) - f := setupCloseoutFixture(t, planRepoModeDocket()) - // Pre-existing, mutation-unrelated corpus error on the integration branch. - f.repo.writerAdvance(t, "main", map[string]string{ - "docs/adrs/0099-malformed.md": "---\n" + - "id: 99\n" + - "title: uses `context: fork` dispatch\n" + - "status: Accepted\n" + - "date: 2026-08-22\n" + - "---\n\n# 99. Malformed on purpose\n", - }) - mergeCommit := f.mergeIntoBase(t) - gh := f.baselineMergedFake(f.head, mergeCommit) - mainBefore := originTip(t, f.repo.origin, "main") - res := FinalizeCloseout(context.Background(), f.closeoutDeps(gh), f.repo.invocation, f.id, CloseoutNotes{}) - if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { - t.Fatalf("closeout = %q disp %q (reason %q)", res.Result, res.Disposition, res.Reason) + // Exactly one metadata commit, and it carries the archive move plus every + // artifact backlink retarget. + metaAfter := originTip(t, f.repo.origin, "docket") + if parent := runGit(t, f.repo.origin, "rev-parse", metaAfter+"^"); parent != metaBefore { + t.Errorf("close-out landed more than one metadata commit: %q^ = %q, want %q", metaAfter, parent, metaBefore) } - // The leg LANDED: no pending finding, and the integration ref advanced. - for _, fd := range res.Findings { - if fd.Code == ReasonCloseoutBacklinkPending { - t.Fatalf("unrelated corpus error refused the backlink leg: %+v", fd) + paths := originCommitPaths(t, f.repo.origin, metaAfter) + for _, want := range []string{groomPath(f.id, f.slug), res.ArchivePath, f.specPath, f.planPath, f.resultsPath} { + if !slices.Contains(paths, want) { + t.Errorf("the archive commit did not touch %q; it changed %v", want, paths) } } - mainAfter := originTip(t, f.repo.origin, "main") - if mainAfter == mainBefore { - t.Fatalf("the integration ref did not advance (no backlink leg)") - } - // The leg's commit touched exactly the plan/results — the malformed record - // and every other corpus byte are untouched. - got := originCommitPaths(t, f.repo.origin, mainAfter) - want := []string{f.planPath, f.resultsPath} - sort.Strings(want) - if strings.Join(got, ",") != strings.Join(want, ",") { - t.Errorf("integration-ref commit changed %v, want exactly %v", got, want) - } - // The retarget itself happened: the plan now backlinks the archive path. - plan, ok := originFile(t, f.repo.origin, "main", f.planPath) - if !ok { - t.Fatalf("plan artifact vanished from main") - } - if !strings.Contains(plan, res.ArchivePath) { - t.Errorf("plan backlink does not point at the archive path %q:\n%s", res.ArchivePath, plan) - } - if !strings.Contains(plan, "# Plan\n\nThe widget plan.") { - t.Errorf("authored plan body disturbed:\n%s", plan) - } -} - -// TestCloseoutBacklinkPendingFindingNamesTheCause is the 0337 diagnosability -// proof (spec D): when the leg still cannot land — here an IN-SCOPE failure, -// the targeted plan artifact's own bytes fail document.Parse — the -// final-backlink-pending finding carries the typed cause (the offending -// artifact path), never a bare coarse token. The change itself still closes -// out done+archived: the leg stays best-effort. -func TestIntegrationFinalizeCloseoutBacklinkPendingFindingNamesTheCause(t *testing.T) { - requireRealGit(t) - f := setupCloseoutFixture(t, planRepoModeDocket()) - // Corrupt the targeted plan artifact on the integration branch: malformed - // frontmatter fails document.Parse, an in-scope condition even after the - // gate is scoped to the patched artifacts. - f.repo.writerAdvance(t, "main", map[string]string{ - f.planPath: "---\ntitle: uses `context: fork` dispatch\n---\n\n" + - artifactWithBacklink(groomPath(f.id, f.slug), "Plan", "The widget plan."), - }) - mergeCommit := f.mergeIntoBase(t) - gh := f.baselineMergedFake(f.head, mergeCommit) - - res := FinalizeCloseout(context.Background(), f.closeoutDeps(gh), f.repo.invocation, f.id, CloseoutNotes{}) - if res.Result != ResultApplied || res.Disposition != CloseoutDispDoneArchived { - t.Fatalf("closeout = %q disp %q (reason %q)", res.Result, res.Disposition, res.Reason) - } - var pending *StatusFinding - for i, fd := range res.Findings { - if fd.Code == ReasonCloseoutBacklinkPending { - pending = &res.Findings[i] + for _, p := range []string{f.specPath, f.planPath, f.resultsPath} { + got, ok := originFile(t, f.repo.origin, "docket", p) + if !ok { + t.Fatalf("artifact %q vanished from the metadata branch", p) + } + rel, err := filepath.Rel(filepath.Dir(p), res.ArchivePath) + if err != nil { + t.Fatalf("relative archive path: %v", err) + } + if !strings.Contains(got, "]("+filepath.ToSlash(rel)+")**") || strings.Contains(got, "active/") { + t.Errorf("artifact %q does not backlink the archived record only:\n%s", p, got) } - } - if pending == nil { - t.Fatalf("an in-scope malformed artifact did not leave the leg pending: %+v", res.Findings) - } - // The finding names the cause: the exact offending artifact path, and the - // typed detail separator — proof it carries more than the old coarse token. - if !strings.Contains(pending.Message, f.planPath) { - t.Errorf("pending finding does not name the offending artifact:\n%s", pending.Message) - } - if !strings.Contains(pending.Message, "): ") { - t.Errorf("pending finding carries no typed detail (still cause-free): %q", pending.Message) } } @@ -202,12 +107,8 @@ func TestIntegrationFinalizeArchiveNeverEditsAuthoredBytes(t *testing.T) { t.Fatalf("closeout did not apply: %q (reason %q)", res.Result, res.Reason) } - integrationBranch := f.branch - if m.name == "docket" { - integrationBranch = "main" - } for _, p := range []string{f.planPath, f.resultsPath} { - got, ok := originFile(t, f.repo.origin, integrationBranch, p) + got, ok := originFile(t, f.repo.origin, f.branch, p) if !ok { t.Fatalf("artifact %q vanished", p) } @@ -516,17 +417,18 @@ func TestIntegrationFinalizeArchiveOrdinary(t *testing.T) { t.Errorf("spec backlink not retargeted to the archive path:\n%s", spec) } - // The plan/results backlinks (integration ref in docket mode) retarget too. - integrationBranch := f.branch - if m.name == "docket" { - integrationBranch = "main" - } + // The plan/results backlinks (metadata ref, beside the record) retarget + // too, each relative to its own directory. for _, p := range []string{f.planPath, f.resultsPath} { - got, ok := originFile(t, f.repo.origin, integrationBranch, p) + got, ok := originFile(t, f.repo.origin, f.branch, p) if !ok { t.Fatalf("artifact %q vanished", p) } - if !strings.Contains(got, archivePath) || strings.Contains(got, "`"+recPath+"`") { + rel, err := filepath.Rel(filepath.Dir(p), archivePath) + if err != nil { + t.Fatalf("relative archive path: %v", err) + } + if !strings.Contains(got, "]("+filepath.ToSlash(rel)+")**") || strings.Contains(got, "active/") { t.Errorf("artifact %q backlink not retargeted:\n%s", p, got) } } diff --git a/internal/app/finalize_closeout_test.go b/internal/app/finalize_closeout_test.go index fadf3e6d8..a64ed042d 100644 --- a/internal/app/finalize_closeout_test.go +++ b/internal/app/finalize_closeout_test.go @@ -2,9 +2,11 @@ package app import ( "context" - "github.com/danielhanold/docket/internal/githubcli" "strings" "testing" + + "github.com/danielhanold/docket/internal/githubcli" + "github.com/danielhanold/docket/internal/render" ) // This file drives `finalize closeout` over a REAL metadata topology in BOTH @@ -80,10 +82,10 @@ const ( ) // closeoutFixture is a real published feature workspace whose parent record is -// implemented, carries the canonical PR reference, and points at a spec (on the -// metadata ref) plus a plan and results (on the integration ref in docket mode, -// on the shared ref in main mode) that each carry a docket:backlink block. It is -// exactly the state `finalize closeout` consumes after a verified merge. +// implemented, carries the canonical PR reference, and points at a spec, a plan, +// and results — all on the metadata ref beside the record — that each carry a +// docket:backlink block. It is exactly the state `finalize closeout` consumes +// after a verified merge. type closeoutFixture struct { *rebaseFixture specPath string @@ -122,6 +124,17 @@ func artifactWithBacklink(activePath, heading, body string) string { "# " + heading + "\n\n" + body + "\n" } +// artifactWithRelativeBacklink renders a minimal metadata-resident artifact whose +// docket:backlink block links the change record relatively, the form a +// same-branch backlink takes, then an authored body whose bytes must survive +// closeout unchanged. +func artifactWithRelativeBacklink(artifactPath, recordPath, heading, body string) string { + return "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + + "> ↩ **[Change 0005 — A change](" + render.RelativeLink(artifactPath, recordPath) + ")**\n" + + "<!-- docket:backlink:end -->\n\n" + + "# " + heading + "\n\n" + body + "\n" +} + // setupCloseoutFixture builds the real published feature workspace, patches the // parent record to carry the PR reference + artifact pointers, and seeds the // spec/plan/results artifacts on their mode-correct refs. @@ -135,21 +148,16 @@ func setupCloseoutFixture(t *testing.T, m planRepoMode) *closeoutFixture { resultsPath: "docs/changes/results/0005-widget-results.md", } recPath := groomPath(f.id, f.slug) - // The record and the spec live on the metadata branch. The plan and results - // live on the integration branch (main) — a genuinely different ref, so a - // read from the wrong source is observable. + // The record, the spec, the plan, and the results all live on the metadata + // branch; nothing of the change's paperwork is on the integration branch. metaFiles := map[string]string{ - recPath: closeoutRecord(f.id, f.slug, "implemented", closeoutRef, cf.specPath, cf.planPath, cf.resultsPath), - cf.specPath: artifactWithBacklink(recPath, "Design", "The widget design."), + recPath: closeoutRecord(f.id, f.slug, "implemented", closeoutRef, cf.specPath, cf.planPath, cf.resultsPath), + cf.specPath: artifactWithBacklink(recPath, "Design", "The widget design."), + cf.planPath: artifactWithRelativeBacklink(cf.planPath, recPath, "Plan", "The widget plan."), + cf.resultsPath: artifactWithRelativeBacklink(cf.resultsPath, recPath, "Results", "The widget results."), } f.repo.writerAdvance(t, f.branch, metaFiles) - integrationFiles := map[string]string{ - cf.planPath: artifactWithBacklink(recPath, "Plan", "The widget plan."), - cf.resultsPath: artifactWithBacklink(recPath, "Results", "The widget results."), - } - f.repo.writerAdvance(t, "main", integrationFiles) - cf.revision = blobRevisionAt(t, f.repo.origin, f.branch, recPath) return cf } @@ -194,8 +202,6 @@ func (f *closeoutFixture) baselineMergedFake(head, mergeCommit string) *fakeClos // --- TestCloseoutRootCarry ------------------------------------------------ -// --- TestCloseoutBacklinkLegDocketMode ------------------------------------ - // --- TestCloseoutNeverEditsAuthoredBytes ---------------------------------- // --- closeout notes ------------------------------------------------------- @@ -213,7 +219,3 @@ const closeoutWantNotesSection = "## Closeout notes\n\n" + "- Production health check passed after deployment\n\n" + "### Late findings\n\n" + "- The upgrade guide should mention the legacy config cleanup\n" - - // --- TestCloseoutBacklinkLegIgnoresUnrelatedCorpusErrors ------------------ - - // --- TestCloseoutBacklinkPendingFindingNamesTheCause ---------------------- diff --git a/internal/app/finalize_git_test.go b/internal/app/finalize_git_test.go index 16b5a341b..c00514091 100644 --- a/internal/app/finalize_git_test.go +++ b/internal/app/finalize_git_test.go @@ -179,10 +179,10 @@ func matrixMerge(t *testing.T, m planRepoMode) { } } -// matrixCloseout covers "Metadata closeout push" and (docket mode) "Integration -// backlink push": the closeout transaction lands once, and a replay keyed on the -// canonical archive record is a verified no-op — never a second commit, never a -// false-second-done. +// matrixCloseout covers "Metadata closeout push": the closeout transaction lands +// once and commits nothing to the integration branch, and a replay keyed on the +// canonical archive record is a verified no-op — never a second commit on either +// ref, never a false-second-done. func matrixCloseout(t *testing.T, m planRepoMode) { f := setupCloseoutFixture(t, m) mergeCommit := f.mergeIntoBase(t) @@ -193,16 +193,8 @@ func matrixCloseout(t *testing.T, m planRepoMode) { t.Fatalf("first closeout = %q disp %q (reason %q)", first.Result, first.Disposition, first.Reason) } metaTip := originTip(t, f.repo.origin, f.branch) - var intBranch, intTip string - if m.name == "docket" { - intBranch = "main" - intTip = originTip(t, f.repo.origin, intBranch) - // No final-backlink-pending finding: the integration leg landed. - for _, fd := range first.Findings { - if fd.Code == ReasonCloseoutBacklinkPending { - t.Fatalf("the docket-mode backlink leg did not land: %+v", fd) - } - } + if tip := originTip(t, f.repo.origin, "main"); tip != mergeCommit { + t.Errorf("close-out committed to the integration branch: main = %q, want the merge commit %q", tip, mergeCommit) } // The response was lost; the replay is keyed on the promised archive record, not @@ -217,10 +209,8 @@ func matrixCloseout(t *testing.T, m planRepoMode) { if tip := originTip(t, f.repo.origin, f.branch); tip != metaTip { t.Errorf("replay produced a second metadata commit: %q -> %q", metaTip, tip) } - if m.name == "docket" { - if tip := originTip(t, f.repo.origin, intBranch); tip != intTip { - t.Errorf("replay produced a second integration-backlink commit: %q -> %q", intTip, tip) - } + if tip := originTip(t, f.repo.origin, "main"); tip != mergeCommit { + t.Errorf("replay committed to the integration branch: %q -> %q", mergeCommit, tip) } } diff --git a/internal/app/finalize_state_integration_test.go b/internal/app/finalize_state_integration_test.go index a30d5eba8..661ed6e0f 100644 --- a/internal/app/finalize_state_integration_test.go +++ b/internal/app/finalize_state_integration_test.go @@ -93,14 +93,12 @@ func TestIntegrationFinalizeStateBytePreservation(t *testing.T) { t.Run(m.name, func(t *testing.T) { f := setupCloseoutFixture(t, m) - integrationBranch := f.branch - if m.name == "docket" { - integrationBranch = "main" - } - // Pre-images of files the closeout must not disturb outside its owned blocks. - planBefore, _ := originFile(t, f.repo.origin, integrationBranch, f.planPath) - resultsBefore, _ := originFile(t, f.repo.origin, integrationBranch, f.resultsPath) - readmeBefore, hadReadme := originFile(t, f.repo.origin, integrationBranch, "README.md") + // Pre-images of files the closeout must not disturb outside its owned + // blocks: the plan and results on the metadata branch, and a file on + // the integration branch the closeout never targets. + planBefore, _ := originFile(t, f.repo.origin, f.branch, f.planPath) + resultsBefore, _ := originFile(t, f.repo.origin, f.branch, f.resultsPath) + readmeBefore, hadReadme := originFile(t, f.repo.origin, "main", "README.md") mergeCommit := f.mergeIntoBase(t) gh := f.baselineMergedFake(f.head, mergeCommit) @@ -108,6 +106,9 @@ func TestIntegrationFinalizeStateBytePreservation(t *testing.T) { if res.Result != ResultApplied { t.Fatalf("closeout did not apply: %q (reason %q)", res.Result, res.Reason) } + if tip := originTip(t, f.repo.origin, "main"); tip != mergeCommit { + t.Errorf("closeout committed to the integration branch: main = %q, want the merge commit %q", tip, mergeCommit) + } // The authored body after the backlink block is byte-identical. for _, tc := range []struct { @@ -117,7 +118,7 @@ func TestIntegrationFinalizeStateBytePreservation(t *testing.T) { {f.planPath, planBefore}, {f.resultsPath, resultsBefore}, } { - after, ok := originFile(t, f.repo.origin, integrationBranch, tc.path) + after, ok := originFile(t, f.repo.origin, f.branch, tc.path) if !ok { t.Fatalf("artifact %q vanished after closeout", tc.path) } @@ -129,7 +130,7 @@ func TestIntegrationFinalizeStateBytePreservation(t *testing.T) { // A file the closeout never targets is byte-identical in full. if hadReadme { - readmeAfter, ok := originFile(t, f.repo.origin, integrationBranch, "README.md") + readmeAfter, ok := originFile(t, f.repo.origin, "main", "README.md") if !ok || readmeAfter != readmeBefore { t.Errorf("README.md was disturbed by closeout:\n--before--\n%q\n--after--\n%q", readmeBefore, readmeAfter) } diff --git a/internal/app/maintenance_assess.go b/internal/app/maintenance_assess.go index fe0d8baf7..72fb803dc 100644 --- a/internal/app/maintenance_assess.go +++ b/internal/app/maintenance_assess.go @@ -34,8 +34,8 @@ import ( // and must not be parsed. const ( // ReasonSweepSnapshotNoWork: every destructive leg is provably a no-op at the - // pinned inventory — a cleaned tombstone, absent local/remote refs, and - // already-correct final and PR backlinks. Nothing was dispatched. + // pinned inventory — a cleaned tombstone, absent local/remote refs, and an + // already-correct PR backlink. Nothing was dispatched. ReasonSweepSnapshotNoWork = "snapshot-no-work" // ReasonSweepSnapshotRetained: a stacked-merged record is retained until its // stack root reaches the integration branch; a cleanup is never dispatched @@ -47,7 +47,7 @@ const ( // the blocker. ReasonSweepSnapshotBlocked = "snapshot-blocked" // ReasonSweepSnapshotUnknown: an inspection could not be resolved (a failed - // shared remote inventory, an unreadable workspace probe, or a malformed + // shared remote inventory, an unreadable workspace probe, or a malformed PR // backlink block), so absence could not be proven and no leg established work. // Nothing was dispatched; the message names the unresolved leg(s). ReasonSweepSnapshotUnknown = "snapshot-unknown" @@ -81,7 +81,6 @@ type sweepSharedFacts struct { // The leg names the assessment reports in its diagnostic messages. They are // explanatory labels, not a parsed vocabulary. const ( - sweepLegBacklink = "backlink" sweepLegWorkspace = "workspace" sweepLegLocalRef = "local-ref" sweepLegRemoteRef = "remote-ref" @@ -94,8 +93,7 @@ const ( // observation, never a fabricated cleanup result) and the subset of candidates // that warrant one normal fresh cleanup attempt. It dispatches no metadata, PR, or // remote-ref read of its own: the remote/worktree inventories and the batched -// PR-body inventory arrive in shared, the backlink leg reads the pinned -// integration artifacts through the reader, and the workspace leg is a local-only +// PR-body inventory arrive in shared, and the workspace leg is a local-only // inspection. func sweepAssessHistorical(ctx context.Context, deps FinalizeDeps, wdeps WorkspaceDeps, inv sweepInventory, pin StatusPin, shared sweepSharedFacts, @@ -161,7 +159,6 @@ func sweepAssessHistorical(ctx context.Context, deps FinalizeDeps, wdeps Workspa // unresolved leg never launders into a clean no-op. featureRef := gitcli.RefName(branchRefPrefix + branch) var a sweepLegAssessment - sweepAssessBacklinkLeg(ctx, deps, pin, c, link, &a) sweepAssessPRBacklinkLeg(shared, c, link, &a) sweepAssessWorkspaceLeg(ctx, wdeps, repo, target, &a) sweepAssessLocalRefLeg(shared, featureRef, &a) @@ -231,59 +228,6 @@ func (a sweepLegAssessment) verdict() (disposition, reason, message string) { "every destructive leg is provably a no-op at the pinned inventory" } -// sweepAssessBacklinkLeg resolves the final-backlink leg: for each of the -// record's plan/results artifacts on the integration ref, it reads the pinned -// bytes and asks the shared backlinkLegHasWork whether the rendered interior would -// change. An already-correct block and a missing artifact are no-effect (the exact -// cases the existing cleanup planner treats as a no-op); a read error or a -// malformed/unbalanced backlink block is unresolved, never a clean no-op. -func sweepAssessBacklinkLeg(ctx context.Context, deps FinalizeDeps, pin StatusPin, c domain.Change, link render.LinkContext, a *sweepLegAssessment) { - block, err := render.BacklinkContent(c, link) - if err != nil { - a.markUnknown(sweepLegBacklink, "the record's final backlink could not be rendered") - return - } - interior := backlinkInterior(block) - for _, p := range sweepBacklinkArtifactPaths(c) { - art, err := deps.Planning.Reader.ReadArtifact(ctx, pin, sourceIntegration, p) - if err != nil { - // A probe error is unknown, never the clean absence ReadArtifact reports - // with Found=false. - a.markUnknown(sweepLegBacklink, "artifact "+p+" could not be read on the integration ref") - return - } - if !art.Found { - // The merged artifact is not on the integration ref; the cleanup planner - // treats a missing artifact as a no-op. - continue - } - has, herr := backlinkLegHasWork(art.Data, interior) - if herr != nil { - a.markUnknown(sweepLegBacklink, "artifact "+p+" carries a malformed final backlink block") - return - } - if has { - a.markWork() - return - } - } -} - -// sweepBacklinkArtifactPaths returns the record's plan and results pointer paths, -// empties omitted — the integration-resident artifacts the final backlink leg -// retargets (the spec is metadata-resident and never on the integration ref). It -// mirrors closeoutBacklinkTargets' path selection. -func sweepBacklinkArtifactPaths(c domain.Change) []string { - var out []string - if p := c.Plan().Value; p != "" { - out = append(out, p) - } - if p := c.Results().Value; p != "" { - out = append(out, p) - } - return out -} - // sweepAssessPRBacklinkLeg resolves the PR-backlink leg from the shared, batched // PR-body inventory: a body whose docket:backlink block does not name the // archived record path has work (cleanup repoints it). A body already naming it, diff --git a/internal/app/maintenance_assess_test.go b/internal/app/maintenance_assess_test.go index 189f21e2a..3740598cd 100644 --- a/internal/app/maintenance_assess_test.go +++ b/internal/app/maintenance_assess_test.go @@ -70,7 +70,7 @@ func wsState(id int, kind workspace.StateKind) *assessWS { } // assessDoneBlob builds a done record in the pinned corpus, optionally carrying a -// plan pointer whose integration artifact the backlink leg reads. +// plan pointer. func assessDoneBlob(id int, slug, planPath string) StatusBlob { extra := "" if planPath != "" { @@ -108,9 +108,9 @@ func (f assessFixture) assess(t *testing.T, ws WorkspaceService, shared sweepSha return sweepAssessHistorical(context.Background(), deps, wdeps, f.inv, f.pin, shared, cands) } -// assessInterior renders the exact final-backlink interior a record's already- -// correct integration artifact must carry — so a test can build an artifact whose -// backlink block is already retargeted (no work). +// assessInterior renders the exact archived backlink interior a record's +// already-correct PR description must carry — so a test can build a body whose +// backlink block is already repointed (no work). func assessInterior(t *testing.T, f assessFixture, id int) string { t.Helper() c, out := f.inv.snap.Change(domain.ChangeID(id)) @@ -134,15 +134,6 @@ func assessArtifactWithBacklink(interior string) StatusArtifact { return StatusArtifact{Found: true, Revision: "artv1", Data: []byte(body)} } -// artifactDanglingMarker is a plan artifact whose backlink block has a start -// marker but no end marker — a dangling managed block the parser refuses. -func artifactDanglingMarker() StatusArtifact { - body := "## Plan\n\n" + - "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + - "> ↩ dangling with no end marker\n" - return StatusArtifact{Found: true, Revision: "artbad", Data: []byte(body)} -} - func integrationArtifactKey(path string) string { return sourceIntegration + "|" + path } // featureRefFor is the fully qualified local/remote ref for a feat/<slug> branch. @@ -201,15 +192,16 @@ func TestAssessStackedMergedIsSnapshotRetainedNoDispatch(t *testing.T) { } // TestAssessCleanTombstoneAbsentRefsCorrectBacklinksIsNoWork: a cleaned workspace -// tombstone, absent local and remote refs, and an already-correct final -// backlink is snapshot-no-work. It asserts the absence of ALL mutations: nothing -// enqueued, and the workspace service was only inspected, never mutated. +// tombstone and absent local and remote refs are snapshot-no-work — even when a +// legacy plan on the integration branch still backlinks the active path, because +// nothing retargets an integration-branch artifact after the merge. It asserts +// the absence of ALL mutations: nothing enqueued, and the workspace service was +// only inspected, never mutated. func TestAssessCleanTombstoneAbsentRefsCorrectBacklinksIsNoWork(t *testing.T) { planPath := "docs/changes/plans/plan-41.md" - f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", planPath)}, nil) - // Build the artifact so its backlink block is already correct: no work. - interior := assessInterior(t, f, 41) - f.reader.artifactData = map[string]StatusArtifact{integrationArtifactKey(planPath): assessArtifactWithBacklink(interior)} + f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", planPath)}, map[string]StatusArtifact{ + integrationArtifactKey(planPath): assessArtifactWithBacklink("> ↩ **stale — points at the active path**"), + }) ws := cleanWS() // Empty non-nil advertisement + no worktrees: absent refs are PROVEN. @@ -256,40 +248,6 @@ func TestAssessAbsentManifestIsBlockedNotClean(t *testing.T) { assertEntry(t, findEntry(entries, 41), SweepDispBlocked, ReasonSweepSnapshotBlocked) } -// TestAssessMissingManifestWithStaleBacklinkIsActionable: the backlink leg is -// INDEPENDENT of the workspace blocker — a stale backlink makes the record -// actionable even though the workspace manifest is foreign. -func TestAssessMissingManifestWithStaleBacklinkIsActionable(t *testing.T) { - planPath := "docs/changes/plans/plan-41.md" - f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", planPath)}, map[string]StatusArtifact{ - integrationArtifactKey(planPath): assessArtifactWithBacklink("> ↩ **stale — points at the active path**"), - }) - ws := wsState(41, workspace.StateForeign) - shared := sweepSharedFacts{remoteHeads: map[gitcli.RefName]gitcli.ObjectID{}} - entries, actionable := f.assess(t, ws, shared, 41) - - if !actionableHas(actionable, 41) { - t.Fatalf("a stale backlink must make the record actionable despite the workspace blocker; actionable=%v", actionable) - } - if findEntry(entries, 41) != nil { - t.Fatalf("an actionable record produces no pre-dispatch entry, got %+v", findEntry(entries, 41)) - } -} - -// TestAssessStaleBacklinkLegIsActionable: a stale final backlink alone makes -// the record actionable (workspace clean, refs absent). -func TestAssessStaleBacklinkLegIsActionable(t *testing.T) { - planPath := "docs/changes/plans/plan-41.md" - f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", planPath)}, map[string]StatusArtifact{ - integrationArtifactKey(planPath): assessArtifactWithBacklink("> ↩ **stale line**"), - }) - shared := sweepSharedFacts{remoteHeads: map[gitcli.RefName]gitcli.ObjectID{}} - _, actionable := f.assess(t, cleanWS(), shared, 41) - if !actionableHas(actionable, 41) { - t.Fatalf("stale backlink leg must be actionable; actionable=%v", actionable) - } -} - // TestAssessReadyWorkspaceLegIsActionable: an owned ready checkout is possible // cleanup work (workspace removal) → actionable. func TestAssessReadyWorkspaceLegIsActionable(t *testing.T) { @@ -329,26 +287,6 @@ func TestAssessLeftoverRemoteRefLegIsActionable(t *testing.T) { } } -// TestAssessMalformedMarkersAreUnknownNeverNoWork: a malformed/unbalanced final -// backlink block is unresolved — unknown/snapshot-unknown — never a clean no-op. -func TestAssessMalformedMarkersAreUnknownNeverNoWork(t *testing.T) { - planPath := "docs/changes/plans/plan-41.md" - f := newAssessFixture(t, []StatusBlob{assessDoneBlob(41, "archived", planPath)}, map[string]StatusArtifact{ - integrationArtifactKey(planPath): artifactDanglingMarker(), - }) - shared := sweepSharedFacts{remoteHeads: map[gitcli.RefName]gitcli.ObjectID{}} - entries, actionable := f.assess(t, cleanWS(), shared, 41) - - if len(actionable) != 0 { - t.Fatalf("a malformed block must not be actionable; actionable=%v", actionable) - } - e := findEntry(entries, 41) - assertEntry(t, e, SweepDispUnknown, ReasonSweepSnapshotUnknown) - if !strings.Contains(e.Message, sweepLegBacklink) { - t.Fatalf("the unknown message must name the backlink leg, got %q", e.Message) - } -} - // TestAssessInvalidRecordDataIsSnapshotInvalid: a done record whose canonical PR // reference does not parse is snapshot-invalid — distinguishable from clean // absence — and nothing is dispatched. @@ -366,25 +304,22 @@ func TestAssessInvalidRecordDataIsSnapshotInvalid(t *testing.T) { // TestAssessFailedRemoteHeadsBlocksNoWorkButNotLocalLegs: a failed shared remote // advertisement makes the remote-ref absence UNPROVABLE — a record with no // locally-established work becomes unknown (never no-work) — but a record whose -// local backlink leg already established work still dispatches. +// local workspace leg already established work still dispatches. func TestAssessFailedRemoteHeadsBlocksNoWorkButNotLocalLegs(t *testing.T) { - planPath := "docs/changes/plans/plan-42.md" corpus := []StatusBlob{ - assessDoneBlob(41, "cleanarchived", ""), // clean everywhere except the failed remote - assessDoneBlob(42, "stalearchived", planPath), // stale backlink = local work + assessDoneBlob(41, "cleanarchived", ""), // clean everywhere except the failed remote + assessDoneBlob(42, "readyarchived", ""), // ready owned workspace = local work } - f := newAssessFixture(t, corpus, map[string]StatusArtifact{ - integrationArtifactKey(planPath): assessArtifactWithBacklink("> ↩ **stale**"), - }) + f := newAssessFixture(t, corpus, nil) shared := sweepSharedFacts{remoteHeadsErr: errors.New("ls-remote transport failed")} - entries, actionable := f.assess(t, cleanWS(), shared, 41, 42) + entries, actionable := f.assess(t, wsState(42, workspace.StateReady), shared, 41, 42) // 41: only the remote leg could speak, and it is unknown → no-work is blocked. if actionableHas(actionable, 41) { t.Fatalf("41 has no local work and an unprovable remote absence; it must not dispatch") } assertEntry(t, findEntry(entries, 41), SweepDispUnknown, ReasonSweepSnapshotUnknown) - // 42: local backlink work stands regardless of the remote inventory failure. + // 42: local workspace work stands regardless of the remote inventory failure. if !actionableHas(actionable, 42) { t.Fatalf("42's locally-established work must still dispatch despite the failed remote read; actionable=%v", actionable) } diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index 744b3e078..c2611f2a8 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -51,8 +51,8 @@ func newFinalizeCommand(setResult func(app.OperationResult)) *cobra.Command { // newFinalizeCleanupSubcommand builds `finalize cleanup`: the ownership-safe // destructive suffix over one final change. It reloads the archived/stacked -// state and the verified merge destination, repairs the final backlinks, -// removes the feature checkout, and deletes the local and remote feature refs +// state and the verified merge destination, repairs the merged PR +// description's backlink, removes the feature checkout, and deletes the local and remote feature refs // under exact proof — retaining any resource whose proof it cannot answer. Only // the change id and the target directory ride on flags; there is no authored // request body. @@ -62,9 +62,9 @@ func newFinalizeCleanupSubcommand(setResult func(app.OperationResult)) *cobra.Co Short: "Clean a final change's workspace and feature refs under exact ownership proof", Args: cobra.NoArgs, // local-write (delete local ref + remove worktree), external-write - // (delete the remote feature ref under lease), metadata-write (the - // closeout-backlink transaction leg on the metadata branch). - Annotations: capability("finalize.cleanup", EffectLocalWrite, EffectExternalWrite, EffectMetadataWrite), + // (delete the remote feature ref under lease, repoint the merged PR + // description's backlink). It commits nothing. + Annotations: capability("finalize.cleanup", EffectLocalWrite, EffectExternalWrite), RunE: func(c *cobra.Command, _ []string) error { repoDir, err := resolveRepoDir(c) if err != nil { diff --git a/internal/cli/maintenance.go b/internal/cli/maintenance.go index 0720d9e77..6b2316d12 100644 --- a/internal/cli/maintenance.go +++ b/internal/cli/maintenance.go @@ -125,7 +125,7 @@ func newMaintenancePreflightSubcommand(setResult func(app.OperationResult)) *cob // newMaintenanceSweepSubcommand builds `maintenance sweep`: one pinned inventory, // processed in a deterministic order, that closes out merged changes (stacked -// children before ancestors), retries final backlink repair and ownership-safe +// children before ancestors), retries PR backlink repair and ownership-safe // cleanup for archived/done records and completed stacks, and reclaims expired // claims when reclaim.auto is on. It reloads fresh authority before every // mutation and reports every item as a structured entry. Two flags ride on it — diff --git a/internal/repoguard/integration_push_test.go b/internal/repoguard/integration_push_test.go new file mode 100644 index 000000000..4f1e5a573 --- /dev/null +++ b/internal/repoguard/integration_push_test.go @@ -0,0 +1,193 @@ +package repoguard + +// TestNoIntegrationPushOutsidePRMerge pins that no operation writes the +// integration branch outside a PR merge. Two syntactic shapes are checked over +// every non-test Go file under internal/app: +// +// 1. Every transaction.Request composite literal's TargetRef expression must +// mention MetadataBranchName — the engine may only commit to the metadata +// branch. +// 2. Every direct PushLease / PushCreateLease call must sit in a function on +// pushAllowlist. Each entry names why it may push; only migrateExecute +// touches the integration branch, for the one-time human-run legacy +// migration prune. A new direct pusher reddens this test and must argue its +// way onto the list. +// +// Mutation-tested: re-adding a TargetRef of branchRefPrefix + integrationBranch +// reddens shape 1; adding a PushLease call to a new function reddens shape 2. +// +// RESIDUAL RISK, recorded not hidden: shape 1 reads composite literals only, so +// a Request built field by field (req.TargetRef = ...) escapes it; no such site +// exists, and the population floor keeps the literal detector honest. Shape 2 +// keys on the selector name, not the receiver's resolved type, which errs safe. + +import ( + "bytes" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "sort" + "strconv" + "strings" + "testing" +) + +// pushAllowlist names every function under internal/app that may call +// PushLease or PushCreateLease directly, and why. +var pushAllowlist = map[string]string{ + "publishOrAdoptMetadataRoot": "repository init seeds the metadata branch", + "executeRepositoryRepair": "repository repair publishes one metadata commit", + "reconcileResumeSeed": "repository migrate resumes the metadata seed", + "publishSeed": "repository migrate publishes the metadata seed", + "migrateExecute": "repository migrate's one-time, human-run legacy prune of the integration branch", +} + +// integrationPushReport is what the walk over internal/app found. +type integrationPushReport struct { + requestLiterals int + pushers map[string]bool + violations []string +} + +// analyzeIntegrationPush walks the parsed files for both shapes. +func analyzeIntegrationPush(fset *token.FileSet, files map[string]*ast.File) integrationPushReport { + rep := integrationPushReport{pushers: map[string]bool{}} + rels := make([]string, 0, len(files)) + for rel := range files { + rels = append(rels, rel) + } + sort.Strings(rels) + for _, rel := range rels { + file := files[rel] + // Shape 1: transaction.Request literals target the metadata branch. + ast.Inspect(file, func(n ast.Node) bool { + lit, ok := n.(*ast.CompositeLit) + if !ok || !isTransactionRequest(lit.Type) { + return true + } + rep.requestLiterals++ + var target ast.Expr + for _, el := range lit.Elts { + kv, ok := el.(*ast.KeyValueExpr) + if !ok { + continue + } + if id, ok := kv.Key.(*ast.Ident); ok && id.Name == "TargetRef" { + target = kv.Value + } + } + pos := fset.Position(lit.Pos()) + if target == nil { + rep.violations = append(rep.violations, rel+": a transaction.Request literal at line "+strconv.Itoa(pos.Line)+" sets no TargetRef, so its target cannot be proven to be the metadata branch") + return true + } + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, target); err != nil || !strings.Contains(buf.String(), "MetadataBranchName") { + rep.violations = append(rep.violations, rel+": a transaction.Request targets "+buf.String()+", not the metadata branch (MetadataBranchName)") + } + return true + }) + // Shape 2: direct pushes sit only in allowlisted functions. + for _, decl := range file.Decls { + fn, isFunc := decl.(*ast.FuncDecl) + ast.Inspect(decl, func(n ast.Node) bool { + call, ok := n.(*ast.CallExpr) + if !ok { + return true + } + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || (sel.Sel.Name != "PushLease" && sel.Sel.Name != "PushCreateLease") { + return true + } + if !isFunc { + rep.violations = append(rep.violations, rel+": a package-level "+sel.Sel.Name+" call sits outside any function") + return true + } + name := fn.Name.Name + rep.pushers[name] = true + if _, ok := pushAllowlist[name]; !ok { + rep.violations = append(rep.violations, rel+": "+name+" calls "+sel.Sel.Name+" directly but is not on pushAllowlist") + } + return true + }) + } + } + return rep +} + +// isTransactionRequest reports whether a composite literal's type is the +// selector transaction.Request. +func isTransactionRequest(e ast.Expr) bool { + sel, ok := e.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Request" { + return false + } + pkg, ok := sel.X.(*ast.Ident) + return ok && pkg.Name == "transaction" +} + +func TestNoIntegrationPushOutsidePRMerge(t *testing.T) { + root := guardRoot(t) + fset := token.NewFileSet() + files := map[string]*ast.File{} + for _, rel := range maintainedPop(t, root) { + if !hasExt(rel, ".go") || strings.HasSuffix(rel, "_test.go") || dirOf(rel) != "internal/app" { + continue + } + file, err := parser.ParseFile(fset, rel, readMaintained(t, root, rel), 0) + if err != nil { + // Fail closed: an unparseable file is a guard failure, never a clean miss. + t.Fatalf("parse %s: %v", rel, err) + } + files[rel] = file + } + + rep := analyzeIntegrationPush(fset, files) + + // Population floors first: an empty enumeration passes every negative. + if rep.requestLiterals < 15 { + t.Fatalf("population floor: found %d transaction.Request literals under internal/app (want >= 15); the literal detector drifted", rep.requestLiterals) + } + for name := range pushAllowlist { + if !rep.pushers[name] { + t.Errorf("pushAllowlist entry %q calls no PushLease/PushCreateLease; the entry is stale — delete it", name) + } + } + for _, v := range rep.violations { + t.Error(v) + } +} + +// TestNoIntegrationPushOutsidePRMergeDetects is the guard's own negative +// control: a planted integration-branch TargetRef and an unlisted pusher each +// produce a violation, and the metadata-branch shape produces none. +func TestNoIntegrationPushOutsidePRMergeDetects(t *testing.T) { + src := `package app + +func good() { + _ = transaction.Request{TargetRef: gitcli.RefName(branchRefPrefix + reposetup.MetadataBranchName)} +} + +func bad() { + _ = transaction.Request{TargetRef: gitcli.RefName(branchRefPrefix + cc.integrationBranch)} +} + +func sneakyPush() { + git.PushLease(ctx, repo, remote, ref, commit, old) +} +` + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "internal/app/x.go", src, 0) + if err != nil { + t.Fatal(err) + } + rep := analyzeIntegrationPush(fset, map[string]*ast.File{"internal/app/x.go": f}) + if rep.requestLiterals != 2 { + t.Errorf("requestLiterals = %d, want 2", rep.requestLiterals) + } + joined := strings.Join(rep.violations, "\n") + if len(rep.violations) != 2 || !strings.Contains(joined, "cc.integrationBranch") || !strings.Contains(joined, "sneakyPush") { + t.Errorf("violations = %v, want exactly the integration TargetRef and sneakyPush", rep.violations) + } +} diff --git a/internal/repoguard/retired_vocabulary_test.go b/internal/repoguard/retired_vocabulary_test.go index 4b023efbf..7533e45a2 100644 --- a/internal/repoguard/retired_vocabulary_test.go +++ b/internal/repoguard/retired_vocabulary_test.go @@ -246,7 +246,7 @@ var retiredVocabulary = []retiredToken{ {Row: "54", Kind: kindToken, Old: "rearm", New: "re-enable"}, {Row: "55", Kind: kindToken, Old: "nothing-to-rearm", New: "nothing-to-re-enable"}, {Row: "56", Kind: kindToken, Old: "fenced-setting-ignored", New: "shared-setting-ignored"}, - {Row: "57", Kind: kindToken, Old: "terminal-backlink-pending", New: "final-backlink-pending"}, + {Row: "57", Kind: kindToken, Old: "terminal-backlink-pending", New: "none — no backlink is committed to the integration branch after a merge (its successor final-backlink-pending is retired too)"}, {Row: "57", Kind: kindToken, Old: "terminal-notes-frozen", New: "final-notes-frozen"}, {Row: "58", Kind: kindToken, Old: "change-terminal-claim-stamp", New: "change-final-claim-stamp"}, {Row: "58", Kind: kindToken, Old: "drop-terminal-claimed-at", New: "drop-final-claimed-at"}, @@ -1140,7 +1140,7 @@ func testRetiredNegativeControls(t *testing.T) { "the claim digest payload keeps its version key", // Change 0474 — the new spellings and the kept namesakes (spec §D). "apply `change.groom` with `outcome: re-enable`; a `nothing-to-re-enable` refusal writes nothing", - "`final-backlink-pending`, `final-notes-frozen`, `not-final`, `skipped-final`, `adr-update-after-final`", + "`final-notes-frozen`, `not-final`, `skipped-final`, `adr-update-after-final`", "`change-final-claim-stamp` and `drop-final-claimed-at`; the warning is `shared-setting-ignored`", "read `../docket-convention/references/close-out.md` now — blocking", "terminal_publish: false stays parseable; terminal publication is deferred from Go v1", From d9e63067302b0549af95ee031bb0820d88fe9dd4 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:37:40 -0400 Subject: [PATCH 13/28] refactor(config): finalize.skip_results_only_delta is an obsolete tombstone --- .docket.yml | 6 -- docs/release/upgrading-from-bash.md | 2 +- internal/app/config_test.go | 14 ++-- internal/app/finalize_e2e_test.go | 7 +- internal/app/status_corpus_test.go | 50 +++++++------- internal/config/capability_test.go | 20 ++---- internal/config/decode.go | 12 ++++ internal/config/decode_test.go | 47 ++++++++++++- internal/config/fixtures_test.go | 17 +++-- internal/config/preflight_test.go | 4 +- internal/config/resolve_test.go | 6 +- internal/config/schema.go | 9 ++- internal/config/schema_test.go | 69 +++++++++---------- internal/config/setting_paths_test.go | 4 +- testdata/repositories/v0.9.12/PROVENANCE.md | 11 +++ .../v0.9.12/docket-self/repo/.docket.yml | 43 ++++++++++++ .../v0.9.12/docket-self/xdg/docket/config.yml | 5 ++ 17 files changed, 219 insertions(+), 107 deletions(-) create mode 100644 testdata/repositories/v0.9.12/PROVENANCE.md create mode 100644 testdata/repositories/v0.9.12/docket-self/repo/.docket.yml create mode 100644 testdata/repositories/v0.9.12/docket-self/xdg/docket/config.yml diff --git a/.docket.yml b/.docket.yml index e7d92705a..859662989 100644 --- a/.docket.yml +++ b/.docket.yml @@ -19,12 +19,6 @@ finalize: # The Go-native whole-suite runner (change 0318), entered from SOURCE so the gate can never # select a stale installed binary. test_command: go run ./cmd/docket development test - # Arms the gate's docs-only post-gate skip (change 0190). Default is false everywhere, because - # the skip is only safe where no executable suite component reads docs/results/ as a content - # source. This repo PROVES that property on every run, in - # tests/test_skip_allowlist_invisibility.sh — which is what earns the opt-in here. Delete that - # guard and this line must go with it. - skip_results_only_delta: false board_surfaces: [inline] # BOARD.md only; the GitHub mirror stays off diff --git a/docs/release/upgrading-from-bash.md b/docs/release/upgrading-from-bash.md index 58e66e157..5b887afed 100644 --- a/docs/release/upgrading-from-bash.md +++ b/docs/release/upgrading-from-bash.md @@ -230,10 +230,10 @@ These are the Bash-era settings docket reports, in `.docket.yml` or in |---|---|---| | `metadata_branch` | Warning: ignores it. `docket repository check` also reports it as an error. | Remove it. | | `runtime.bash` | Warning: ignores it. | Remove it. | +| `finalize.skip_results_only_delta` | Warning: ignores it. | Remove it. | | `terminal_publish` | Error when set to `true`. | Remove it, or set it to `false`. | | `auto_groom` | Error when set to `true`. | Remove it, or set it to `false`. | | `build.checkpoint` | Error when set to `true`. | Remove it, or set it to `false`. | -| `finalize.skip_results_only_delta` | Error when set to `true`. | Remove it, or set it to `false`. | | `auto_capture.enabled` | Error when set to `true`. | Remove it, or set it to `false`. | | `dummy_mode.enabled` | Error when set to `true`. | Remove it, or set it to `false`. | | `finalize.gate` | Error when set to `ci` or `both`. The saved installs cover `ci` only. | Remove it, or set it to `local` or `off`. | diff --git a/internal/app/config_test.go b/internal/app/config_test.go index 88b2ae2e8..df94918a7 100644 --- a/internal/app/config_test.go +++ b/internal/app/config_test.go @@ -38,7 +38,7 @@ func blockedSources() []config.Source { "metadata_branch: docket\n" + "integration_branch: main\n" + "terminal_publish: true\n" + - "finalize:\n skip_results_only_delta: true\n" + + "auto_groom: true\n" + "build:\n checkpoint: true\n"), }, } @@ -156,7 +156,7 @@ func TestPreflightUnsupported(t *testing.T) { } } sort.Strings(blockers) - want := []string{"auto_capture.enabled", "build.checkpoint", "finalize.skip_results_only_delta", "terminal_publish"} + want := []string{"auto_capture.enabled", "auto_groom", "build.checkpoint", "terminal_publish"} if strings.Join(blockers, ",") != strings.Join(want, ",") { t.Errorf("blockers = %v, want %v", blockers, want) } @@ -534,7 +534,7 @@ func TestMigrationHostContraction(t *testing.T) { Data: []byte("agents:\n claude:\n implement-next:\n model: m\n effort: low\n"), } // The committed repository file with the three owned switches at chosen states. - repoSwitches := func(terminalPublish, skipResultsOnly, checkpoint bool) config.Source { + repoSwitches := func(terminalPublish, autoGroom, checkpoint bool) config.Source { return config.Source{ Layer: config.LayerRepository, Name: ".docket.yml", @@ -542,9 +542,9 @@ func TestMigrationHostContraction(t *testing.T) { "metadata_branch: docket\n"+ "integration_branch: main\n"+ "terminal_publish: %v\n"+ - "finalize:\n skip_results_only_delta: %v\n"+ + "auto_groom: %v\n"+ "build:\n checkpoint: %v\n", - terminalPublish, skipResultsOnly, checkpoint)), + terminalPublish, autoGroom, checkpoint)), } } // The repository-local layer the migration drops: an auto_capture request and @@ -587,7 +587,7 @@ func TestMigrationHostContraction(t *testing.T) { preBlockers := blockerSet(pre) for _, want := range []string{ "terminal_publish", - "finalize.skip_results_only_delta", + "auto_groom", "build.checkpoint", "auto_capture.enabled", } { @@ -622,7 +622,7 @@ func TestMigrationHostContraction(t *testing.T) { sources []config.Source }{ {"build.checkpoint", []config.Source{globalAgentPin, repoSwitches(false, false, true)}}, - {"finalize.skip_results_only_delta", []config.Source{globalAgentPin, repoSwitches(false, true, false)}}, + {"auto_groom", []config.Source{globalAgentPin, repoSwitches(false, true, false)}}, {"terminal_publish", []config.Source{globalAgentPin, repoSwitches(true, false, false)}}, {"auto_capture.enabled", []config.Source{globalAgentPin, repoSwitches(false, false, false), repoLocal(true, false)}}, {"repo-local agents pin", []config.Source{globalAgentPin, repoSwitches(false, false, false), repoLocal(false, true)}}, diff --git a/internal/app/finalize_e2e_test.go b/internal/app/finalize_e2e_test.go index c649f8534..8ad90b20a 100644 --- a/internal/app/finalize_e2e_test.go +++ b/internal/app/finalize_e2e_test.go @@ -1373,7 +1373,7 @@ func mergeMaps(a, b map[string]string) map[string]string { // TestE2EUnsupportedConfigRefused loads the capability requests Docket refused // before 0326 — repository-local `agents.*`, `auto_capture.enabled`, -// `build.checkpoint`, `finalize.skip_results_only_delta`, and `terminal_publish` +// `build.checkpoint`, `auto_groom`, and `terminal_publish` // — into the invocation clone's OWN `.docket.yml` (a tempdir file, never a frozen // fixture tree), and proves every mutating 0316 operation that reruns the // capability preflight returns `unsupported-config` naming the blockers, with the @@ -1396,7 +1396,8 @@ func TestE2EUnsupportedConfigRefused(t *testing.T) { // the working tree). The file body is authored here in the test's own space, // never copied into a frozen fixture tree. deferred := "metadata_branch: " + m.branch + "\n" + - "finalize:\n test_command: 'exit 0'\n skip_results_only_delta: true\n" + + "finalize:\n test_command: 'exit 0'\n" + + "auto_groom: true\n" + "auto_capture:\n enabled: true\n" + "build:\n test_command: 'exit 0'\n checkpoint: true\n" + "terminal_publish: true\n" + @@ -1439,7 +1440,7 @@ func TestE2EUnsupportedConfigRefused(t *testing.T) { t.Fatalf("%s under deferred caps = %q, want unsupported-config\n%s", op.name, r.result(), r.stdout) } if !strings.Contains(r.stdout, "terminal_publish") && !strings.Contains(r.stdout, "build.checkpoint") && - !strings.Contains(r.stdout, "auto_capture") && !strings.Contains(r.stdout, "skip_results_only_delta") && + !strings.Contains(r.stdout, "auto_capture") && !strings.Contains(r.stdout, "auto_groom") && !strings.Contains(r.stdout, "agents.claude.adr.model") { t.Errorf("%s refusal named no blocker path:\n%s", op.name, r.stdout) } diff --git a/internal/app/status_corpus_test.go b/internal/app/status_corpus_test.go index 346da3620..e80f08c94 100644 --- a/internal/app/status_corpus_test.go +++ b/internal/app/status_corpus_test.go @@ -80,10 +80,10 @@ func loadCorpusBlobs(t *testing.T) (blobs []StatusBlob, docketYML []byte) { // corpusConfig resolves the frozen `.docket.yml` as the repository layer, the // way PinContext would, and returns the snapshot together with the diagnostics // the resolver raised. The frozen production config is faithful, not synthetic: -// it requests three capabilities Go v1 has not implemented (build.checkpoint, -// finalize.skip_results_only_delta, terminal_publish) plus one deferred setting -// (learnings.enabled), so `docket status` over this repo surfaces those four -// diagnostics as findings. Threading them is what makes the corpus the oracle +// it requests two capabilities Go v1 has not implemented (build.checkpoint, +// terminal_publish), one deferred setting (learnings.enabled), and two obsolete +// settings (metadata_branch, finalize.skip_results_only_delta), so `docket +// status` over this repo surfaces those five diagnostics as findings. Threading them is what makes the corpus the oracle // for the operation's REAL output on this tree, not a substitute config. func corpusConfig(t *testing.T, docketYML []byte) (config.Snapshot, []config.Diagnostic) { t.Helper() @@ -116,10 +116,11 @@ func corpusConfig(t *testing.T, docketYML []byte) (config.Snapshot, []config.Dia // 36, whose depends_on:[35] and related:[35] both name change 35, which is // deliberately excluded. That yields exactly one error and one warning. // -// On top of the two record findings, the frozen config contributes four -// findings (see corpusConfig): three deferred-capability ERRORS and one -// deferred-setting NOTICE. Findings are assembled config-first, so the full -// health tally is 4 errors + 1 warning + 1 notice = 6 findings. +// On top of the two record findings, the frozen config contributes five +// findings (see corpusConfig): two deferred-capability ERRORS, two +// obsolete-setting WARNINGS, and one deferred-setting NOTICE. Findings are +// assembled config-first, so the full health tally is 3 errors + 3 warnings + +// 1 notice = 7 findings. func TestStatusCorpusFrozenSemantics(t *testing.T) { blobs, docketYML := loadCorpusBlobs(t) if docketYML == nil { @@ -171,16 +172,16 @@ func TestStatusCorpusFrozenSemantics(t *testing.T) { if got.Summary.Learnings != 0 { t.Errorf("Learnings = %d, want 0 (the tag's tree carries no learnings ledger)", got.Summary.Learnings) } - // Error tally: 3 deferred-capability config errors + change 36's dangling + // Error tally: 2 deferred-capability config errors + change 36's dangling // depends_on:[35]. Warning tally: the committed .docket.yml's obsolete - // metadata_branch key (0363) + change 36's dangling related:[35]. (The - // deferred-setting learnings.enabled diagnostic is a NOTICE, counted in - // neither tally.) - if got.Summary.ErrorFindings != 4 { - t.Errorf("ErrorFindings = %d, want 4 (3 deferred-capability config errors + change 36 depends_on:[35])", got.Summary.ErrorFindings) + // metadata_branch and finalize.skip_results_only_delta keys + change 36's + // dangling related:[35]. (The deferred-setting learnings.enabled diagnostic + // is a NOTICE, counted in neither tally.) + if got.Summary.ErrorFindings != 3 { + t.Errorf("ErrorFindings = %d, want 3 (2 deferred-capability config errors + change 36 depends_on:[35])", got.Summary.ErrorFindings) } - if got.Summary.WarningFindings != 2 { - t.Errorf("WarningFindings = %d, want 2 (obsolete metadata_branch + change 36 related:[35] dangling)", got.Summary.WarningFindings) + if got.Summary.WarningFindings != 3 { + t.Errorf("WarningFindings = %d, want 3 (obsolete metadata_branch and finalize.skip_results_only_delta + change 36 related:[35] dangling)", got.Summary.WarningFindings) } // --- empty active projection ---------------------------------------------- @@ -227,16 +228,17 @@ func TestStatusCorpusFrozenSemantics(t *testing.T) { // ordering while still pinning the exact set. type findKey struct{ code, severity, field, identity string } wantFindings := map[findKey]bool{ - // Config layer: three deferred capabilities (errors) + one deferred + // Config layer: two deferred capabilities (errors) + one deferred // setting (notice). Identity is empty — a config diagnostic names a // setting path, not a repository entity. - {"deferred-capability-requested", "error", "build.checkpoint", ""}: false, - {"deferred-capability-requested", "error", "finalize.skip_results_only_delta", ""}: false, - {"deferred-capability-requested", "error", "terminal_publish", ""}: false, - {"deferred-setting", "notice", "learnings.enabled", ""}: false, - // The committed .docket.yml still carries metadata_branch, now an obsolete - // tombstone (0363): a warning-severity obsolete-setting finding. - {"obsolete-setting", "warning", "metadata_branch", ""}: false, + {"deferred-capability-requested", "error", "build.checkpoint", ""}: false, + {"deferred-capability-requested", "error", "terminal_publish", ""}: false, + {"deferred-setting", "notice", "learnings.enabled", ""}: false, + // The committed .docket.yml still carries metadata_branch and + // finalize.skip_results_only_delta, both obsolete tombstones: one + // warning-severity obsolete-setting finding each. + {"obsolete-setting", "warning", "metadata_branch", ""}: false, + {"obsolete-setting", "warning", "finalize.skip_results_only_delta", ""}: false, // Record layer: change 36's two dangling references to the excluded 35. {"change-reference-dangling", "error", "depends_on", "0036"}: false, {"change-reference-dangling", "warning", "related", "0036"}: false, diff --git a/internal/config/capability_test.go b/internal/config/capability_test.go index 39fe78c5b..af17d9c36 100644 --- a/internal/config/capability_test.go +++ b/internal/config/capability_test.go @@ -110,17 +110,11 @@ func TestClassifyMatrix(t *testing.T) { sources: []Source{srcR("finalize:\n test_command: make test\n require_pr_approval: true\n")}, }, - // Row 10 — repo-only deferred bool. + // Row 10 — obsolete tombstone: even true is inactive and never blocks. { - name: "skip_results_only_delta true blocks", - sources: []Source{srcR("finalize:\n skip_results_only_delta: true\n")}, - want: []capWant{{"finalize.skip_results_only_delta", Deferred, true, true, LayerRepository}}, - blockers: []string{"finalize.skip_results_only_delta"}, - }, - { - name: "skip_results_only_delta explicit false is inactive", - sources: []Source{srcR("finalize:\n skip_results_only_delta: false\n")}, - want: []capWant{{"finalize.skip_results_only_delta", Deferred, false, false, LayerRepository}}, + name: "skip_results_only_delta true is obsolete", + sources: []Source{srcR("finalize:\n skip_results_only_delta: true\n")}, + want: []capWant{{"finalize.skip_results_only_delta", Obsolete, false, false, LayerRepository}}, }, // Rows 11-12 — learnings. @@ -361,12 +355,12 @@ func TestClassifyMatrix(t *testing.T) { func TestClassifyMultiBlockerCompleteSet(t *testing.T) { snap := mustSnapshot(t, srcG("auto_capture:\n enabled: true\n"), - srcR("build:\n checkpoint: true\nterminal_publish: true\nfinalize:\n skip_results_only_delta: true\n"), + srcR("build:\n checkpoint: true\nterminal_publish: true\nauto_groom: true\n"), ) want := []string{ "auto_capture.enabled", + "auto_groom", "build.checkpoint", - "finalize.skip_results_only_delta", "terminal_publish", } if got := blockerPaths(snap); !reflect.DeepEqual(got, want) { @@ -470,7 +464,7 @@ func TestClassifyReasonsAndRemedies(t *testing.T) { // declaration at all, so it cannot be a capability request either — the guard // warning is the whole report. func TestClassifyGuardedDeclarationsAreNotCapabilities(t *testing.T) { - snap := mustSnapshot(t, srcL("terminal_publish: true\nfinalize:\n skip_results_only_delta: true\n")) + snap := mustSnapshot(t, srcL("terminal_publish: true\ngithub_project: {owner: acme, number: 7}\n")) if len(snap.Capabilities) != 0 { t.Errorf("capabilities = %+v, want none", gotCaps(snap.Capabilities)) } diff --git a/internal/config/decode.go b/internal/config/decode.go index 617ab83a0..a46717c7d 100644 --- a/internal/config/decode.go +++ b/internal/config/decode.go @@ -165,6 +165,18 @@ func (d *layerDecoder) decodeLeaf(path string, spec *pathSpec, key, val *yaml.No } d.diags = append(d.diags, diag) return + case "finalize.skip_results_only_delta": + // Obsolete in EVERY layer: the results file lives on the metadata branch, + // so there is no results-only feature-branch commit left to skip. It is + // recognized so inspection can attribute it, and excluded from resolution + // rather than guarded — even `true` blocks nothing. + diag := leafDiag(d.src, path, CodeObsoleteSetting, key, + "armed a post-gate skip for a results-only commit, which docket no longer makes; it is ignored") + diag.Severity = SeverityWarning + diag.Classification = Obsolete + diag.Remedy = "remove finalize.skip_results_only_delta from " + d.src.Name + d.diags = append(d.diags, diag) + return case "board_surfaces": value = d.keepKnownSurfaces(path, val, value.([]string)) case "board.section_order": diff --git a/internal/config/decode_test.go b/internal/config/decode_test.go index df8dcb6df..d493f584f 100644 --- a/internal/config/decode_test.go +++ b/internal/config/decode_test.go @@ -76,7 +76,7 @@ func decodeAcceptanceCases() []decodeCase { flow: "finalize: {repair_max_attempts: 9}\n", value: 9}, {row: "finalize.skip_results_only_delta", path: "finalize.skip_results_only_delta", block: "finalize:\n skip_results_only_delta: true\n", - flow: "finalize: {skip_results_only_delta: true}\n", value: true}, + flow: "finalize: {skip_results_only_delta: true}\n", obsolete: true}, {row: "learnings.enabled", path: "learnings.enabled", block: "learnings:\n enabled: false\n", flow: "learnings: {enabled: false}\n", value: false}, @@ -414,6 +414,51 @@ func TestDecodeUnknownSubtreeIsNotDescended(t *testing.T) { } } +// TestSkipResultsOnlyDeltaIsObsoleteTombstone: finalize.skip_results_only_delta +// armed a post-gate skip for a results-only commit that no longer exists, so +// in every layer it is one obsolete-setting warning naming its removal, never +// a resolved value, and never a mutation blocker — even when set to true. +func TestSkipResultsOnlyDeltaIsObsoleteTombstone(t *testing.T) { + const path = "finalize.skip_results_only_delta" + for _, src := range []Source{ + srcR("finalize:\n skip_results_only_delta: true\n"), + srcR("finalize: {skip_results_only_delta: false}\n"), + srcL("finalize:\n skip_results_only_delta: true\n"), + srcG("finalize:\n skip_results_only_delta: true\n"), + } { + t.Run(string(src.Layer), func(t *testing.T) { + res := mustResolve(t, []Source{src}, mainCtx) + if _, ok := res.declared[path]; ok { + t.Fatalf("%s resolved to a honored declaration; it must be excluded", path) + } + obs := diagsWithCode(res, CodeObsoleteSetting) + if len(obs) != 1 || obs[0].Path != path { + t.Fatalf("want exactly one obsolete-setting diagnostic on %s, got %v", path, diagSummary(res)) + } + if obs[0].Severity != SeverityWarning || obs[0].Classification != Obsolete { + t.Errorf("diagnostic = %s/%s, want warning/obsolete", obs[0].Severity, obs[0].Classification) + } + if want := "remove " + path + " from " + src.Name; obs[0].Remedy != want { + t.Errorf("remedy = %q, want %q", obs[0].Remedy, want) + } + for _, code := range []string{CodeDeferredSetting, CodeDeferredCapRequested, CodeSharedSettingIgnored} { + if got := diagsWithCode(res, code); len(got) != 0 { + t.Errorf("unexpected %s diagnostic: %v", code, diagSummary(res)) + } + } + snap := mustSnapshot(t, src) + if d := PreflightMutation(snap); !d.Allowed { + t.Errorf("preflight blocked on an obsolete setting: %+v", d.Blockers) + } + for _, c := range snap.Capabilities { + if c.Path == path && (c.Classification != Obsolete || c.Active || c.MutationBlock) { + t.Errorf("capability = %+v, want inactive non-blocking obsolete", c) + } + } + }) + } +} + // TestDecodeObsoleteScalarAutoCaptureRemedy: the pre-0127 scalar shape must // tell the reader the nested replacement, not merely refuse. func TestDecodeObsoleteScalarAutoCaptureRemedy(t *testing.T) { diff --git a/internal/config/fixtures_test.go b/internal/config/fixtures_test.go index 190bf2a56..12cd9b224 100644 --- a/internal/config/fixtures_test.go +++ b/internal/config/fixtures_test.go @@ -446,13 +446,14 @@ func TestFixtureGuardedMachineKeys(t *testing.T) { eff := snap.Effective def := builtinEffective() - // metadata_branch is no longer guarded — it is an obsolete tombstone (0363), - // excluded at decode rather than guarded, so the machine-layer declaration - // produces an obsolete-setting warning, not a shared-setting-ignored one. + // metadata_branch and finalize.skip_results_only_delta are no longer + // guarded — they are obsolete tombstones, excluded at decode rather than + // guarded, so a machine-layer declaration produces an obsolete-setting + // warning, not a shared-setting-ignored one. guarded := diagPathSet(snap, CodeSharedSettingIgnored) for _, path := range []string{ "integration_branch", "changes_dir", "adrs_dir", "results_dir", - "finalize.skip_results_only_delta", "github_project", "terminal_publish", "board_surfaces", + "github_project", "terminal_publish", "board_surfaces", } { if !guarded[path] { t.Errorf("%s is declared in a machine layer but produced no shared-setting-ignored warning", path) @@ -512,8 +513,10 @@ func TestFixtureDocketSelf(t *testing.T) { // `go run ./cmd/docket development test` (the whole-suite gate cutover). Change // 0468 cut v0.9.8 for a comment-only rewording (ADR-0129 row 64). v0.9.10 // adds the explicit agent_harnesses opt-in, which requests no deferred - // capability and so leaves the blocker set below unchanged. - const docketSelfRoot = "../../testdata/repositories/v0.9.10" + // capability and so leaves the blocker set below unchanged. v0.9.12 drops the + // explicit `finalize.skip_results_only_delta: false` (now an obsolete + // tombstone), which requested nothing either. + const docketSelfRoot = "../../testdata/repositories/v0.9.12" assertFrozenCopyMatchesLive(t, filepath.Join(docketSelfRoot, "docket-self", "repo", ".docket.yml"), @@ -533,7 +536,7 @@ func TestFixtureDocketSelf(t *testing.T) { } decision := PreflightMutation(snap) - // After the contraction the three committed switches are explicitly false, so + // After the contraction the two committed switches are explicitly false, so // they no longer request anything. The machine-global auto-capture request in // this fixture's xdg layer is untouched and remains the sole blocker: the // configuration is still refused, now on one deferred capability from one diff --git a/internal/config/preflight_test.go b/internal/config/preflight_test.go index 5431753b9..5ad559d47 100644 --- a/internal/config/preflight_test.go +++ b/internal/config/preflight_test.go @@ -13,7 +13,7 @@ func blockedSnapshot(t *testing.T) *Snapshot { t.Helper() return mustSnapshot(t, srcG("auto_capture:\n enabled: true\n"), - srcR("build:\n checkpoint: true\nterminal_publish: true\nfinalize:\n skip_results_only_delta: true\n"), + srcR("build:\n checkpoint: true\nterminal_publish: true\nauto_groom: true\n"), ) } @@ -36,8 +36,8 @@ func TestPreflightBlockedComplete(t *testing.T) { } want := []string{ "auto_capture.enabled", + "auto_groom", "build.checkpoint", - "finalize.skip_results_only_delta", "terminal_publish", } var paths []string diff --git a/internal/config/resolve_test.go b/internal/config/resolve_test.go index 225e12d8a..9c12eb546 100644 --- a/internal/config/resolve_test.go +++ b/internal/config/resolve_test.go @@ -568,14 +568,12 @@ func guardCases() []guardCase { return []guardCase{ // metadata_branch is no longer guarded — it is an obsolete tombstone // (change 0363), excluded at decode. Its tombstone behavior is covered by - // TestMetadataBranchIsObsoleteTombstone. + // TestMetadataBranchIsObsoleteTombstone. finalize.skip_results_only_delta + // is likewise a tombstone (TestSkipResultsOnlyDeltaIsObsoleteTombstone). {"integration_branch", "integration_branch: develop\n", "integration_branch: trunk\n", "develop"}, {"changes_dir", "changes_dir: docs/a\n", "changes_dir: docs/b\n", "docs/a"}, {"adrs_dir", "adrs_dir: docs/a\n", "adrs_dir: docs/b\n", "docs/a"}, {"results_dir", "results_dir: docs/a\n", "results_dir: docs/b\n", "docs/a"}, - {"finalize.skip_results_only_delta", - "finalize:\n skip_results_only_delta: true\n", - "finalize:\n skip_results_only_delta: false\n", true}, {"github_project", "github_project: {owner: acme, number: 7}\n", "github_project: auto\n", githubProject{Owner: "acme", Number: 7}}, {"terminal_publish", "terminal_publish: true\n", "terminal_publish: false\n", true}, diff --git a/internal/config/schema.go b/internal/config/schema.go index f4d4a0d23..c4fd18f74 100644 --- a/internal/config/schema.go +++ b/internal/config/schema.go @@ -257,8 +257,13 @@ func buildRegistry() []pathSpec { merge: mergeScalar, scope: scopeAny, disp: dispSupported, validate: intLeaf(1)}, {path: "finalize.repair_max_attempts", kind: kindInt, def: 6, merge: mergeScalar, scope: scopeAny, disp: dispSupported, validate: intLeaf(1)}, - {path: "finalize.skip_results_only_delta", kind: kindBool, def: false, - merge: mergeScalar, scope: scopeRepoOnly, disp: dispDeferred, validate: boolLeaf()}, + // finalize.skip_results_only_delta is an obsolete tombstone: it armed a + // post-gate skip for a results-only commit on the feature branch, and the + // results file no longer rides that branch. Like metadata_branch it decodes + // in every layer (default scopeAny) and decode excludes it as obsolete + // before any guard runs, so it never resolves and never blocks. + {path: "finalize.skip_results_only_delta", kind: kindBool, merge: mergeScalar, + disp: dispObsolete, validate: boolLeaf()}, // 11-12: learnings. {path: "learnings.enabled", kind: kindBool, def: true, diff --git a/internal/config/schema_test.go b/internal/config/schema_test.go index 7c778edab..9a20c47c5 100644 --- a/internal/config/schema_test.go +++ b/internal/config/schema_test.go @@ -72,14 +72,14 @@ func TestRegistryEveryRowHasValidator(t *testing.T) { } } -// TestRegistryRepoOnlySet pins both guard directions exactly: the eight +// TestRegistryRepoOnlySet pins both guard directions exactly: the six // shared-setting-guarded (repo-owned) paths and the single machine-only path. // Both compares are whole-set, so a row silently gaining or losing a guard // reddens this test. func TestRegistryRepoOnlySet(t *testing.T) { wantRepoOnly := []string{ "integration_branch", "changes_dir", "adrs_dir", - "results_dir", "finalize.skip_results_only_delta", "github_project", + "results_dir", "github_project", "terminal_publish", } wantLocalOnly := []string{"runtime.bash"} @@ -108,39 +108,38 @@ func TestRegistryRepoOnlySet(t *testing.T) { func TestRegistryDefaults(t *testing.T) { want := map[string]any{ // metadata_branch has no default: it is an obsolete tombstone (change 0363). - "integration_branch": "auto", - "changes_dir": "docs/changes", - "adrs_dir": "docs/adrs", - "results_dir": "docs/results", - "finalize.gate": "local", - "finalize.test_command": "", - "finalize.require_pr_approval": false, - "finalize.resolver_max_attempts": 10, - "finalize.repair_max_attempts": 6, - "finalize.skip_results_only_delta": false, - "learnings.enabled": true, - "learnings.cap": 300, - "reclaim.lease_ttl": 72, - "reclaim.auto": false, - "build.checkpoint": false, - "build.gate": "local", - "build.test_command": "", - "build.max_attempts": 4, - "run.max_attempts": 2, - "review.min_fix_severity": "minor", - "review.max_fix_tasks": 10, - "gate_observation_budget": 30, - "delegation_observation_budget": 60, - "board_surfaces": []string{"inline"}, - "github_project": "auto", - "terminal_publish": false, - "auto_groom": false, - "change_types": []string{"chore", "docs", "feat", "fix", "refactor", "perf"}, - "auto_capture.enabled": false, - "auto_capture.types": "all", - "dummy_mode.enabled": false, - "dummy_mode.persona": "", - "dummy_mode.surfaces": "all", + "integration_branch": "auto", + "changes_dir": "docs/changes", + "adrs_dir": "docs/adrs", + "results_dir": "docs/results", + "finalize.gate": "local", + "finalize.test_command": "", + "finalize.require_pr_approval": false, + "finalize.resolver_max_attempts": 10, + "finalize.repair_max_attempts": 6, + "learnings.enabled": true, + "learnings.cap": 300, + "reclaim.lease_ttl": 72, + "reclaim.auto": false, + "build.checkpoint": false, + "build.gate": "local", + "build.test_command": "", + "build.max_attempts": 4, + "run.max_attempts": 2, + "review.min_fix_severity": "minor", + "review.max_fix_tasks": 10, + "gate_observation_budget": 30, + "delegation_observation_budget": 60, + "board_surfaces": []string{"inline"}, + "github_project": "auto", + "terminal_publish": false, + "auto_groom": false, + "change_types": []string{"chore", "docs", "feat", "fix", "refactor", "perf"}, + "auto_capture.enabled": false, + "auto_capture.types": "all", + "dummy_mode.enabled": false, + "dummy_mode.persona": "", + "dummy_mode.surfaces": "all", } // The board presentation block (change 0367): the section-order list default // and every per-section sort default, derived from BoardSectionTokens. diff --git a/internal/config/setting_paths_test.go b/internal/config/setting_paths_test.go index eb8e79047..b2652f21b 100644 --- a/internal/config/setting_paths_test.go +++ b/internal/config/setting_paths_test.go @@ -52,9 +52,9 @@ func TestSettingPathsSupportSplit(t *testing.T) { } // One sample per unsupported disposition family. for _, p := range []string{ - "metadata_branch", "runtime.bash", // obsolete + "metadata_branch", "runtime.bash", "finalize.skip_results_only_delta", // obsolete "learnings.cap", "delegation_observation_budget", "github_project", // inert - "build.checkpoint", "terminal_publish", "auto_groom", "finalize.skip_results_only_delta", // deferred + "build.checkpoint", "terminal_publish", "auto_groom", // deferred "auto_capture.types", "dummy_mode.persona", "runners.*.shim_model", // inert companion "skills.build", "agents.*.*.runner", // deferred-active } { diff --git a/testdata/repositories/v0.9.12/PROVENANCE.md b/testdata/repositories/v0.9.12/PROVENANCE.md new file mode 100644 index 000000000..9c419c082 --- /dev/null +++ b/testdata/repositories/v0.9.12/PROVENANCE.md @@ -0,0 +1,11 @@ +# v0.9.12 docket-self fixture + +Source: docket's own committed `.docket.yml` and `xdg/docket/config.yml` as of the change that +keeps plan, results, and build evidence on the metadata branch, 2026-10-06. + +Cut from `v0.9.10/docket-self`. The ONLY file that changed from v0.9.10 is +`docket-self/repo/.docket.yml`: it drops the `finalize.skip_results_only_delta: false` line and its +comment block, because that setting is now an obsolete tombstone. An explicit `false` requested no +deferred capability, so the preflight blocker set is unchanged: the machine-global +`auto_capture.enabled` request remains the sole blocker. Everything else is carried verbatim. +Older versioned fixture trees (v0.9.2–v0.9.11) are immutable inputs and are never edited in place. diff --git a/testdata/repositories/v0.9.12/docket-self/repo/.docket.yml b/testdata/repositories/v0.9.12/docket-self/repo/.docket.yml new file mode 100644 index 000000000..859662989 --- /dev/null +++ b/testdata/repositories/v0.9.12/docket-self/repo/.docket.yml @@ -0,0 +1,43 @@ +# .docket.yml — committed on the repo's default branch; read by every docket skill at startup. +# +# This file carries ONLY the values docket's own repo overrides. For EVERY key, its default, its +# documentation, and which layers may set it, see .docket.example.yml — the canonical reference. +# Copy the keys you want from there; anything not set here runs docket's shipped default. +# +# This repo runs in DOCKET-MODE (migrated 2026-06-04 via migrate-to-docket.sh — the dogfood of +# change 0002): planning metadata is the live surface on the `docket` branch, while `main` keeps +# code, build artifacts, and published archived records. + +# metadata_branch removed (change 0363): one metadata topology (the fixed `docket` branch). +integration_branch: main # code lands on main; feature branches cut from origin/main +changes_dir: docs/changes +adrs_dir: docs/adrs +results_dir: docs/results + +finalize: + gate: local # rebase onto main + re-run the suite locally before merging + # The Go-native whole-suite runner (change 0318), entered from SOURCE so the gate can never + # select a stale installed binary. + test_command: go run ./cmd/docket development test + +board_surfaces: [inline] # BOARD.md only; the GitHub mirror stays off + +# This repo mirrors its final records (archived change files, specs, Accepted ADRs) onto main, +# so it opts in to the direct-commit publish explicitly. See .docket.example.yml for the caveats. +terminal_publish: false + +# No `skills:` block: since change 0193 docket-build and docket-review are the SHIPPED defaults, +# so this repo dogfoods them by running the default rather than pinning a copy of it. + +build: + # The build role's own whole-suite gate (change 0374) — same suite as finalize today, + # but an independent setting; the two may diverge. + gate: local + test_command: go run ./cmd/docket development test + # enable resume ledger + checkpoint: false + +# Harnesses the per-repo agent pass generates wrapper files for (see .docket.example.yml). +agent_harnesses: + - claude + - opencode diff --git a/testdata/repositories/v0.9.12/docket-self/xdg/docket/config.yml b/testdata/repositories/v0.9.12/docket-self/xdg/docket/config.yml new file mode 100644 index 000000000..c18269f36 --- /dev/null +++ b/testdata/repositories/v0.9.12/docket-self/xdg/docket/config.yml @@ -0,0 +1,5 @@ +# The machine-global half of the docket-self fixture: one deferred capability +# requested from outside the repository, so the blocker envelope spans layers. + +auto_capture: + enabled: true From febc56be8c026edfe1e5a2420134cbec0131a43e Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:44:21 -0400 Subject: [PATCH 14/28] test(workflow): pin the spec-plus-code PR and the post-gate results checkpoint --- internal/app/finalize_e2e_test.go | 38 +++++++++++++++- internal/app/workflow_e2e_test.go | 73 ++++++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 4 deletions(-) diff --git a/internal/app/finalize_e2e_test.go b/internal/app/finalize_e2e_test.go index 8ad90b20a..308a9a16f 100644 --- a/internal/app/finalize_e2e_test.go +++ b/internal/app/finalize_e2e_test.go @@ -71,6 +71,8 @@ type e2eState struct { stateFile string xdgHome string env []string + // rebase is the last finalize rebase document rebaseAndPublish observed. + rebase dkResult } // e2eXDGOnce isolates the in-process git client's global-config layer exactly @@ -551,12 +553,42 @@ func TestE2EOrdinaryFinalize(t *testing.T) { m := m t.Run(m.name, func(t *testing.T) { s := reachImplemented(t, m, docketBin, ghBin) - runOrdinaryFinalize(t, s) + + // A post-gate results checkpoint: a metadata commit only. The feature + // head does not move, so finalize's rebase still takes the no-op skip + // on the record's exact-head evidence. + resultsPath := "docs/results/2026-08-17-" + s.slug + "-results.md" + ck := ChangeAttachResults(context.Background(), s.node.deps, s.node.dir, ChangeAttachRequest{ + ID: s.id, Revision: s.ver(t), Path: resultsPath, + Markdown: []byte("# Add the widget — Results\n\n**Human action:** No required action.\n\n## Outcome\n\nA post-gate checkpoint before finalize.\n"), + }) + if ck.Result != ResultApplied { + t.Fatalf("post-gate attach results = %q (reason %q msg %q findings %v)", ck.Result, ck.Reason, ck.Message, ck.Findings) + } + + mainBefore := runGit(t, s.repo.origin, "rev-parse", "refs/heads/main") + mg := runOrdinaryFinalize(t, s) + + gate, _ := s.rebase.doc["gate"].(map[string]any) + if compose, _ := gate["compose"].(string); compose != gateComposeSkipped { + t.Errorf("rebase gate compose after a post-gate results checkpoint = %q, want %q\n%s", compose, gateComposeSkipped, s.rebase.stdout) + } + + // Close-out writes nothing to the integration branch: origin/main ends + // at the merge's own commit, with no backlink commit on top. + mc := mergeCommitField(t, mg) + if mainAfter := runGit(t, s.repo.origin, "rev-parse", "refs/heads/main"); mainAfter != mc { + extra := runGit(t, s.repo.origin, "log", "--format=%H %s", mc+"..refs/heads/main") + t.Errorf("origin/main after close-out = %s, want the merge commit %s and nothing else; extra commits:\n%s", mainAfter, mc, extra) + } + if _, err := tryGit(s.repo.origin, "merge-base", "--is-ancestor", mainBefore, mc); err != nil { + t.Errorf("the merge commit %s does not descend from the pre-merge base %s", mc, mainBefore) + } }) } } -func runOrdinaryFinalize(t *testing.T, s *e2eState) { +func runOrdinaryFinalize(t *testing.T, s *e2eState) dkResult { t.Helper() head, revision := rebaseAndPublish(t, s) @@ -587,6 +619,7 @@ func runOrdinaryFinalize(t *testing.T, s *e2eState) { if cl.result() != "applied" { t.Fatalf("finalize cleanup = %q\n%s", cl.result(), cl.stdout) } + return mg } // rebaseAndPublish drives the shared context->rebase->publish preamble and @@ -628,6 +661,7 @@ func rebaseAndPublish(t *testing.T, s *e2eState) (head, revision string) { // so a real rewrite happens and the local gate genuinely runs and passes. Both // are valid ordinary outcomes; the subsequent steps thread the resulting head. rb := s.dk(t, "", "finalize", "rebase", "--id", strconv.Itoa(s.id), "--revision", revision, "--head", s.head) + s.rebase = rb if rb.result() != "applied" && rb.result() != "no-op" { t.Fatalf("finalize rebase = %q\n%s", rb.result(), rb.stdout) } diff --git a/internal/app/workflow_e2e_test.go b/internal/app/workflow_e2e_test.go index 8d008d351..561b62bc0 100644 --- a/internal/app/workflow_e2e_test.go +++ b/internal/app/workflow_e2e_test.go @@ -2,12 +2,15 @@ package app import ( "context" + "fmt" + "github.com/danielhanold/docket/internal/evidence" "github.com/danielhanold/docket/internal/githubcli" "github.com/danielhanold/docket/internal/testsupport" "github.com/danielhanold/docket/internal/workspace" "os" "os/exec" "path/filepath" + "reflect" "runtime" "sort" "strings" @@ -53,6 +56,7 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . recPath := groomPath(id, slug) planPath := "docs/superpowers/plans/2026-08-17-widget-plan.md" resultsPath := "docs/results/2026-08-17-" + slug + "-results.md" + specPath := "docs/superpowers/specs/2026-08-17-" + slug + "-design.md" // A resolved build.test_command is required so EvidenceRecord (build-owned // since change 0374) records a real observed gate command rather than @@ -60,7 +64,13 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // so the finalize path stays configured. Both are set in the config layer // each mode reads: on main in main mode, on main (the integration branch) in // docket mode. - repo := buildConfiguredRepo(t, m, recPath, buildReadyChange(id, slug)) + // The record links a spec seeded on the metadata branch with its backlink + // block, so the run commits the spec copy as the feature branch's first + // commit and the PR carries exactly that copy plus the code. + repo := buildConfiguredRepoWith(t, m, map[string]string{ + recPath: specLinkedBuildReadyChange(id, slug, specPath), + specPath: workflowMetadataSpec(id, slug), + }) ctx := context.Background() node := planningDepsFor(t, repo.invocation) @@ -114,6 +124,13 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . } wp := prep.Path + // (4b) The spec copy is the feature branch's first commit. + 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) + } + specCommit := cs.Head + // (5)–(6) The plan-writer half: author the plan body and hand it to // attach-plan, which writes it on the metadata branch with its backlink // in one transaction. Nothing is committed on the feature branch. @@ -246,6 +263,17 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . t.Fatalf("pr snapshot did not round-trip: %+v", pr) } + // (12a) The PR carries exactly the spec copy and the code — no plan, no + // results, no evidence file — and the spec copy is its first commit. + diff := runGit(t, wp, "diff", "--name-only", "--no-renames", prep.BaseCommit+".."+head) + if got := strings.Fields(diff); !reflect.DeepEqual(got, []string{specPath, "widget.go"}) { + t.Fatalf("PR diff = %v, want exactly the spec copy and the code", got) + } + first := runGit(t, wp, "rev-list", "--reverse", prep.BaseCommit+".."+head) + if commits := strings.Fields(first); len(commits) == 0 || commits[0] != specCommit { + t.Fatalf("the spec copy %s is not the feature branch's first commit: %v", specCommit, commits) + } + // (13) Mark implemented after reprobing every published effect. mi := ChangeMarkImplemented(ctx, node.deps, wdeps, gdeps, node.dir, MarkImplementedRequest{ ID: id, @@ -258,6 +286,25 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . t.Fatalf("mark implemented = %q (status %q findings %v)", mi.Result, mi.Status, mi.Findings) } + // (13a) The plan, the results, and the record's build evidence all live + // at the metadata remote tip, and the record evidence — written after the + // post-gate results checkpoint — still verifies against the head. + for _, p := range []string{planPath, resultsPath} { + if _, ok := originFile(t, repo.origin, m.branch, p); !ok { + t.Errorf("%s is missing at the metadata remote tip", p) + } + } + record, ok := originFile(t, repo.origin, m.branch, recPath) + if !ok { + t.Fatalf("change record %s is missing at the metadata remote tip", recPath) + } + if _, present, err := recordEvidenceSection([]byte(record)); err != nil || !present { + t.Fatalf("the change record carries no %q section at the metadata remote tip (err %v):\n%s", buildEvidenceHeading, err, record) + } + if v := VerifyRecordEvidence([]byte(record), head); v != evidence.VerdictVerified { + t.Fatalf("record evidence against head %s = %q, want verified", head, v) + } + // (14) Read-only run verification: every postcondition holds ⇒ run-complete. rv := RunVerify(ctx, node.deps, wdeps, gdeps, node.dir, RunVerifyRequest{ID: id}) if rv.Result != ResultApplied || rv.Verdict != VerdictRunComplete { @@ -287,13 +334,35 @@ func runClaimToImplemented(t *testing.T, m planRepoMode, ghBin string, entries . // build.test_command and finalize.test_command in the repository config layer, // plus the one build-ready change record on the metadata branch. func buildConfiguredRepo(t *testing.T, m planRepoMode, recPath, record string) *gitRepo { + t.Helper() + return buildConfiguredRepoWith(t, m, map[string]string{recPath: record}) +} + +// buildConfiguredRepoWith is buildConfiguredRepo over an arbitrary set of +// metadata-branch files (a record plus, say, the spec it links). +func buildConfiguredRepoWith(t *testing.T, m planRepoMode, metadata map[string]string) *gitRepo { t.Helper() if m.name != "docket" { t.Fatalf("unknown repository topology %q", m.name) } return newDocketModeRepo(t, map[string]string{".docket.yml": "integration_branch: main\nbuild:\n test_command: 'go test ./...'\nfinalize:\n test_command: 'go test ./...'\n"}, - map[string]string{recPath: record}) + metadata) +} + +// specLinkedBuildReadyChange is a proposed, build-ready change record whose +// design is the spec at specPath (not a trivial verdict). +func specLinkedBuildReadyChange(id int, slug, specPath string) string { + return strings.Replace(groomableChange(id, slug), "spec:\n", "spec: "+specPath+"\n", 1) +} + +// workflowMetadataSpec is a spec as the metadata branch stores it: a +// docket:backlink block above the authored title. +func workflowMetadataSpec(id int, slug string) string { + return "<!-- docket:backlink:start (generated — do not hand-edit) -->\n" + + "> ↩ **[Change " + fmt.Sprintf("%04d", id) + " — A change](../../changes/active/" + fmt.Sprintf("%04d", id) + "-" + slug + ".md)**\n" + + "<!-- docket:backlink:end -->\n\n" + + "# Widget: design\n\nBuild the widget.\n" } // assertDeferredCapabilityBlocksClaim proves a `.docket.yml` that actively From da86d70108e81dc64ee0a1aaa21acfe2729624fe Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:48:19 -0400 Subject: [PATCH 15/28] docs(implement-next): plan, results, and evidence on the metadata branch; the spec copy rides the PR --- internal/assets/embedded/manifest.json | 18 ++++---- .../skills/docket-implement-next/SKILL.md | 42 +++++++++---------- .../references/edge-paths.md | 36 ++++++---------- .../references/fix-pass.md | 2 +- .../docket-implement-next/results-template.md | 6 +-- skills/docket-implement-next/SKILL.md | 42 +++++++++---------- .../references/edge-paths.md | 36 ++++++---------- .../references/fix-pass.md | 2 +- .../docket-implement-next/results-template.md | 6 +-- 9 files changed, 81 insertions(+), 109 deletions(-) diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 8c17a139f..53649c13a 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:ee29ff00a74f9f875c35fcb72b4ab9ca4e1f93d383163a2efbaa95d9ceb60a3d", + "asset_set_id": "sha256:eb96161ad81b8b1f8eb968a79bfb652d521b7737ea8077a712d1296597fbc033", "entries": [ { "path": ".docket.example.yml", @@ -378,29 +378,29 @@ "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 58219, - "sha256": "f2b93b5516e1418a93806df34c82e95cfd92fb54d3e380ac7d4161a0b3ad5cfd" + "size": 56301, + "sha256": "ecbdb78ff2a8474d1594b4ada9f67ce1872aa3ea8842c78357b8f07feafcb0d2" }, { "path": "skills/docket-implement-next/references/edge-paths.md", "role": "skill", "mode": 420, - "size": 10120, - "sha256": "e7a745822ea8ce6be2811cd722eac776999c529677794541127a169b361cf0ad" + "size": 8312, + "sha256": "d189ca4a4c4de2548e3514ae5ee29e430e6119f9e4fe06621cafee0176b5ee86" }, { "path": "skills/docket-implement-next/references/fix-pass.md", "role": "skill", "mode": 420, - "size": 12346, - "sha256": "b1e32a57a6a2c450ecc2122a8b35785edf36fa869408d09a123618cee9b65455" + "size": 12335, + "sha256": "895f5c2a1e030e8591ab02a48a8f82b8d48b5a778a66699f170011ad3abd7c11" }, { "path": "skills/docket-implement-next/results-template.md", "role": "skill", "mode": 420, - "size": 3748, - "sha256": "66f8b754c4ae282ae3b099ad7e478e9d9e56d2371d71235e8f0d2665564ca43e" + "size": 3760, + "sha256": "a5f1d87a0d63f764551c6e3301e019886c83b541507b22eb0e935ebc881bed8c" }, { "path": "skills/docket-new-change/SKILL.md", 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 6a4b26887..a7164f2d5 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -24,7 +24,7 @@ Before anything else in this skill, invoke the `docket-convention` skill via the ### Step 0 — Sync & sweep -Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir <dir> --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the plan + results + code land on the feature branch. +Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir <dir> --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the spec copy and the code land on the feature branch. **Named invocation — no maintenance preflight.** When this run was invoked with exactly **one explicit change id** — an initial named call, an attributed retry carrying the id, or a named resume of an in-progress change — skip the implementation preflight and Step 1's status-digest certification entirely: after the `repository.prepare` operation succeeds, go **directly** to the authoritative `context.implementation` operation with `--id <id>` and the existing claim/resume path. The named path never runs `maintenance.preflight` or any maintenance sweep first — another change's failing closeout, cleanup, or reclaim can never halt this change before its own claim/gate; anyone who wants those maintenance outcomes runs the explicit `maintenance.preflight` or `maintenance.sweep` operation deliberately, where every problem stays visible. Every local check for the named change is kept — readiness, record revision, dependencies, effective base, claim eligibility, run context, and resume/cancellation — so a malformed, absent, not-ready, or improperly resumed id returns its existing typed refusal, reported with its reason exactly as a scoped-id skip is (ending `drained` when nothing else is in scope); a named request **never falls back to selecting another change**, and shared repository/configuration/transport failures keep the `halted` posture. @@ -75,18 +75,18 @@ The `workspace.prepare` operation reloads the authoritative snapshot, resolves t the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/<integration_branch>` in the ordinary case). -**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Confirm the fresh worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id <id> --revision <revision>` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. +**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id <id>`: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id <id> --revision <revision>` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. <!-- docket:feature-dispatch:start targets=docket-plan-writer --> **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> -It also supplies the change id, title, synchronized change-file (the plan backlink's target) 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 file under `docs/superpowers/plans/` 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), stamps the backlink with the `artifact.backlink` operation (`--artifact <plan repo-relative path> --change <change repo-relative path>`), stages only the plan path, commits on `<type>/<slug>` with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`, and returns the single success line `PLAN_PATH=<repo-relative-path>`. +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 --> -**Verification (parent).** The returned path is a **claim, not proof**. Before attaching the plan, verify from git that: the path is a safe repo-relative path contained by the feature worktree; the file exists, is tracked, and changed after the pre-dispatch HEAD; the worktree is clean; the full branch delta since the pre-dispatch HEAD contains **only the returned plan file**; the plan commit carries **exactly one** `Docket-Plan-Path:` trailer whose value equals the returned path; and the artifact's managed backlink markers are ordered, balanced, and point home to this change. The plan must live under `docs/superpowers/plans/` — `change.attach-plan` accepts no other location. On a malformed return, unsafe path, unexpected delta, dirty worktree, missing commit, or invalid backlink: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's uncommitted output, and never retry with the parent or a weaker model. Once verified, attach it: the `change.attach-plan` operation with `--id <id> --revision <revision> --path <plan path> --commit <feature head>`. Before its transaction `attach-plan` re-verifies the same facts from Git — never from the child return — (head + descends-from-base, a canonical tracked path in the allowed planning directory, the single-artifact delta with `--no-renames` and the `Docket-Plan-Path:` trailer, a balanced backlink targeting this change, no unresolved placeholder), rechecks the exact revision, stores `plan:`, and renders the `## Artifacts` block and inline board atomically. A retry verifies the same plan identity (path + blob at the verified commit) and returns its prior applied outcome, never attaching whatever now occupies the path. The plan **file** merges with the code, so the `plan:` link resolves on the integration branch only after the PR merges (why `docket-status` ignores a missing `plan:` on an `implemented` change). +**Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified and attached, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. The plan's backlink is stamped once here; at close-out `finalize.closeout` re-points the merged copy at the archived record. +**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build @@ -110,24 +110,20 @@ It also contains the branch and base ref, change title and scope, relevant learn ### Step 6.5 — Results (required) -The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` **IN THE FEATURE WORKTREE**; the path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `artifact.backlink` operation — never hand-author its markers. +The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the `docket` branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. **Human actions and testing** labels every item **Important** or **Optional**, written for a mid-level engineer with little knowledge of Docket internals. An **Important** item marks a meaningful verification gap or necessary human judgment (never a new automated merge requirement) — give the reason, when the action matters, and what remains uncertain if skipped. An Optional walkthrough MAY exercise behavior automated tests already cover — a routine suite rerun is not a functional walkthrough. Every item carries prerequisites and setup, concrete steps with observable expected results, and cleanup when it changes persistent state — complete enough to run without reconstructing missing commands. Do not infer coverage from a green suite: inspect the relevant tests and evidence, and where coverage is genuinely uncertain report that uncertainty under Known issues and follow-ups or Verification performed rather than authoring a speculative checklist. The section holds only what a human should do or check — never a record of what the run already checked, which belongs under Verification performed. -**Checkpoint lifecycle.** The coordinator captures the findings it already knows at each evaluated boundary: **(i)** after the build role returns — prefer consolidating **before** the existing end-of-build gate so that gate tests the checkpoint-containing head; **(ii)** after review returns, and again after fixes with their **actual** dispositions; **(iii)** before a planned pause, halt, or handoff, but **only** when the ownership and gate-drive contracts permit a safe write; **(iv)** at final consolidation, before the implemented transition. A checkpoint is an **evaluated boundary, not a scheduled commit**: unchanged substantive content is a no-op — no empty commits, no per-observation commits, and **never commit or move HEAD beneath a live gate, a running worker, or a transferred drive**. +**Checkpoint lifecycle.** The coordinator captures the findings it already knows at each evaluated boundary: **(i)** after the build role returns; **(ii)** after review returns, and again after fixes with their **actual** dispositions; **(iii)** before a planned pause, halt, or handoff, but **only** when the ownership and gate-drive contracts permit a safe write; **(iv)** at final consolidation, before the implemented transition. A checkpoint is an **evaluated boundary, not a scheduled commit**: unchanged substantive content is a no-op — no empty commits, no per-observation commits, and **never commit or move HEAD beneath a live gate, a running worker, or a transferred drive**. **Per-checkpoint mechanics** (in order): 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. -2. Read and **preserve** the prior results content; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). -3. Write the update, then stamp and validate the back-link home with the `artifact.backlink` operation (`--artifact <results repo-relative path> --change <change repo-relative path>`). -4. Commit **ONLY the results path** on `<type>/<slug>` — stage by explicit path. -5. Publish through the existing feature-branch transport, **never forced**. -6. On first creation, attach via the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --commit <feature head>` (the same verification as `attach-plan`), landing the `results:` FIELD on the `docket` branch (the file rides the feature branch, the field lands via the transaction — the same split as `plan:`); later updates keep the **same path** and revalidate/reattach before completion. +2. Read and **preserve** the prior results content — after a re-sync, from `.docket/<results path>` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). +3. Write the update into the scratch file. +4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>` writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. -Local commit, remote publication, and metadata attachment are **distinct facts** — a denied push is not remote durability. On a denied push, keep the local commit and its diagnostic and follow the existing halt posture; never report the checkpoint durable on the strength of a local commit alone. - -**Evidence sequencing.** Results prose is **never gate evidence**. Batch the review/fix checkpoint edits before the final certification run; then, after the final authored results are committed, obtain gate evidence for **that exact head** through the gate driver. A post-gate results edit invalidates stale evidence and can require another certification run — say so in the report. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. +**Evidence sequencing.** Results prose is **never gate evidence**, and a results checkpoint never moves HEAD, so it never stales evidence. Obtain gate evidence for the final code head through the gate driver; any code commit (a fix) still invalidates it. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. **Known issues and follow-ups** merges unresolved findings, limitations, and follow-up work into one plain-language section — each entry gives when the problem occurs and what the person experiences, its practical impact, whether it is confirmed or suspected, any workaround, and the suggested next action. Follow-ups recorded there hold out-of-scope work for **human triage** — final consolidation checks each against the backlog and names any match (*Backlog match* below), and the run **never** mints a change, issue, ADR, or learning automatically. An in-scope defect stays fix/block/halt work regardless of where it is written. Final consolidation verifies that no material coordinator handoff remains only in chat, and the final report links the durable results file. @@ -141,11 +137,11 @@ Local commit, remote publication, and metadata attachment are **distinct facts** **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 three docket elements: the best-effort `#<issue>` reference (never `Closes #N`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the **build-evidence block** — the current evidence record written marker-bounded (`<!-- docket:build-evidence:start -->` / `<!-- docket:build-evidence:end -->`) alongside the review outcome, carrying its actual result — `green`, or `skipped` / `build-gate-off` under `build.gate: off` — never overstated, the block `docket-finalize-change` reads to decide whether its post-rebase suite run can be skipped. Before assembling any of the three, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics, including marker validation and the expected-staleness rule for a post-gate `head_sha`. +**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the review disposition table; `pr.publish` adds 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. -**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 build-evidence 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. +**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. -**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 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 + `## 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. +**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. @@ -153,16 +149,16 @@ Local commit, remote publication, and metadata attachment are **distinct facts** ### Step postconditions -Each step below is complete only when its row holds — read from **git**, never from a sub-skill's report or its own narration. One qualification: the build-evidence record of rows 5–6 is an in-context artifact, not a git object, so only its `head_sha` == HEAD condition is a git fact — load-bearing, not decorative. The conditions are **cumulative**: each holds in addition to every earlier step's, each read **as of the close of its own step** — a later commit moving branch HEAD (Step 6.5's results file) leaves an earlier row's `head_sha` stale, which `references/edge-paths.md` calls expected, not a defect. These certify a **step**, never the run. **Once a change is claimed, and absent a `halted` disposition or a Step-3 kill, the only postcondition that also completes the run is Step 7's** — a satisfied intermediate row is never licence to stop. A run that ends any other way ends on a **disposition**, not on a postcondition. +Each step below is complete only when its row holds — read from **git**, never from a sub-skill's report or its own narration. One qualification: the build-evidence record of rows 5–6 is an in-context artifact, not a git object, so only its `head_sha` == HEAD condition is a git fact — load-bearing, not decorative. The conditions are **cumulative**: each holds in addition to every earlier step's, each read **as of the close of its own step**. These certify a **step**, never the run. **Once a change is claimed, and absent a `halted` disposition or a Step-3 kill, the only postcondition that also completes the run is Step 7's** — a satisfied intermediate row is never licence to stop. A run that ends any other way ends on a **disposition**, not on a postcondition. | Step | Complete only when | |---|---| | 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the `docket` branch **and landed** (local tip == remote tip). | | 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the `docket` branch — or, on the kill path, the change archived. | -| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the plan file **and** its `docket:backlink` stamp committed on `<type>/<slug>`, **and** `plan:` landed on the `docket` branch — a two-tree condition, both refs read. | +| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `<type>/<slug>` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the `docket` branch, the feature HEAD unchanged by planning. | | 5 Build | the executed plan committed on `<type>/<slug>`, with a build-evidence record at `result: green` (or `skipped` / `build-gate-off` under `build.gate: off`) whose `head_sha` **equals branch HEAD**. | | 6 Review + ADRs | that record still present at `head_sha` == HEAD **after** any fix commits, and every ADR the run produced landed in `adrs:`. Known-weak row: on a clean review this reduces to Step 5's, because whether a reviewer ran is not a fact about git. | -| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink stamp committed on `<type>/<slug>`. | +| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink on the `docket` branch, and the record's `## Build evidence` section naming the PR head. | Rows 3, 4, 6 and 7 each carry, in addition, the **field-write rule**'s conditions on every metadata commit they name: the `claimed_at` re-stamp, and — for any link-bearing field write — the regenerated `## Artifacts` block in the same commit. @@ -209,8 +205,8 @@ Every change-file field write in this run (claim's `status:`/`branch:`, reconcil ### Feature branch invariants -New change ⇒ the `workspace.prepare` operation with `--id <id> --revision <revision>`, which cuts `<type>/<slug>` from the resolved effective base — `origin/<integration_branch>` in the ordinary case, except for a change carrying `stacked_on:`, whose base is the parent's effective base per the convention's *Branch model* exception and its stacked-changes reference. The workspace is prepared AFTER claim + reconcile, adds only plan + results + code, and **never modifies** docket metadata (the change file, `BOARD.md`, ADRs) — at merge, the 3-way merge takes the integration branch's side for the change file unconditionally, so there is no conflict and no revert needed. (The change file may not even exist on `origin/<integration_branch>`.) +New change ⇒ the `workspace.prepare` operation with `--id <id> --revision <revision>`, which cuts `<type>/<slug>` from the resolved effective base — `origin/<integration_branch>` in the ordinary case, except for a change carrying `stacked_on:`, whose base is the parent's effective base per the convention's *Branch model* exception and its stacked-changes reference. The workspace is prepared AFTER claim + reconcile, adds only the spec copy + code, and **never modifies** docket metadata (the change file, `BOARD.md`, ADRs) — at merge, the 3-way merge takes the integration branch's side for the change file unconditionally, so there is no conflict and no revert needed. (The change file may not even exist on `origin/<integration_branch>`.) -Metadata commits (per the **field-write rule**) happen in the metadata working tree; code and plan/results *file* commits happen in the **feature worktree** on `<type>/<slug>` — the `plan:`/`results:` *fields* are always written on the `docket` branch, never the feature worktree. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree touch is step 4's spec **read** — a read across trees, never a metadata write into the feature tree.) +Metadata commits (per the **field-write rule**) happen in the metadata working tree; only code commits happen in the **feature worktree** on `<type>/<slug>` — the plan and results files and their fields are metadata writes through their attach transactions, and build evidence lands in the change record. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree write is `workspace.commit-spec`'s spec copy, an operation-owned commit.) This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/<integration_branch>` and the `docket` branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. 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 d163408ab..ea60c1da8 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 @@ -75,31 +75,19 @@ proposed-only allowlist filtering; a normal invocation that merely names an alre id still skips it (it may belong to a live concurrent run — the run tracker's before-set/dispatch attribution is what distinguishes a resume from claim theft). Then: -1. `plan:` already set and its committed artifact + backlink verify → reuse it and continue at +1. `plan:` set → planning is complete (the attach wrote file and field together); continue at Step 5; **never dispatch a second planner**. -2. `plan:` empty, but the feature branch's latest commit is a clean, single-file plan commit whose - `Docket-Plan-Path:` trailer and backlink agree → recover that path, land it under the normal - field-write rule, and continue at Step 5. -3. The persisted path, commit delta, backlink, and manifest disagree or are ambiguous → halt with - the exact mismatch. **Never guess a plan location and never re-plan** merely because the - parent stopped after the child returned. The trailer is evidence only — subject it to the same - git and backlink verification as a live return. +2. `plan:` unset → re-dispatch the plan-writer. + +Before building continues, re-run `workspace.commit-spec`: after a mid-flight spec revision it +refreshes the copy; otherwise it is a no-op. **The results seam.** The results artifact is required for every change, so a resume -must not lose it. On resume, **load the committed results before starting new work**, and reuse the -`results:` field whenever it is already set — a changed authoring date **never mints a second -file**; the canonical path is chosen once and reused. - -1. `results:` already set and its committed artifact + backlink verify → reuse that path and - continue; **never author a second results file**. -2. `results:` empty, but the owned feature branch holds **exactly one** safe, correctly - backlinked results candidate at the canonical `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` - location (a commit-before-attach interruption) → recover that path and reattach it under the - normal field-write rule. -3. Zero such candidates, more than one, or any that fails path/backlink verification → **surface - the ambiguity and halt**; never guess a path and never fabricate a file. **Never overwrite newer - remote work** — a resume that finds the remote ahead re-reads authority rather than force-writing - its local view. +must not lose it. On resume, **load the attached results from `.docket/<results path>` (after a +re-sync) before starting new work**, and reuse the `results:` path whenever it is set — a changed +authoring date **never mints a second file**; the canonical path is chosen once and reused. +**Never overwrite newer remote work** — a resume that finds the remote ahead re-reads authority +rather than force-writing its local view. **Pre-workspace halt or unsafe-write pause.** When a run halts before the feature workspace exists, or a checkpoint boundary is reached while the ownership or gate-drive contract forbids a safe write, @@ -115,4 +103,6 @@ to force a workspace, a commit, or a HEAD move. **PR-body back-link.** When docket authors the PR body, prepend a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic; skill-side, since the renderer's contract excludes the PR body). Best-effort — never block the PR on it. -**Build-evidence block.** Write the current evidence record into the PR body, marker-bounded, alongside the review outcome — the tier that reviewed, and the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, or recorded. The table's states are defined in `fix-pass.md`; do not redefine them here. The PR body is the block's durable home: `docket-finalize-change` reads it to decide whether its post-rebase suite run can be skipped. Validate marker order and balance before rewriting an existing block. A step-6.5 results commit — now **required** for every change, like any post-gate commit — moves branch HEAD after the evidence was minted, so a stale `head_sha` on that path is EXPECTED, not a defect: write the block anyway with that stale SHA. `docket-finalize-change`'s gate skips its suite run **only** when the rebase was a no-op **and** the PR body carries green build-evidence for the exact current head recorded against the resolved `finalize.test_command`. As that skill states, "**There is no strict-ancestor or results-only skip**." A post-gate results commit moves HEAD past the evidence head, so **expect finalize's suite to run** for it exactly as for any other post-gate commit. +**Review outcome.** The authored body names the tier that reviewed and carries the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, recorded, or reported. The table's states are defined in `fix-pass.md`; do not redefine them here. + +**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the `docket` branch; never author it by hand. 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 949052407..967c87bca 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 @@ -173,7 +173,7 @@ workspace is safe to write — the coordinator persists the returned review find artifact, then updates their **actual** dispositions at a later checkpoint once the fixes return, so that unresolved findings and the fix consequences that matter survive as durable prose rather than living only in chat. This does **not** relocate the disposition table: the **PR body remains the -disposition table's durable home** — the block `docket-finalize-change` reads. During the build the +disposition table's durable home**; the change record holds evidence. During the build the results file may hold the full returned findings, their evidence, and their impact, so they survive a halt before the PR exists; final consolidation condenses them to a one-line review summary plus Known issues entries for the findings not fixed (Step 6.5's *Review findings*). The results file never carries diff --git a/internal/assets/embedded/tree/skills/docket-implement-next/results-template.md b/internal/assets/embedded/tree/skills/docket-implement-next/results-template.md index 33a9a5da2..57069d009 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/results-template.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/results-template.md @@ -1,13 +1,13 @@ <!-- results-template.md — REQUIRED close-out artifact for every implemented change (trivial - included). Authored and consolidated by the coordinator in the FEATURE worktree, - committed on <type>/<slug> at each checkpoint and finally before the implemented transition. + included). Authored and consolidated by the coordinator, written on the docket branch + through change.attach-results at each checkpoint and finally before the implemented transition. Written for a mid-level engineer with little knowledge of Docket internals: lead with what the reader must do and what changed; implementation detail belongs in the linked PR, plan, and evidence. Angle-bracket instructions are authoring guidance only — remove them from actual artifacts. The Human action statement and Outcome are required at finalization; omit any other section, including its subsections, when there is no substantive content. The generated docket:backlink block above the title is owned by the - artifact.backlink operation — never hand-author its markers, and do not add empty header + change.attach-results operation — never hand-author its markers, and do not add empty header fields for unavailable links. Content rules and the checkpoint lifecycle are normative in docket-implement-next's Step 6.5. --> # <Change title> — Results diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index 6a4b26887..a7164f2d5 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -24,7 +24,7 @@ Before anything else in this skill, invoke the `docket-convention` skill via the ### Step 0 — Sync & sweep -Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir <dir> --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the plan + results + code land on the feature branch. +Run the convention's **startup check** (load the convention; run the capability bootstrap; run the `repository.prepare` operation with `--repo-dir <dir> --json` as its own Bash call; validate the protocol-v1 envelope and carry its typed context values forward as literals; act on the disposition). All bookkeeping in this skill (claim, reconcile, `status`, `pr:`, `plan:`, `adrs:`, the plan and results files, build evidence) lands in the `.docket/` worktree on the `docket` branch, pushed immediately; only the spec copy and the code land on the feature branch. **Named invocation — no maintenance preflight.** When this run was invoked with exactly **one explicit change id** — an initial named call, an attributed retry carrying the id, or a named resume of an in-progress change — skip the implementation preflight and Step 1's status-digest certification entirely: after the `repository.prepare` operation succeeds, go **directly** to the authoritative `context.implementation` operation with `--id <id>` and the existing claim/resume path. The named path never runs `maintenance.preflight` or any maintenance sweep first — another change's failing closeout, cleanup, or reclaim can never halt this change before its own claim/gate; anyone who wants those maintenance outcomes runs the explicit `maintenance.preflight` or `maintenance.sweep` operation deliberately, where every problem stays visible. Every local check for the named change is kept — readiness, record revision, dependencies, effective base, claim eligibility, run context, and resume/cancellation — so a malformed, absent, not-ready, or improperly resumed id returns its existing typed refusal, reported with its reason exactly as a scoped-id skip is (ending `drained` when nothing else is in scope); a named request **never falls back to selecting another change**, and shared repository/configuration/transport failures keep the `halted` posture. @@ -75,18 +75,18 @@ The `workspace.prepare` operation reloads the authoritative snapshot, resolves t the `docket` branch only receives bookkeeping commits — it NEVER determines where code branches start; the feature branch is cut from the resolved effective base (`origin/<integration_branch>` in the ordinary case). -**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Confirm the fresh worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id <id> --revision <revision>` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. +**Preparation (parent).** The reconciled **spec is read from the metadata working tree** (**re-sync `.docket/` immediately before reading it**). Then run the `workspace.commit-spec` operation with `--id <id>`: it commits the spec copy as the branch's first commit (a trivial change reports `no-spec`); a `spec-path-occupied` or `workspace-not-ready` refusal halts. Confirm the worktree is clean and record its HEAD as the **pre-dispatch HEAD**. Resolve nothing new: learnings enablement and every repo path come from the startup-check prepared context. Immediately before the long plan dispatch, **refresh the claim** — the `change.refresh-claim` operation with `--id <id> --revision <revision>` re-stamps `claimed_at` (only) and returns the new revision; a refresh `contended` means a newer record owns the change, so it **stops a NEW dispatch** but **never cancels** a child already running whose outcome must still be inspected. <!-- docket:feature-dispatch:start targets=docket-plan-writer --> **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> -It also supplies the change id, title, synchronized change-file (the plan backlink's target) 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 file under `docs/superpowers/plans/` 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), stamps the backlink with the `artifact.backlink` operation (`--artifact <plan repo-relative path> --change <change repo-relative path>`), stages only the plan path, commits on `<type>/<slug>` with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`, and returns the single success line `PLAN_PATH=<repo-relative-path>`. +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 --> -**Verification (parent).** The returned path is a **claim, not proof**. Before attaching the plan, verify from git that: the path is a safe repo-relative path contained by the feature worktree; the file exists, is tracked, and changed after the pre-dispatch HEAD; the worktree is clean; the full branch delta since the pre-dispatch HEAD contains **only the returned plan file**; the plan commit carries **exactly one** `Docket-Plan-Path:` trailer whose value equals the returned path; and the artifact's managed backlink markers are ordered, balanced, and point home to this change. The plan must live under `docs/superpowers/plans/` — `change.attach-plan` accepts no other location. On a malformed return, unsafe path, unexpected delta, dirty worktree, missing commit, or invalid backlink: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's uncommitted output, and never retry with the parent or a weaker model. Once verified, attach it: the `change.attach-plan` operation with `--id <id> --revision <revision> --path <plan path> --commit <feature head>`. Before its transaction `attach-plan` re-verifies the same facts from Git — never from the child return — (head + descends-from-base, a canonical tracked path in the allowed planning directory, the single-artifact delta with `--no-renames` and the `Docket-Plan-Path:` trailer, a balanced backlink targeting this change, no unresolved placeholder), rechecks the exact revision, stores `plan:`, and renders the `## Artifacts` block and inline board atomically. A retry verifies the same plan identity (path + blob at the verified commit) and returns its prior applied outcome, never attaching whatever now occupies the path. The plan **file** merges with the code, so the `plan:` link resolves on the integration branch only after the PR merges (why `docket-status` ignores a missing `plan:` on an `implemented` change). +**Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified and attached, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. The plan's backlink is stamped once here; at close-out `finalize.closeout` re-points the merged copy at the archived record. +**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build @@ -110,24 +110,20 @@ It also contains the branch and base ref, change title and scope, relevant learn ### Step 6.5 — Results (required) -The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` **IN THE FEATURE WORKTREE**; the path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `artifact.backlink` operation — never hand-author its markers. +The results artifact is **required for every change, trivial included** — there is no skip and no close-out condition to weigh. Author `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` from `results-template.md` in a scratch file outside both worktrees; it is written on the `docket` branch, never the feature branch. The path is chosen **once per change and reused on every resume** even when the authoring date has since changed (a changed date never mints a second file). Immediately after the H1 title, a **`**Human action:**` statement** says whether human action is needed — during implementation it may say the assessment is pending, but final results give a settled assessment consistent with the rest of the artifact, and the implemented boundary refuses a missing or empty statement. `## Outcome` is required; every other section is **conditional** — omit an empty heading and its subsections rather than filling it, and never write a `None`/`N/A`/`not applicable` body (whole-section filler refuses at the implemented boundary). The `docket:backlink` block above the title is owned by the `change.attach-results` operation — never hand-author its markers. **Human actions and testing** labels every item **Important** or **Optional**, written for a mid-level engineer with little knowledge of Docket internals. An **Important** item marks a meaningful verification gap or necessary human judgment (never a new automated merge requirement) — give the reason, when the action matters, and what remains uncertain if skipped. An Optional walkthrough MAY exercise behavior automated tests already cover — a routine suite rerun is not a functional walkthrough. Every item carries prerequisites and setup, concrete steps with observable expected results, and cleanup when it changes persistent state — complete enough to run without reconstructing missing commands. Do not infer coverage from a green suite: inspect the relevant tests and evidence, and where coverage is genuinely uncertain report that uncertainty under Known issues and follow-ups or Verification performed rather than authoring a speculative checklist. The section holds only what a human should do or check — never a record of what the run already checked, which belongs under Verification performed. -**Checkpoint lifecycle.** The coordinator captures the findings it already knows at each evaluated boundary: **(i)** after the build role returns — prefer consolidating **before** the existing end-of-build gate so that gate tests the checkpoint-containing head; **(ii)** after review returns, and again after fixes with their **actual** dispositions; **(iii)** before a planned pause, halt, or handoff, but **only** when the ownership and gate-drive contracts permit a safe write; **(iv)** at final consolidation, before the implemented transition. A checkpoint is an **evaluated boundary, not a scheduled commit**: unchanged substantive content is a no-op — no empty commits, no per-observation commits, and **never commit or move HEAD beneath a live gate, a running worker, or a transferred drive**. +**Checkpoint lifecycle.** The coordinator captures the findings it already knows at each evaluated boundary: **(i)** after the build role returns; **(ii)** after review returns, and again after fixes with their **actual** dispositions; **(iii)** before a planned pause, halt, or handoff, but **only** when the ownership and gate-drive contracts permit a safe write; **(iv)** at final consolidation, before the implemented transition. A checkpoint is an **evaluated boundary, not a scheduled commit**: unchanged substantive content is a no-op — no empty commits, no per-observation commits, and **never commit or move HEAD beneath a live gate, a running worker, or a transferred drive**. **Per-checkpoint mechanics** (in order): 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. -2. Read and **preserve** the prior results content; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). -3. Write the update, then stamp and validate the back-link home with the `artifact.backlink` operation (`--artifact <results repo-relative path> --change <change repo-relative path>`). -4. Commit **ONLY the results path** on `<type>/<slug>` — stage by explicit path. -5. Publish through the existing feature-branch transport, **never forced**. -6. On first creation, attach via the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --commit <feature head>` (the same verification as `attach-plan`), landing the `results:` FIELD on the `docket` branch (the file rides the feature branch, the field lands via the transaction — the same split as `plan:`); later updates keep the **same path** and revalidate/reattach before completion. +2. Read and **preserve** the prior results content — after a re-sync, from `.docket/<results path>` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). +3. Write the update into the scratch file. +4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>` writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. -Local commit, remote publication, and metadata attachment are **distinct facts** — a denied push is not remote durability. On a denied push, keep the local commit and its diagnostic and follow the existing halt posture; never report the checkpoint durable on the strength of a local commit alone. - -**Evidence sequencing.** Results prose is **never gate evidence**. Batch the review/fix checkpoint edits before the final certification run; then, after the final authored results are committed, obtain gate evidence for **that exact head** through the gate driver. A post-gate results edit invalidates stale evidence and can require another certification run — say so in the report. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. +**Evidence sequencing.** Results prose is **never gate evidence**, and a results checkpoint never moves HEAD, so it never stales evidence. Obtain gate evidence for the final code head through the gate driver; any code commit (a fix) still invalidates it. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. **Known issues and follow-ups** merges unresolved findings, limitations, and follow-up work into one plain-language section — each entry gives when the problem occurs and what the person experiences, its practical impact, whether it is confirmed or suspected, any workaround, and the suggested next action. Follow-ups recorded there hold out-of-scope work for **human triage** — final consolidation checks each against the backlog and names any match (*Backlog match* below), and the run **never** mints a change, issue, ADR, or learning automatically. An in-scope defect stays fix/block/halt work regardless of where it is written. Final consolidation verifies that no material coordinator handoff remains only in chat, and the final report links the durable results file. @@ -141,11 +137,11 @@ Local commit, remote publication, and metadata attachment are **distinct facts** **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 three docket elements: the best-effort `#<issue>` reference (never `Closes #N`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the **build-evidence block** — the current evidence record written marker-bounded (`<!-- docket:build-evidence:start -->` / `<!-- docket:build-evidence:end -->`) alongside the review outcome, carrying its actual result — `green`, or `skipped` / `build-gate-off` under `build.gate: off` — never overstated, the block `docket-finalize-change` reads to decide whether its post-rebase suite run can be skipped. Before assembling any of the three, **read `references/edge-paths.md` now (blocking)** — it owns the mechanics, including marker validation and the expected-staleness rule for a post-gate `head_sha`. +**PR-body assembly.** The authored PR body carries the best-effort `#<issue>` reference (never `Closes #N`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the review disposition table; `pr.publish` adds 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. -**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 build-evidence 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. +**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. -**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 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 + `## 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. +**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. @@ -153,16 +149,16 @@ Local commit, remote publication, and metadata attachment are **distinct facts** ### Step postconditions -Each step below is complete only when its row holds — read from **git**, never from a sub-skill's report or its own narration. One qualification: the build-evidence record of rows 5–6 is an in-context artifact, not a git object, so only its `head_sha` == HEAD condition is a git fact — load-bearing, not decorative. The conditions are **cumulative**: each holds in addition to every earlier step's, each read **as of the close of its own step** — a later commit moving branch HEAD (Step 6.5's results file) leaves an earlier row's `head_sha` stale, which `references/edge-paths.md` calls expected, not a defect. These certify a **step**, never the run. **Once a change is claimed, and absent a `halted` disposition or a Step-3 kill, the only postcondition that also completes the run is Step 7's** — a satisfied intermediate row is never licence to stop. A run that ends any other way ends on a **disposition**, not on a postcondition. +Each step below is complete only when its row holds — read from **git**, never from a sub-skill's report or its own narration. One qualification: the build-evidence record of rows 5–6 is an in-context artifact, not a git object, so only its `head_sha` == HEAD condition is a git fact — load-bearing, not decorative. The conditions are **cumulative**: each holds in addition to every earlier step's, each read **as of the close of its own step**. These certify a **step**, never the run. **Once a change is claimed, and absent a `halted` disposition or a Step-3 kill, the only postcondition that also completes the run is Step 7's** — a satisfied intermediate row is never licence to stop. A run that ends any other way ends on a **disposition**, not on a postcondition. | Step | Complete only when | |---|---| | 2 Claim | `status: in-progress` + `branch:` + `updated:` + `claimed_at:` committed on the `docket` branch **and landed** (local tip == remote tip). | | 3 Reconcile | `reconciled: true` and a dated `## Reconcile log` entry landed on the `docket` branch — or, on the kill path, the change archived. | -| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the plan file **and** its `docket:backlink` stamp committed on `<type>/<slug>`, **and** `plan:` landed on the `docket` branch — a two-tree condition, both refs read. | +| 4 Worktree + plan | Step 3's push SHA-confirmed **before** the branch is cut; then the spec copy committed as the first commit on `<type>/<slug>` (non-trivial changes), then `plan:` and the plan file with its backlink landed on the `docket` branch, the feature HEAD unchanged by planning. | | 5 Build | the executed plan committed on `<type>/<slug>`, with a build-evidence record at `result: green` (or `skipped` / `build-gate-off` under `build.gate: off`) whose `head_sha` **equals branch HEAD**. | | 6 Review + ADRs | that record still present at `head_sha` == HEAD **after** any fix commits, and every ADR the run produced landed in `adrs:`. Known-weak row: on a clean review this reduces to Step 5's, because whether a reviewer ran is not a fact about git. | -| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink stamp committed on `<type>/<slug>`. | +| 7 PR + stop | the branch pushed (`origin/<type>/<slug>` resolves), the PR open, and `status: implemented` + `pr:` landed on the `docket` branch; `results:` set and the final results file with its backlink on the `docket` branch, and the record's `## Build evidence` section naming the PR head. | Rows 3, 4, 6 and 7 each carry, in addition, the **field-write rule**'s conditions on every metadata commit they name: the `claimed_at` re-stamp, and — for any link-bearing field write — the regenerated `## Artifacts` block in the same commit. @@ -209,8 +205,8 @@ Every change-file field write in this run (claim's `status:`/`branch:`, reconcil ### Feature branch invariants -New change ⇒ the `workspace.prepare` operation with `--id <id> --revision <revision>`, which cuts `<type>/<slug>` from the resolved effective base — `origin/<integration_branch>` in the ordinary case, except for a change carrying `stacked_on:`, whose base is the parent's effective base per the convention's *Branch model* exception and its stacked-changes reference. The workspace is prepared AFTER claim + reconcile, adds only plan + results + code, and **never modifies** docket metadata (the change file, `BOARD.md`, ADRs) — at merge, the 3-way merge takes the integration branch's side for the change file unconditionally, so there is no conflict and no revert needed. (The change file may not even exist on `origin/<integration_branch>`.) +New change ⇒ the `workspace.prepare` operation with `--id <id> --revision <revision>`, which cuts `<type>/<slug>` from the resolved effective base — `origin/<integration_branch>` in the ordinary case, except for a change carrying `stacked_on:`, whose base is the parent's effective base per the convention's *Branch model* exception and its stacked-changes reference. The workspace is prepared AFTER claim + reconcile, adds only the spec copy + code, and **never modifies** docket metadata (the change file, `BOARD.md`, ADRs) — at merge, the 3-way merge takes the integration branch's side for the change file unconditionally, so there is no conflict and no revert needed. (The change file may not even exist on `origin/<integration_branch>`.) -Metadata commits (per the **field-write rule**) happen in the metadata working tree; code and plan/results *file* commits happen in the **feature worktree** on `<type>/<slug>` — the `plan:`/`results:` *fields* are always written on the `docket` branch, never the feature worktree. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree touch is step 4's spec **read** — a read across trees, never a metadata write into the feature tree.) +Metadata commits (per the **field-write rule**) happen in the metadata working tree; only code commits happen in the **feature worktree** on `<type>/<slug>` — the plan and results files and their fields are metadata writes through their attach transactions, and build evidence lands in the change record. Never cross these streams — a metadata write landing in the feature worktree silently diverges or conflicts at merge. (The single deliberate cross-tree write is `workspace.commit-spec`'s spec copy, an operation-owned commit.) This skill is safe to invoke from any branch: it `git fetch`es and operates against `origin/<integration_branch>` and the `docket` branch explicitly; the branch the human happened to be on when they typed the command is irrelevant. diff --git a/skills/docket-implement-next/references/edge-paths.md b/skills/docket-implement-next/references/edge-paths.md index d163408ab..ea60c1da8 100644 --- a/skills/docket-implement-next/references/edge-paths.md +++ b/skills/docket-implement-next/references/edge-paths.md @@ -75,31 +75,19 @@ proposed-only allowlist filtering; a normal invocation that merely names an alre id still skips it (it may belong to a live concurrent run — the run tracker's before-set/dispatch attribution is what distinguishes a resume from claim theft). Then: -1. `plan:` already set and its committed artifact + backlink verify → reuse it and continue at +1. `plan:` set → planning is complete (the attach wrote file and field together); continue at Step 5; **never dispatch a second planner**. -2. `plan:` empty, but the feature branch's latest commit is a clean, single-file plan commit whose - `Docket-Plan-Path:` trailer and backlink agree → recover that path, land it under the normal - field-write rule, and continue at Step 5. -3. The persisted path, commit delta, backlink, and manifest disagree or are ambiguous → halt with - the exact mismatch. **Never guess a plan location and never re-plan** merely because the - parent stopped after the child returned. The trailer is evidence only — subject it to the same - git and backlink verification as a live return. +2. `plan:` unset → re-dispatch the plan-writer. + +Before building continues, re-run `workspace.commit-spec`: after a mid-flight spec revision it +refreshes the copy; otherwise it is a no-op. **The results seam.** The results artifact is required for every change, so a resume -must not lose it. On resume, **load the committed results before starting new work**, and reuse the -`results:` field whenever it is already set — a changed authoring date **never mints a second -file**; the canonical path is chosen once and reused. - -1. `results:` already set and its committed artifact + backlink verify → reuse that path and - continue; **never author a second results file**. -2. `results:` empty, but the owned feature branch holds **exactly one** safe, correctly - backlinked results candidate at the canonical `<results_dir>/<YYYY-MM-DD>-<slug>-results.md` - location (a commit-before-attach interruption) → recover that path and reattach it under the - normal field-write rule. -3. Zero such candidates, more than one, or any that fails path/backlink verification → **surface - the ambiguity and halt**; never guess a path and never fabricate a file. **Never overwrite newer - remote work** — a resume that finds the remote ahead re-reads authority rather than force-writing - its local view. +must not lose it. On resume, **load the attached results from `.docket/<results path>` (after a +re-sync) before starting new work**, and reuse the `results:` path whenever it is set — a changed +authoring date **never mints a second file**; the canonical path is chosen once and reused. +**Never overwrite newer remote work** — a resume that finds the remote ahead re-reads authority +rather than force-writing its local view. **Pre-workspace halt or unsafe-write pause.** When a run halts before the feature workspace exists, or a checkpoint boundary is reached while the ownership or gate-drive contract forbids a safe write, @@ -115,4 +103,6 @@ to force a workspace, a commit, or a HEAD move. **PR-body back-link.** When docket authors the PR body, prepend a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic; skill-side, since the renderer's contract excludes the PR body). Best-effort — never block the PR on it. -**Build-evidence block.** Write the current evidence record into the PR body, marker-bounded, alongside the review outcome — the tier that reviewed, and the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, or recorded. The table's states are defined in `fix-pass.md`; do not redefine them here. The PR body is the block's durable home: `docket-finalize-change` reads it to decide whether its post-rebase suite run can be skipped. Validate marker order and balance before rewriting an existing block. A step-6.5 results commit — now **required** for every change, like any post-gate commit — moves branch HEAD after the evidence was minted, so a stale `head_sha` on that path is EXPECTED, not a defect: write the block anyway with that stale SHA. `docket-finalize-change`'s gate skips its suite run **only** when the rebase was a no-op **and** the PR body carries green build-evidence for the exact current head recorded against the resolved `finalize.test_command`. As that skill states, "**There is no strict-ancestor or results-only skip**." A post-gate results commit moves HEAD past the evidence head, so **expect finalize's suite to run** for it exactly as for any other post-gate commit. +**Review outcome.** The authored body names the tier that reviewed and carries the **findings disposition table**: one row per finding, each marked fixed (with its commit SHA), deferred, reverted, recorded, or reported. The table's states are defined in `fix-pass.md`; do not redefine them here. + +**Plan/results links.** `pr.publish` adds a Docket-owned links block pointing at the plan and results files on the `docket` branch; never author it by hand. diff --git a/skills/docket-implement-next/references/fix-pass.md b/skills/docket-implement-next/references/fix-pass.md index 949052407..967c87bca 100644 --- a/skills/docket-implement-next/references/fix-pass.md +++ b/skills/docket-implement-next/references/fix-pass.md @@ -173,7 +173,7 @@ workspace is safe to write — the coordinator persists the returned review find artifact, then updates their **actual** dispositions at a later checkpoint once the fixes return, so that unresolved findings and the fix consequences that matter survive as durable prose rather than living only in chat. This does **not** relocate the disposition table: the **PR body remains the -disposition table's durable home** — the block `docket-finalize-change` reads. During the build the +disposition table's durable home**; the change record holds evidence. During the build the results file may hold the full returned findings, their evidence, and their impact, so they survive a halt before the PR exists; final consolidation condenses them to a one-line review summary plus Known issues entries for the findings not fixed (Step 6.5's *Review findings*). The results file never carries diff --git a/skills/docket-implement-next/results-template.md b/skills/docket-implement-next/results-template.md index 33a9a5da2..57069d009 100644 --- a/skills/docket-implement-next/results-template.md +++ b/skills/docket-implement-next/results-template.md @@ -1,13 +1,13 @@ <!-- results-template.md — REQUIRED close-out artifact for every implemented change (trivial - included). Authored and consolidated by the coordinator in the FEATURE worktree, - committed on <type>/<slug> at each checkpoint and finally before the implemented transition. + included). Authored and consolidated by the coordinator, written on the docket branch + through change.attach-results at each checkpoint and finally before the implemented transition. Written for a mid-level engineer with little knowledge of Docket internals: lead with what the reader must do and what changed; implementation detail belongs in the linked PR, plan, and evidence. Angle-bracket instructions are authoring guidance only — remove them from actual artifacts. The Human action statement and Outcome are required at finalization; omit any other section, including its subsections, when there is no substantive content. The generated docket:backlink block above the title is owned by the - artifact.backlink operation — never hand-author its markers, and do not add empty header + change.attach-results operation — never hand-author its markers, and do not add empty header fields for unavailable links. Content rules and the checkpoint lifecycle are normative in docket-implement-next's Step 6.5. --> # <Change title> — Results From 0c1d0e504052316730fd740cdf84d0dfc76abed1 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:52:58 -0400 Subject: [PATCH 16/28] docs(skills): the plan-writer attaches on the metadata branch; evidence lives in the record --- agents/docket-plan-writer.md | 15 ++++---- cursor-rules/dispatch/docket-plan-writer.md | 16 ++++----- internal/assets/embedded/manifest.json | 34 +++++++++--------- .../tree/agents/docket-plan-writer.md | 15 ++++---- .../dispatch/docket-plan-writer.md | 16 ++++----- .../tree/skills/docket-build/SKILL.md | 11 +++--- .../tree/skills/docket-convention/SKILL.md | 36 +++++++++---------- .../docket-convention/references/close-out.md | 27 +++++++------- .../references/stacked-changes.md | 2 +- .../skills/docket-finalize-change/SKILL.md | 12 +++---- .../tree/skills/docket-status/SKILL.md | 2 +- .../testdata/golden/docket-plan-writer.md | 15 ++++---- .../testdata/golden/docket-plan-writer.toml | 15 ++++---- .../testdata/golden/docket-plan-writer.md | 15 ++++---- .../testdata/golden/docket-plan-writer.md | 15 ++++---- internal/repoguard/budgets_test.go | 4 +-- internal/repoguard/prose_contracts_test.go | 6 ++-- skills/docket-build/SKILL.md | 11 +++--- skills/docket-convention/SKILL.md | 36 +++++++++---------- .../docket-convention/references/close-out.md | 27 +++++++------- .../references/stacked-changes.md | 2 +- skills/docket-finalize-change/SKILL.md | 12 +++---- skills/docket-status/SKILL.md | 2 +- 23 files changed, 174 insertions(+), 172 deletions(-) diff --git a/agents/docket-plan-writer.md b/agents/docket-plan-writer.md index 80c49be56..159faac32 100644 --- a/agents/docket-plan-writer.md +++ b/agents/docket-plan-writer.md @@ -1,19 +1,20 @@ --- name: docket-plan-writer -description: Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan's repo-relative path. Not invoked directly by a human. +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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. diff --git a/cursor-rules/dispatch/docket-plan-writer.md b/cursor-rules/dispatch/docket-plan-writer.md index 655c44673..d94faef5b 100644 --- a/cursor-rules/dispatch/docket-plan-writer.md +++ b/cursor-rules/dispatch/docket-plan-writer.md @@ -5,17 +5,17 @@ plan step, not invoked directly. If asked to author the implementation plan for dispatch it. Dispatch to the subagent `docket-plan-writer`, foreground, using this mode's subagent-launch -mechanism. The prompt must include the change id, title, and synchronized change-file path, the -synchronized spec path, the resolved plan and build skill names, the learnings index path when -learnings are enabled, and the feature worktree the branch is checked out in together with its -pre-dispatch HEAD — this agent is feature-scoped, and a delegated dispatch that names no worktree -is refused. The agent commits the plan artifact with its backlink and a `Docket-Plan-Path` trailer -on the feature branch, performs no docket metadata mutation, and returns only the plan's -repo-relative path as `PLAN_PATH=<repo-relative-path>`. +mechanism. The prompt must include the change id, title, exact record revision, and synchronized +change-file path, the synchronized spec path, the resolved plan and build skill names, the +learnings index path when learnings are enabled, and the feature worktree the branch is checked out +in together with its pre-dispatch HEAD — this agent is feature-scoped, and a delegated dispatch that +names no worktree is refused. The agent writes the plan artifact with its backlink on the metadata +branch through its one docket operation, `change.attach-plan`, writes nothing in either worktree, +and returns only the plan's repo-relative path as `PLAN_PATH=<repo-relative-path>`. Do NOT write the plan inline in the parent. One concrete call, as an illustration of the shape — not the contract: Task(subagent_type: "docket-plan-writer", run_in_background: false, - prompt: "Author the implementation plan and commit it on the feature branch.") + prompt: "Author the implementation plan and attach it on the metadata branch.") diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 53649c13a..9e9fe5ce2 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:eb96161ad81b8b1f8eb968a79bfb652d521b7737ea8077a712d1296597fbc033", + "asset_set_id": "sha256:29038749d4581e85a127b62c3c47afde32c5722288c9f54547349fd19ad00781", "entries": [ { "path": ".docket.example.yml", @@ -91,8 +91,8 @@ "path": "agents/docket-plan-writer.md", "role": "agent-source", "mode": 420, - "size": 3164, - "sha256": "689917c0c0cd6b905fe4570b33853f89278b38da2bc108acddd02b28d7740fe1" + "size": 3053, + "sha256": "2124b9eb0c33b1b17245b1b39d39be9f5a3de1e7a6aa2042fea0e30836404a9a" }, { "path": "agents/docket-rebase-resolver.md", @@ -224,8 +224,8 @@ "path": "cursor-rules/dispatch/docket-plan-writer.md", "role": "dispatch", "mode": 420, - "size": 1252, - "sha256": "ff944801f518d93019d2d2be4380941ea513bfe947319e72efb88b5b37474213" + "size": 1295, + "sha256": "cf1906d4a7bf5cbdc9cad21e9bfb0d90a3dfb2b6152ff3cc7d825219778acaf9" }, { "path": "cursor-rules/dispatch/docket-rebase-resolver.md", @@ -301,8 +301,8 @@ "path": "skills/docket-build/SKILL.md", "role": "skill", "mode": 420, - "size": 25626, - "sha256": "8afafd93471424aa855ccf723697126de9c3156ad3a425e271e4e87f82fb7245" + "size": 25642, + "sha256": "34e10719f0e1cdef7d3dc1905401d3e49ae3ad7b8acb8483c2681c5d219a1c57" }, { "path": "skills/docket-build/references/gate-driver.md", @@ -322,8 +322,8 @@ "path": "skills/docket-convention/SKILL.md", "role": "skill", "mode": 420, - "size": 51082, - "sha256": "8cd881b6e1046637b8a73dd4113f78a5fa75a4f4eb480959b7c9ebc55c18bd77" + "size": 51338, + "sha256": "47ffa6f66686b4bb0df5ca96455e6656d05cf6e24b0ec9679137970cf431e163" }, { "path": "skills/docket-convention/references/agent-layer.md", @@ -336,8 +336,8 @@ "path": "skills/docket-convention/references/close-out.md", "role": "skill", "mode": 420, - "size": 9562, - "sha256": "2cff2d3809342e07e4c2ada532ebf354d6b70b6819aeca8de098bcc22b710a4e" + "size": 9515, + "sha256": "0273fcd39f934d974fa20f23a07893745f1fe1e27178ad33c3c9c71e4eb239e4" }, { "path": "skills/docket-convention/references/learnings.md", @@ -350,15 +350,15 @@ "path": "skills/docket-convention/references/stacked-changes.md", "role": "skill", "mode": 420, - "size": 14032, - "sha256": "8e7ae8ecb39406ca121c850cc4ee3cef26a709a8883ed52bbb468e8298031f41" + "size": 14033, + "sha256": "ad1c0dceb7f68584bfe272d66d1967ebdc6a10fa00b491336d11396307ef02a9" }, { "path": "skills/docket-finalize-change/SKILL.md", "role": "skill", "mode": 420, - "size": 41614, - "sha256": "e09de679804ee2341eff76c6803a620e4577c52e1d5717dac4b943c21e3b7e1c" + "size": 41498, + "sha256": "ef64a37b1002b7dd45061665a5ba3f8675bd29b1ba472164abf296bede016cea" }, { "path": "skills/docket-finalize-change/references/gate-failure.md", @@ -420,8 +420,8 @@ "path": "skills/docket-status/SKILL.md", "role": "skill", "mode": 420, - "size": 19720, - "sha256": "0f1a406746c0174c5da6968b6758b5ea56ba271b4b996d35c01f8124bb32d18a" + "size": 19818, + "sha256": "ba45dd2538b3805c20fa75181fb2e03441f1babb646d5875be7b9aed071729a5" } ] } diff --git a/internal/assets/embedded/tree/agents/docket-plan-writer.md b/internal/assets/embedded/tree/agents/docket-plan-writer.md index 80c49be56..159faac32 100644 --- a/internal/assets/embedded/tree/agents/docket-plan-writer.md +++ b/internal/assets/embedded/tree/agents/docket-plan-writer.md @@ -1,19 +1,20 @@ --- name: docket-plan-writer -description: Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan's repo-relative path. Not invoked directly by a human. +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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. diff --git a/internal/assets/embedded/tree/cursor-rules/dispatch/docket-plan-writer.md b/internal/assets/embedded/tree/cursor-rules/dispatch/docket-plan-writer.md index 655c44673..d94faef5b 100644 --- a/internal/assets/embedded/tree/cursor-rules/dispatch/docket-plan-writer.md +++ b/internal/assets/embedded/tree/cursor-rules/dispatch/docket-plan-writer.md @@ -5,17 +5,17 @@ plan step, not invoked directly. If asked to author the implementation plan for dispatch it. Dispatch to the subagent `docket-plan-writer`, foreground, using this mode's subagent-launch -mechanism. The prompt must include the change id, title, and synchronized change-file path, the -synchronized spec path, the resolved plan and build skill names, the learnings index path when -learnings are enabled, and the feature worktree the branch is checked out in together with its -pre-dispatch HEAD — this agent is feature-scoped, and a delegated dispatch that names no worktree -is refused. The agent commits the plan artifact with its backlink and a `Docket-Plan-Path` trailer -on the feature branch, performs no docket metadata mutation, and returns only the plan's -repo-relative path as `PLAN_PATH=<repo-relative-path>`. +mechanism. The prompt must include the change id, title, exact record revision, and synchronized +change-file path, the synchronized spec path, the resolved plan and build skill names, the +learnings index path when learnings are enabled, and the feature worktree the branch is checked out +in together with its pre-dispatch HEAD — this agent is feature-scoped, and a delegated dispatch that +names no worktree is refused. The agent writes the plan artifact with its backlink on the metadata +branch through its one docket operation, `change.attach-plan`, writes nothing in either worktree, +and returns only the plan's repo-relative path as `PLAN_PATH=<repo-relative-path>`. Do NOT write the plan inline in the parent. One concrete call, as an illustration of the shape — not the contract: Task(subagent_type: "docket-plan-writer", run_in_background: false, - prompt: "Author the implementation plan and commit it on the feature branch.") + prompt: "Author the implementation plan and attach it on the metadata branch.") diff --git a/internal/assets/embedded/tree/skills/docket-build/SKILL.md b/internal/assets/embedded/tree/skills/docket-build/SKILL.md index 622683673..db0af4c02 100644 --- a/internal/assets/embedded/tree/skills/docket-build/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-build/SKILL.md @@ -20,7 +20,7 @@ gets exactly one fresh worker dispatch unless that worker requests its single al ## Inputs - The **plan** `docket-implement-next` Step 4 wrote, at the path recorded in the change's `plan:` - field and committed on the feature branch. + field, read from the metadata worktree at `.docket/<plan path>`. - The **feature branch and worktree** already cut for this change, plus that repo's own instruction files (`AGENTS.md`, `CLAUDE.md`, nested equivalents). - The plan's `### Task N` headings, the **unit of dispatch** — one heading, one worker, one @@ -250,8 +250,8 @@ ran_at: <UTC ISO-8601> `head_sha` and `ran_at`. The record certifies the branch so the review step need not re-run the suite; `docket-implement-next` -Step 6 validates it and Step 7 writes it into the PR body, then runs the review role (`docket-review`) -once over the whole branch. Only a green run — or an explicit `build_gate: off` — mints a +Step 6 validates it, then runs the review role (`docket-review`) +once over the whole branch; Step 7's `change.mark-implemented` records it in the change record. Only a green run — or an explicit `build_gate: off` — mints a record: a red suite mints no evidence record at all, and enters the repair path below. **Red** → the build **never invokes review**. `build_max_attempts` (from the implementation context, @@ -350,8 +350,7 @@ When docket-build runs as the invoked build role for a coordinator that owns a r the controller **may** perform the build-findings checkpoint on that coordinator's behalf, under the explicit caller contract that grants it that authority — consolidating the build findings available at this point (the material TDD exceptions, residual risks, and worker-surfaced findings -above) before the final full-suite gate where the ordering permits, so the gate certifies the -checkpoint-containing head. **Task workers never edit the results file**, there are **never +above) through `change.attach-results`, a metadata commit that never moves the feature head. **Task workers never edit the results file**, there are **never concurrent writers**, and **a checkpoint never independently launches tests** — it records what is already known and moves no gate of its own. A custom build skill bound in this role's place owns none of this: it returns its findings through its own contract, and the coordinator captures them at @@ -380,5 +379,5 @@ Emit concise, stable lines and nothing more: task-to-tier selection and reason; reason; worker outcome and commit; focused verification; full-suite command and result; the build-evidence record on green; the terminal build disposition (**role-scoped** — a build disposition, never a run disposition). Write no verbose task artifact; material TDD exceptions, -residual risks, and worker-surfaced findings flow to the coordinator's results artifact (and the PR description where evidence belongs), not into per-task +residual risks, and worker-surfaced findings flow to the coordinator's results artifact (and the change record's build evidence), not into per-task files. diff --git a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md index 01aacd46d..8b0192e59 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-convention/SKILL.md @@ -55,7 +55,7 @@ Every operation reads the committed `.docket.yml` from the fetched tip of origin This resolution — read configuration, apply defaults, resolve `integration_branch` — runs inside the **`repository.prepare`** operation (the *startup check*), which returns the resolved values in its typed context. -**Reaching docket's operations.** Maintained skills reach every docket operation through the native `docket` binary on `PATH`, but never by hard-coding its argv: each skill resolves the executable spelling from the capability catalog (`docket capabilities --json` — the one spelling this convention permits a skill to hard-code) and constructs the invocation from the entry for the semantic operation it names (`repository.prepare`, `maintenance.sweep`, `status`, `change.*`, `context.*`, `adr.*`, `artifact.backlink`, …) — never a shell facade, and never a `DOCKET_*` transport variable resolved into a command. An operation's **request/result payload shape** resolves the same way — per-operation, from the catalog-resolved **`schema`** operation, validated fail-closed as the capability bootstrap is (refuse `protocol_version` ≠ 1 or an unsupported `schema_version`, a malformed envelope, or an id the surface does not carry) — building the body from the descriptor's real JSON keys, required markers, and named vocabularies, **never** from `--help`, `strings`, the source tree, or a probe. The two bootstrap remedies a **human** types — `docket repository migrate` and `docket repository init` — are the deliberate exception: they run before any catalog exists to resolve against, are human-initiated, and are never an agent runtime invocation. If the `docket` binary is missing from `PATH`, the install is broken — stop and fix it, never silently degrade to hand-worked operations. Every env var docket introduces is **DOCKET_-namespaced**. +**Reaching docket's operations.** Maintained skills reach every docket operation through the native `docket` binary on `PATH`, but never by hard-coding its argv: each skill resolves the executable spelling from the capability catalog (`docket capabilities --json` — the one spelling this convention permits a skill to hard-code) and constructs the invocation from the entry for the semantic operation it names (`repository.prepare`, `maintenance.sweep`, `status`, `change.*`, `context.*`, `adr.*`, …) — never a shell facade, and never a `DOCKET_*` transport variable resolved into a command. An operation's **request/result payload shape** resolves the same way — per-operation, from the catalog-resolved **`schema`** operation, validated fail-closed as the capability bootstrap is (refuse `protocol_version` ≠ 1 or an unsupported `schema_version`, a malformed envelope, or an id the surface does not carry) — building the body from the descriptor's real JSON keys, required markers, and named vocabularies, **never** from `--help`, `strings`, the source tree, or a probe. The two bootstrap remedies a **human** types — `docket repository migrate` and `docket repository init` — are the deliberate exception: they run before any catalog exists to resolve against, are human-initiated, and are never an agent runtime invocation. If the `docket` binary is missing from `PATH`, the install is broken — stop and fix it, never silently degrade to hand-worked operations. Every env var docket introduces is **DOCKET_-namespaced**. **`board_surfaces` — the board as 0..n derived views.** The board is a *derived view* over the change files; `board_surfaces` lists which surfaces to render. `inline` (the committed, offline-safe `BOARD.md`) is the one supported surface; default `[inline]`. **`[]` disables the board entirely** — the change files plus git history remain fully authoritative. An unknown token is warned-and-ignored (a typo must never abort a build). @@ -105,11 +105,11 @@ The *Skill layer*'s **missing-skill rule** and `halt` are two conditions with tw ### Agent layer — model/effort-pinned subagents -Each **autonomous** docket skill can run as a model/effort-pinned **subagent** instead of inline at the session model. The autonomous skills get a wrapper — `docket-implement-next`, `docket-auto-groom`, `docket-finalize-change`, `docket-status`, `docket-adr`, `docket-build-task` (shared by its four tier agents), and `docket-review` (shared by its three tier wrappers); the two **interactive** skills (`docket-new-change`, `docket-groom-next`) stay inline and only surface an **advisory** recommended model/effort at startup. `docket-convention` is not an agent — it is listed in the skills list of every wrapper except these: `docket-brainstorm-consultant` (ADR-0022), **`docket-plan-writer`**, the `docket-build-*` tier workers, and the `docket-review-*` tier wrappers. These wrapper-bearing exceptions perform no docket metadata operations — a boundary each worker contract's own *Scope* states normatively; the consultant wraps nothing at all (see *Composition*). +Each **autonomous** docket skill can run as a model/effort-pinned **subagent** instead of inline at the session model. The autonomous skills get a wrapper — `docket-implement-next`, `docket-auto-groom`, `docket-finalize-change`, `docket-status`, `docket-adr`, `docket-build-task` (shared by its four tier agents), and `docket-review` (shared by its three tier wrappers); the two **interactive** skills (`docket-new-change`, `docket-groom-next`) stay inline and only surface an **advisory** recommended model/effort at startup. `docket-convention` is not an agent — it is listed in the skills list of every wrapper except these: `docket-brainstorm-consultant` (ADR-0022), **`docket-plan-writer`**, the `docket-build-*` tier workers, and the `docket-review-*` tier wrappers. Of these wrapper-bearing exceptions the plan-writer performs exactly one docket metadata operation (`change.attach-plan`) and the others perform none — a boundary each worker contract's own *Scope* states normatively; the consultant wraps nothing at all (see *Composition*). A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on `<type>/<slug>`** (the committed plan + backlink + `Docket-Plan-Path:` trailer), which the parent verifies before attaching; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -120,7 +120,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: | Role | Skill | Invoked by | Final artifact — stop-point | |---|---|---|---| | brainstorm | `superpowers:brainstorming` | `docket-new-change` §2, `docket-groom-next` | a spec file at the configured spec path; stop at the spec | -| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the feature branch, recorded in `plan:` | +| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the `docket` branch, recorded in `plan:` | | build | `docket-build` | `docket-implement-next` §5 | the plan executed on the feature branch | | review | `docket-review` | `docket-implement-next` §6 | a whole-branch review before the PR opens, over a branch whose build evidence is green | @@ -154,7 +154,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: <adrs_dir>/ # default docs/adrs/ — flat; ADRs are NEVER archived <NNNN>-<slug>.md # immutable once Accepted (only its status: line ever changes) README.md # generated ADR index -<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (feature-branch build files; NEVER archived) +<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the `docket` branch; NEVER archived) <YYYY-MM-DD>-<slug>-results.md ``` @@ -162,7 +162,7 @@ The `archive/` filename date prefix is **UTC**: the **merge commit's** date for All of the above lives on the `docket` branch, written through the persistent, gitignored **`.docket/` metadata worktree** (deliberately not under `.worktrees/` — slug collisions, prune blast radius; see *Branch model*). -**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the feature branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `results:` **field** is set only in the main tree through the `change.attach-results` operation — never cross the streams. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. +**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. ### Change manifest (frontmatter at the top of each change file) @@ -184,8 +184,8 @@ related: [4, 6] # cross-links the reconcile pass reads discovered_from: [62] # change id(s) whose work surfaced this one; informational like related:, never a readiness gate adrs: [24] # ADRs this change cites or produces spec: # superpowers design doc path; set at brainstorm (propose) time, on the `docket` branch -plan: # plan FILE lives on the feature branch; this FIELD is set in the main tree at build time -results: # results FILE on the feature branch; this FIELD set by change.attach-results at the first checkpoint, required before the implemented transition +plan: # plan FILE on the `docket` branch; FILE and FIELD written together by change.attach-plan at build time +results: # results FILE on the `docket` branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition trivial: false # true = no spec needed (small mechanical change); still build-ready auto_groomable: # `true` makes a stub auto-groomable; unset or `false` means not branch_prefix: # optional one unqualified branch-path component; overrides <type> at mint, consumed only at claim, inert once branch: is populated, survives reclaim @@ -201,31 +201,31 @@ claimed_at: # UTC ISO-8601 claim lease (YYYY-MM-DDTHH:MM:SSZ); sta the sole source of truth for that relationship — the parent-side link is derived at render time — and is never copied into `related:` or `depends_on:`. -**Merged plans and results are frozen build records.** Once a change's PR merges, its `plan:` and +**Closed-out plans and results are frozen build records.** Once a change is closed out, its `plan:` and `results:` files are never hand-edited again — not to correct a stale line reference, not to update a -superseded instruction. The only writes they receive afterward re-stamp the generated `docket:backlink` block (the -`artifact.backlink` operation, and `finalize.closeout`'s integration-ref backlink leg); authored -content never changes. +superseded instruction. The only later write re-stamps the generated `docket:backlink` block, done by +the archive transaction; authored content never changes. They record what a build was *told* to do at the time it ran, which is the only thing that makes a completed run auditable; editing one destroys that record while silently changing what a re-read of the artifact would say the build was asked for. Corrections go in a new -change, never in the merged artifact. +change, never in the frozen artifact. ### Change body sections `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer: `artifact.backlink` writes the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. - `## Open questions` — unknowns to resolve during reconcile/design. - `## Reconcile log` — dated entries appended by the implementer's reconcile pass. -- `## Closeout notes` — final-only, **optional**, and the **final authored body section** of an archived record. Written solely by the `finalize.closeout` operation from its structured request (`verification_outcomes` / `late_findings`, rendered as `### Verification` / `### Late findings` bullet lists); never hand-edited, copied to a stacked descendant, or a link-bearing artifact. The merged `results:` file stays a frozen build record — the freeze rule above is unchanged. +- `## Closeout notes` — final-only, **optional**, and the **final authored body section** of an archived record. Written solely by the `finalize.closeout` operation from its structured request (`verification_outcomes` / `late_findings`, rendered as `### Verification` / `### Late findings` bullet lists); never hand-edited, copied to a stacked descendant, or a link-bearing artifact. The closed-out `results:` file stays a frozen build record — the freeze rule above is unchanged. - `## Reclaim log` — dated entries appended by the `change.reclaim` operation when an expired-lease, no-branch claim self-heals back to `proposed`. - `## Auto-groom blocked` — dated abstain record written by `change.groom` `outcome: abstain`; contents and lifecycle (including removal by `outcome: re-enable`) are defined by the *Autonomous grooming* shared definition below. - `## Finalize blocked` — dated record appended by `docket-finalize-change` when a gate failure leaves a change needing a human; presence drives the board's `finalize blocked — needs you` cell. It is a visible note that never stops finalize selection or merge, so the next finalize run retries the change. The clearing rule is owned by `docket-finalize-change` and not restated here. - `## Run halted` — written by the `change.halt` operation when an autonomous run stops needing a human (the `halted` disposition); the heading is **bare**, never dated — the reader is a whole-line match, so the date sits inside the body. **Marker-section state**, in the same family as `## Auto-groom blocked` and `## Finalize blocked`: while the section is present the `run.verify` operation reports `run-halted`, which is what makes a `halted` disposition verifiable in git rather than a claim in a completion report. It is removed by the `change.resume-halted` operation, and by the claim when a halted change is claimed again. +- `## Build evidence` — the build-evidence block for the certified feature head, written by `change.mark-implemented`, `finalize.publish`, and `evidence.recertify`; never hand-edited. - `## Why deferred` / `## Why killed` — added when entering those states. The change body is a **PM-altitude proposal** (intent + scope). Detailed design lives in the linked superpowers spec; the task breakdown in the linked superpowers plan. Different zoom levels, no duplication. @@ -330,7 +330,7 @@ Compressed rules (detail in the reference): the promotion tiering criterion is ### Derived views (shared definition) -The derived views over the change files — the `inline` board, each change's `## Artifacts` link block, and the reciprocal per-artifact `docket:backlink` block — are rendered by the **Go app inside the owning metadata transaction** (the board and `## Artifacts` block re-render atomically in the same commit that writes the record they reflect) or via the **`artifact.backlink` operation** for the backlink blocks. Each generated block has a **sole writer** and is **never hand-edited** (ADR-0012). +The derived views over the change files — the `inline` board, each change's `## Artifacts` link block, and the reciprocal per-artifact `docket:backlink` block — are rendered by the **Go app inside the owning metadata transaction** (the board, `## Artifacts` block, and file backlinks re-render atomically in the same commit that writes the record they reflect). Each generated block has a **sole writer** and is **never hand-edited** (ADR-0012). ### Bootstrap guard (first-run safety) @@ -350,8 +350,8 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr Metadata (change files, `BOARD.md`, ADRs, specs) commits to the `docket` branch via the persistent **`.docket/` worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to its remote immediately** (the planning surface stays browsable on the remote). -A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the plan + results + code and **never modifies** docket metadata. +A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. The `.docket` metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. -On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code, plans, and results through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. +On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md index 411f4885b..27ef0b513 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/close-out.md @@ -30,12 +30,12 @@ before the first read; every commit pushes immediately. ``` `--input` carries only the optional authored closeout notes (`verification_outcomes`, - `late_findings`; `-` for stdin); the merged `results:` file is read from the verified merge, not - passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a + `late_findings`; `-` for stdin); the `results:` file already sits on the `docket` branch and is + never passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a stack) ⇒ the change is marked done and relocated to the dated archive path — idempotent if already archived, including across a day boundary. This ONE metadata commit atomically owns the - archive move, the `## Artifacts` re-render, the re-stamp of **every metadata-resident back-link - including the spec** (which lives on the metadata ref), and the inline board render; a typed + archive move, the `## Artifacts` re-render, the re-stamp of **every metadata-resident back-link** + (spec, plan, and results all live on the metadata ref), and the inline board render; a typed refusal or process failure writes nothing and aborts per the caller's posture, with **no partial caller-owned follow-up**. It still relocates the change file in its own step, so concurrent done drivers converge tree-identically (see *Determinism invariant*). The frozen step-2 re-render and @@ -58,28 +58,27 @@ before the first read; every commit pushes immediately. Trust the typed outcome: `applied` ⇒ archived — an idempotent no-op if already archived, including across a day boundary (it reuses the existing dated filename). This ONE metadata commit atomically owns the archive move, the refreshed `updated:` date, the spliced `## Why killed` - section, the `## Artifacts` re-render, the retargeted spec back-link, and the inline board render + section, the `## Artifacts` re-render, the retargeted spec, plan, and results back-links, and the inline board render — so the step-2 re-render and step-4 board pass below carry **nothing** for the kill path, exactly as `finalize.closeout` owns them for the done path. A wrong `revision` or an illegal source status returns a typed refusal that writes nothing (a lost conflict-checked write is `contended`; see *Determinism invariant*). -2. **Artifact block + spec back-link — owned atomically by step 1, no separate caller commit.** - Both close-out transactions re-render the archived record's `## Artifacts` block (plan/results - re-point to the integration branch at final state) **and** re-stamp every metadata-resident - back-link — the spec's `docket:backlink` block included — retargeted to the +2. **Artifact block + back-links — owned atomically by step 1, no separate caller commit.** + Both close-out transactions re-render the archived record's `## Artifacts` block (relative + same-branch rows, plus an absolute `Spec (merged)` row for a merged change) **and** re-stamp every + metadata-resident back-link — spec, plan, and results — retargeted to the now-**archived** change path, **in the same step-1 metadata commit** as the archive: - On the **done** path the step-1 `finalize.closeout` operation transaction owns this restamp atomically. - On the **kill** path the step-1 `change.kill` operation transaction owns it identically — it - re-renders the `## Artifacts` block and retargets the linked spec's `docket:backlink` block in - its one commit. + re-renders the `## Artifacts` block and retargets the linked spec, plan, and results + `docket:backlink` blocks in its one commit. So **no separate caller re-render or back-link commit runs for either path** — the skill never - invokes a facade renderer and never hand-edits a managed block. Nothing is copied onto the - integration branch; a spec that lives on the metadata ref is restamped in that same step-1 - commit. A typed refusal (malformed markers, + invokes a facade renderer and never hand-edits a managed block. Nothing touches the integration + branch. A typed refusal (malformed markers, missing artifact) leaves the file untouched and aborts per the caller's posture — surface it, never hand-edit the block. diff --git a/internal/assets/embedded/tree/skills/docket-convention/references/stacked-changes.md b/internal/assets/embedded/tree/skills/docket-convention/references/stacked-changes.md index d1b5197b9..454573868 100644 --- a/internal/assets/embedded/tree/skills/docket-convention/references/stacked-changes.md +++ b/internal/assets/embedded/tree/skills/docket-convention/references/stacked-changes.md @@ -110,7 +110,7 @@ A change whose base does not resolve is **not build-ready**: the board reads At the branch cut, the resolved effective base replaces `origin/<integration_branch>` — and only there. Everything else about the feature branch is unchanged: it is cut after claim and reconcile, -carries only plan + results + code, and never modifies docket metadata. +carries only the spec copy and code, and never modifies docket metadata. ``` workspace.prepare --id <id> --revision <revision> # cuts <type>/<slug> from the effective base; resolve argv from the capability catalog 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 9e0d9bdd5..77eda9238 100644 --- a/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-finalize-change/SKILL.md @@ -77,7 +77,7 @@ If the candidate has open child PRs targeting this change's branch, they must be The `finalize.rebase` operation with `--id <id> --revision <revision> --head <feature head>`. It writes an ownership-scoped receipt before any Git mutation, rebases the feature branch onto the exact effective base under owned refs, then composes the local gate. Route on `disposition`: -- `unchanged` / `rebased` — the rebase completed and the gate was satisfied (skipped on exact-head PR evidence for a no-op, or run and passed). Carry forward the returned `attempt` token, the resulting `head`, and the gate report's `evidence` block (present when the suite ran; otherwise reuse the exact-head PR-body evidence). Go to step 6. +- `unchanged` / `rebased` — the rebase completed and the gate was satisfied (skipped on exact-head record evidence for a no-op, or run and passed). Carry forward the returned `attempt` token, the resulting `head`, and the gate report's `evidence` block (present when the suite ran; otherwise reuse the exact-head evidence from the change record's `## Build evidence` section). Go to step 6. - `conflicted` — the rebase stopped at the reported `unmerged_paths`. Enter the resolver rounds below. - `waiting` with `reason: gate-waiting` — the rebase completed and the local suite is still running under the detached supervisor; the owned receipt now carries the drive continuation. Re-run the **identical** `finalize.rebase` invocation (same `--id --revision --head`); it recovers the completed rebase from the receipt and advances the **same** drive — on `passed` it mints the evidence block and returns `unchanged`/`rebased` as a single-slice run does. Never re-enter through `gate drive advance`, mint a run root, or carry the continuation yourself — the receipt does. Repeat until a terminal disposition; the driver's observation budget bounds the polling, and expiry surfaces as `blocked` with `reason: gate-halted`. A `waiting` from `finalize.rebase-continue` re-enters the same way: through `finalize.rebase`, never another `rebase-continue`. - `failed` with `reason: gate-failed` — the rebase completed but the local suite is **red** at the rebased head. Enter the repair path (step 5). @@ -109,7 +109,7 @@ A resolver that returns `disposition: stuck`, or a resolver dispatch that is una ### 4. The local gate -The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebase runs the full resolved suite unless the rebase was a **no-op and** the PR body carries **green** build-evidence for the **exact** current head **whose recorded command equals the resolved `finalize.test_command`**, in which case the run is skipped and the permit named in the gate report. A **skipped** (`build-gate-off`) build-evidence record never waives finalize's gate — the build role's gate policy is independent of finalize's, so skipped build evidence, or green evidence recorded against a different command, forces the suite to run. There is no strict-ancestor or results-only skip. A passing gate records evidence through the landed `evidence.record` seam with the finalize owner, so the block names `finalize.test_command`, and returns the block in the rebase document; a red gate returns `failed`/`gate-failed` (step 5). +The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebase runs the full resolved suite unless the rebase was a **no-op and** the change record's `## Build evidence` section carries **green** build-evidence for the **exact** current head **whose recorded command equals the resolved `finalize.test_command`**, in which case the run is skipped and the permit named in the gate report. A **skipped** (`build-gate-off`) build-evidence record never waives finalize's gate — the build role's gate policy is independent of finalize's, so skipped build evidence, or green evidence recorded against a different command, forces the suite to run. There is no strict-ancestor or results-only skip. A passing gate records evidence through the landed `evidence.record` seam with the finalize owner, so the block names `finalize.test_command`, and returns the block in the rebase document; a red gate returns `failed`/`gate-failed` (step 5). `docket gate` owns the gate's mechanics — the supervised run outliving any foreground call, the durable run directory, completion established from that artifact, and the bounded `gate_observation_budget` that fails closed when spent. **One clause of `docket-build`'s *Gate run posture* it cannot own is yours to obey:** whether you may **yield** while the gate runs is decided by *your own* dispatch posture, never by the gate's. Only a top-level session agent, able to receive a resumption signal, may yield and then make short observations. Running as a dispatched or forked child you have no such channel, so you may **never** yield — observe by *blocking* instead, in repeated short foreground reads, control never handed back to your caller mid-gate. Neither a `finalize.rebase` re-entry nor a `gate.drive.advance` slice can tell which you are; a child that yields here parks until a human notices. @@ -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 converges the PR build-evidence block onto that head — authored prose, title, and every other body byte stay byte-identical. 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. ### 8. Merge exactly once @@ -158,16 +158,16 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec, plan, and results backlinks + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. - `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. The same follow-up then repoints the root's and each descendant's PR. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). -- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. +- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). Close-out is one metadata commit plus the best-effort PR-description backlink repoint; nothing touches the integration branch. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. ### 10. Cleanup — Docket-owned resources only -The `finalize.cleanup` operation with `--id <id>`. An ordered, independently retryable suffix: repair a pending backlink (artifact files and the PR description) first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. +The `finalize.cleanup` operation with `--id <id>`. An ordered, independently retryable suffix: repair the PR description's backlink first when it is pending, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. ### 11. Integration sync — best-effort, end-of-run diff --git a/internal/assets/embedded/tree/skills/docket-status/SKILL.md b/internal/assets/embedded/tree/skills/docket-status/SKILL.md index 4398d8679..5dbe2142c 100644 --- a/internal/assets/embedded/tree/skills/docket-status/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-status/SKILL.md @@ -121,6 +121,6 @@ The pass reads nothing and writes nothing under `learnings/`, and nothing refres ### Findings -Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a broken spec/plan/results link, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. +Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the `docket` branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. One judgment check stays in-model, on top of the operations: `blocked_by:` re-examination (see *Judgment follow-ups* above) — warn-only, never auto-fix. diff --git a/internal/harness/claude/testdata/golden/docket-plan-writer.md b/internal/harness/claude/testdata/golden/docket-plan-writer.md index 76b214be7..2a1390ffb 100644 --- a/internal/harness/claude/testdata/golden/docket-plan-writer.md +++ b/internal/harness/claude/testdata/golden/docket-plan-writer.md @@ -1,21 +1,22 @@ --- name: 'docket-plan-writer' -description: 'Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan''s repo-relative path. Not invoked directly by a human.' +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.' --- 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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. diff --git a/internal/harness/codex/testdata/golden/docket-plan-writer.toml b/internal/harness/codex/testdata/golden/docket-plan-writer.toml index 737916969..2daab9896 100644 --- a/internal/harness/codex/testdata/golden/docket-plan-writer.toml +++ b/internal/harness/codex/testdata/golden/docket-plan-writer.toml @@ -1,5 +1,5 @@ name = "docket-plan-writer" -description = "[docket worktree: feature] Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan's repo-relative path. Not invoked directly by a human." +description = "[docket worktree: feature] 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." developer_instructions = """ 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. @@ -7,18 +7,19 @@ 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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. """ diff --git a/internal/harness/cursor/testdata/golden/docket-plan-writer.md b/internal/harness/cursor/testdata/golden/docket-plan-writer.md index 36b09663f..45e8cc1df 100644 --- a/internal/harness/cursor/testdata/golden/docket-plan-writer.md +++ b/internal/harness/cursor/testdata/golden/docket-plan-writer.md @@ -1,21 +1,22 @@ --- name: docket-plan-writer -description: 'Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan''s repo-relative path. Not invoked directly by a human.' +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.' --- 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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. diff --git a/internal/harness/opencode/testdata/golden/docket-plan-writer.md b/internal/harness/opencode/testdata/golden/docket-plan-writer.md index d14e5ad90..f752f1346 100644 --- a/internal/harness/opencode/testdata/golden/docket-plan-writer.md +++ b/internal/harness/opencode/testdata/golden/docket-plan-writer.md @@ -1,21 +1,22 @@ --- -description: 'Internal plan-writing agent for docket-implement-next Step 4 — invokes `superpowers:writing-plans` in a pinned context, commits the plan artifact with its backlink on the feature branch, and returns only the plan''s repo-relative path. Not invoked directly by a human.' +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.' 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, and synchronized change-file path; the synchronized spec path; the feature-worktree path and its pre-dispatch HEAD; whether learnings are enabled and, when enabled, the learnings index path; and the synchronized change-file path the plan's backlink must point home to. +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 own exactly one durable artifact: the plan file, committed on the feature branch. You may read the synchronized metadata worktree; you perform no Docket metadata mutation, board update, or status transition — no writes outside the feature worktree, and inside it only the plan artifact. +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. Sequence (bounded; run it in order): 1. Confirm the feature worktree is clean and its HEAD equals the handed-off pre-dispatch HEAD. A dirty tree or moved HEAD is a blocking diagnostic, not something to repair. 2. Read the change file, the spec, the current feature-tree code the plan must touch, and — when learnings are enabled — the learnings index, then the finding files whose hook + topics bear on this change. Selecting the relevant findings is your judgment, not the parent's. -3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan file under `docs/superpowers/plans/` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. -4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself under `docs/superpowers/plans/`. -5. Stamp the plan's back-link home by running the `artifact.backlink` operation (resolve argv from the capability catalog) with `--artifact <plan repo-relative path> --change <change repo-relative path>` (the one operation that writes a feature-worktree file; it refuses on absolute/escaping paths and on malformed backlink markers, leaving the file untouched), stage only the plan path (never `add -A`), and commit it on the feature branch with the exact git trailer `Docket-Plan-Path: <repo-relative-path>`. -6. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an uncommitted or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies, attaches, and continues past. +3. Invoke `superpowers:writing-plans` DIRECTED to: write the plan to a scratch file in a directory made with `mktemp -d "${TMPDIR:-/tmp}/docket-plan.XXXXXX"` and stop there. Answer any execution-mode or option choice it poses internally: the plan is executed by `docket-build`; surface none. +4. When `superpowers:writing-plans` cannot be invoked, apply the missing-skill rule: warn prominently and author the same plan yourself in that scratch file. +5. Run the `change.attach-plan` operation (resolve argv from the capability catalog) with `--id <id> --revision <revision> --path docs/superpowers/plans/<date>-<slug>.md --markdown <scratch file>`. It writes the plan with its backlink on the `docket` branch and sets `plan:` in one commit. A refusal is a blocking diagnostic. +6. Re-confirm the feature worktree is clean and its HEAD still equals the pre-dispatch HEAD. +7. Finish with the single authoritative success line `PLAN_PATH=<repo-relative-path>`. Informational warning lines (for example a missing-skill degrade) may precede it. On any failure, return a concrete blocking diagnostic instead of a `PLAN_PATH` line — never success-shaped output for an unattached or partially written plan. The token says PATH, not complete or done: it is a sub-step receipt the parent verifies and continues past. You run autonomously with no human to pause and ask: treat any unmet precondition or blocking ambiguity as abort-and-report (return the diagnostic), never an interactive prompt. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 2759e1651..aed5c1ae2 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,7 +196,7 @@ var skillBudgets = []skillBudget{ {"docket-build/references/gate-driver.md", 138, 1552}, // 0502 review: the raw-verb section names finalize's repaired-head re-gate (137/1546 -> 138/1552); 0502: "loop" means /loop only (137/1550 -> 137/1546); 0502: finalize's repaired-head re-gate is a direct caller (136/1523 -> 137/1550); 0502: the gate-execution intro reference is deleted (138/1555 -> 136/1523); 0502: renamed from gate-caller-loop.md; 0497: +halted-gate tree-survives finding relay (134/1510 -> 138/1555); 0491: the run id is retired (134/1513 -> 134/1510); 0490: the admission section describes the supervisor-held worktree lock; launch-unconfirmed is no longer a refusal (136/1511 -> 134/1513); 0488 review fix: finalize reaches the driver only through finalize.rebase; task-era handoff clauses dropped (word ceiling 1504 -> 1511); 0488 review: start row carries --change-id (word ceiling 1491 -> 1504); 0488: worker-scope/takeover/acknowledge rows and the parent-takeover section removed; callers are the full-suite gates (175/1872 -> 136/1491); 0467: +prepare-scope --run-id and scope-inherited start run id (word ceiling 1826 -> 1872); 0375: +worktree-admission section (word ceiling 1750 -> 1826) {"docket-build/references/task-routing.md", 50, 500}, {"docket-build-task/SKILL.md", 168, 1637}, // 0488: scoped drive protocol replaced by direct foreground tests under timeout; three outcomes (211/2235 -> 168/1637); 0467 review fix: +the repair re-run run-id exception (206/2183 -> 211/2235); 0467: +the run id rides on the scope, never the bundle (204/2151 -> 206/2183); 0459: +post-handoff continuation never acknowledges or reuses the transferred scope (188/1964 -> 204/2151); 0405: sequential-drive receipt and acknowledgement; 0420: shell-safe capture; 0375: worktree-busy-not-a-retry rule (179/1842 -> 188/1964) - {"docket-convention/SKILL.md", 357, 7026}, // 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) + {"docket-convention/SKILL.md", 357, 7062}, // 0530: plan, results, and build evidence live on the docket branch — the plan-writer's one metadata operation, the attach operations as file-backlink writers, relative same-branch links, and the new ## Build evidence record section (357/7026 -> 357/7062); 0529 review: the backlink block names its PR-body writers beside artifact.backlink, one shared renderer (357/7004 -> 357/7026); 0515: the repair sign-off is retired; a green repair merges (357/7007 -> 357/7004); 0502 rebase onto 0510: kept the backlog-match follow-up clause in the results-artifact paragraph (357/7004 -> 357/7007); 0502 review: the review-tier dispatch is the controller's own (357/6995 -> 357/7004); 0502: configuration read paths corrected — operations read .docket.yml from origin's default-branch tip, the repository setup operations from the primary worktree (357/6959 -> 357/6995); 0502: "loop" means /loop only (357/7036 -> 357/7037); 0502: one metadata layout — the docket branch and the .docket/ worktree (357/7035 -> 357/7036); 0502: agent layer names user-level wrappers and global-only pins (357/7024 -> 357/7035); 0502: typed operations replace the Bash-era script names; ## Run halted facts corrected (359/7092 -> 357/7024); 0502: terminal publish, the learnings cap, and the repository auto_groom default are deleted (362/7256 -> 359/7092); 0502: the auto-capture export paragraph is deleted (370/7334 -> 362/7256); 0502: the configuration contract rewritten from the schema (371/7488 -> 370/7334); 0502: fixed role skills; halt posture (372/7660 -> 371/7488); 0502: record blocks mirror the renderers (367/7619 -> 372/7660); 0502: the dummy-mode shared definition is deleted (390/7848 -> 367/7619); 0410: +required-results lifecycle prose; 0399: +schema request/result contract prose; 0388: +sync-integration prose (see note above); 0502: drop change citations and Go v1 narration (357/7037 -> 357/6958); 0502: the harness adapter is named without a docket source path (357/6958 -> 357/6959) // 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) @@ -212,7 +212,7 @@ var skillBudgets = []skillBudget{ {"docket-implement-next/results-template.md", 70, 545}, // 0502 rebase onto 0510: carried the backlog verdict and next action in the Known issues placeholder (68/506 -> 70/545); 0502: inline review replaces the custom review skill (68/511 -> 68/510); 0498: +whole-branch review summary line in Verification performed (64/446 -> 68/511); 0440: reader-first template — action statement + merged Known issues (see note above); 0502: drop change citations and Go v1 narration (68/510 -> 68/506) {"docket-review/SKILL.md", 114, 965}, // 0502: the evidence rule keys on the payload's build.gate value; gate-off skipped records are expected (110/913 -> 114/965); 0410: +findings-return capture contract (see note above) {"docket-new-change/SKILL.md", 57, 1596}, // 0502 review: omitted draft-time scalars are described exactly (57/1591 -> 57/1596); 0502: "loop" means /loop only (57/1593 -> 57/1591); 0502: one metadata layout — the docket branch and the .docket/ worktree (57/1590 -> 57/1593); 0502: terminal-publication narration and the repository auto_groom default are deleted (57/1609 -> 57/1590); 0502: invoke superpowers:brainstorming by name (57/1636 -> 57/1609); 0502: the dummy-mode pointer is deleted (59/1674 -> 57/1636); 0445: +pointer to the docket-groom-next revise path after landing (word ceiling 1700 -> 1706); 0382: draft-time scalars moved into change.create (ceiling 1706 -> 1675) - {"docket-status/SKILL.md", 126, 2904}, // 0529 review: full-scope cleanup retries also repoint stale merged-PR description backlinks; repair --pr-backlinks previews them (126/2880 -> 126/2904); 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) + {"docket-status/SKILL.md", 126, 2921}, // 0530: artifact-missing names where a plan or results file may live — the docket branch, or the integration branch for a record closed before the move (126/2904 -> 126/2921); 0529 review: full-scope cleanup retries also repoint stale merged-PR description backlinks; repair --pr-backlinks previews them (126/2880 -> 126/2904); 0502: one metadata layout — the docket branch and the .docket/ worktree (126/2876 -> 126/2880); 0502: findings named as the binary emits them (126/2852 -> 126/2876); 0502: deferred learnings and publication narration is deleted (127/2956 -> 126/2852); 0502: the dummy-mode pointer is deleted (129/2985 -> 127/2956); 0388: +sync-integration prose (see note above) } // wcLines counts lines the way `wc -l` does: the number of newline bytes. diff --git a/internal/repoguard/prose_contracts_test.go b/internal/repoguard/prose_contracts_test.go index 506d84a06..917a77c96 100644 --- a/internal/repoguard/prose_contracts_test.go +++ b/internal/repoguard/prose_contracts_test.go @@ -123,9 +123,9 @@ var proseContracts = []proseContract{ // tests/test_plan_writer_step4.sh — Step 4 plan-writer dispatch. {sentinel: "test_plan_writer_step4", file: "skills/docket-implement-next/SKILL.md", present: []string{"docket-plan-writer"}}, - // tests/test_results_artifact.sh — merged plan/results freeze rule. + // tests/test_results_artifact.sh — closed-out plan/results freeze rule. {sentinel: "test_results_artifact", file: "skills/docket-convention/SKILL.md", - present: []string{"Merged plans and results are frozen build records."}}, + present: []string{"Closed-out plans and results are frozen build records."}}, // tests/test_skill_fork_dispatch.sh — fork-dispatch README contract. {sentinel: "test_skill_fork_dispatch", file: "docs/install/models-and-effort.md", present: []string{"completed (forked execution)"}}, @@ -282,7 +282,7 @@ var proseContracts = []proseContract{ // change 0410 — the convention now describes results as a REQUIRED close-out // artifact in both the directory-map row and the lifecycle paragraph. (The // existing frozen-records row above — sentinel test_results_artifact — is left - // untouched and still pins "Merged plans and results are frozen build records.") + // untouched and still pins "Closed-out plans and results are frozen build records.") {sentinel: "change_0410_convention_results", file: "skills/docket-convention/SKILL.md", present: []string{ "required close-out artifacts (one per implemented change, trivial included)", diff --git a/skills/docket-build/SKILL.md b/skills/docket-build/SKILL.md index 622683673..db0af4c02 100644 --- a/skills/docket-build/SKILL.md +++ b/skills/docket-build/SKILL.md @@ -20,7 +20,7 @@ gets exactly one fresh worker dispatch unless that worker requests its single al ## Inputs - The **plan** `docket-implement-next` Step 4 wrote, at the path recorded in the change's `plan:` - field and committed on the feature branch. + field, read from the metadata worktree at `.docket/<plan path>`. - The **feature branch and worktree** already cut for this change, plus that repo's own instruction files (`AGENTS.md`, `CLAUDE.md`, nested equivalents). - The plan's `### Task N` headings, the **unit of dispatch** — one heading, one worker, one @@ -250,8 +250,8 @@ ran_at: <UTC ISO-8601> `head_sha` and `ran_at`. The record certifies the branch so the review step need not re-run the suite; `docket-implement-next` -Step 6 validates it and Step 7 writes it into the PR body, then runs the review role (`docket-review`) -once over the whole branch. Only a green run — or an explicit `build_gate: off` — mints a +Step 6 validates it, then runs the review role (`docket-review`) +once over the whole branch; Step 7's `change.mark-implemented` records it in the change record. Only a green run — or an explicit `build_gate: off` — mints a record: a red suite mints no evidence record at all, and enters the repair path below. **Red** → the build **never invokes review**. `build_max_attempts` (from the implementation context, @@ -350,8 +350,7 @@ When docket-build runs as the invoked build role for a coordinator that owns a r the controller **may** perform the build-findings checkpoint on that coordinator's behalf, under the explicit caller contract that grants it that authority — consolidating the build findings available at this point (the material TDD exceptions, residual risks, and worker-surfaced findings -above) before the final full-suite gate where the ordering permits, so the gate certifies the -checkpoint-containing head. **Task workers never edit the results file**, there are **never +above) through `change.attach-results`, a metadata commit that never moves the feature head. **Task workers never edit the results file**, there are **never concurrent writers**, and **a checkpoint never independently launches tests** — it records what is already known and moves no gate of its own. A custom build skill bound in this role's place owns none of this: it returns its findings through its own contract, and the coordinator captures them at @@ -380,5 +379,5 @@ Emit concise, stable lines and nothing more: task-to-tier selection and reason; reason; worker outcome and commit; focused verification; full-suite command and result; the build-evidence record on green; the terminal build disposition (**role-scoped** — a build disposition, never a run disposition). Write no verbose task artifact; material TDD exceptions, -residual risks, and worker-surfaced findings flow to the coordinator's results artifact (and the PR description where evidence belongs), not into per-task +residual risks, and worker-surfaced findings flow to the coordinator's results artifact (and the change record's build evidence), not into per-task files. diff --git a/skills/docket-convention/SKILL.md b/skills/docket-convention/SKILL.md index 01aacd46d..8b0192e59 100644 --- a/skills/docket-convention/SKILL.md +++ b/skills/docket-convention/SKILL.md @@ -55,7 +55,7 @@ Every operation reads the committed `.docket.yml` from the fetched tip of origin This resolution — read configuration, apply defaults, resolve `integration_branch` — runs inside the **`repository.prepare`** operation (the *startup check*), which returns the resolved values in its typed context. -**Reaching docket's operations.** Maintained skills reach every docket operation through the native `docket` binary on `PATH`, but never by hard-coding its argv: each skill resolves the executable spelling from the capability catalog (`docket capabilities --json` — the one spelling this convention permits a skill to hard-code) and constructs the invocation from the entry for the semantic operation it names (`repository.prepare`, `maintenance.sweep`, `status`, `change.*`, `context.*`, `adr.*`, `artifact.backlink`, …) — never a shell facade, and never a `DOCKET_*` transport variable resolved into a command. An operation's **request/result payload shape** resolves the same way — per-operation, from the catalog-resolved **`schema`** operation, validated fail-closed as the capability bootstrap is (refuse `protocol_version` ≠ 1 or an unsupported `schema_version`, a malformed envelope, or an id the surface does not carry) — building the body from the descriptor's real JSON keys, required markers, and named vocabularies, **never** from `--help`, `strings`, the source tree, or a probe. The two bootstrap remedies a **human** types — `docket repository migrate` and `docket repository init` — are the deliberate exception: they run before any catalog exists to resolve against, are human-initiated, and are never an agent runtime invocation. If the `docket` binary is missing from `PATH`, the install is broken — stop and fix it, never silently degrade to hand-worked operations. Every env var docket introduces is **DOCKET_-namespaced**. +**Reaching docket's operations.** Maintained skills reach every docket operation through the native `docket` binary on `PATH`, but never by hard-coding its argv: each skill resolves the executable spelling from the capability catalog (`docket capabilities --json` — the one spelling this convention permits a skill to hard-code) and constructs the invocation from the entry for the semantic operation it names (`repository.prepare`, `maintenance.sweep`, `status`, `change.*`, `context.*`, `adr.*`, …) — never a shell facade, and never a `DOCKET_*` transport variable resolved into a command. An operation's **request/result payload shape** resolves the same way — per-operation, from the catalog-resolved **`schema`** operation, validated fail-closed as the capability bootstrap is (refuse `protocol_version` ≠ 1 or an unsupported `schema_version`, a malformed envelope, or an id the surface does not carry) — building the body from the descriptor's real JSON keys, required markers, and named vocabularies, **never** from `--help`, `strings`, the source tree, or a probe. The two bootstrap remedies a **human** types — `docket repository migrate` and `docket repository init` — are the deliberate exception: they run before any catalog exists to resolve against, are human-initiated, and are never an agent runtime invocation. If the `docket` binary is missing from `PATH`, the install is broken — stop and fix it, never silently degrade to hand-worked operations. Every env var docket introduces is **DOCKET_-namespaced**. **`board_surfaces` — the board as 0..n derived views.** The board is a *derived view* over the change files; `board_surfaces` lists which surfaces to render. `inline` (the committed, offline-safe `BOARD.md`) is the one supported surface; default `[inline]`. **`[]` disables the board entirely** — the change files plus git history remain fully authoritative. An unknown token is warned-and-ignored (a typo must never abort a build). @@ -105,11 +105,11 @@ The *Skill layer*'s **missing-skill rule** and `halt` are two conditions with tw ### Agent layer — model/effort-pinned subagents -Each **autonomous** docket skill can run as a model/effort-pinned **subagent** instead of inline at the session model. The autonomous skills get a wrapper — `docket-implement-next`, `docket-auto-groom`, `docket-finalize-change`, `docket-status`, `docket-adr`, `docket-build-task` (shared by its four tier agents), and `docket-review` (shared by its three tier wrappers); the two **interactive** skills (`docket-new-change`, `docket-groom-next`) stay inline and only surface an **advisory** recommended model/effort at startup. `docket-convention` is not an agent — it is listed in the skills list of every wrapper except these: `docket-brainstorm-consultant` (ADR-0022), **`docket-plan-writer`**, the `docket-build-*` tier workers, and the `docket-review-*` tier wrappers. These wrapper-bearing exceptions perform no docket metadata operations — a boundary each worker contract's own *Scope* states normatively; the consultant wraps nothing at all (see *Composition*). +Each **autonomous** docket skill can run as a model/effort-pinned **subagent** instead of inline at the session model. The autonomous skills get a wrapper — `docket-implement-next`, `docket-auto-groom`, `docket-finalize-change`, `docket-status`, `docket-adr`, `docket-build-task` (shared by its four tier agents), and `docket-review` (shared by its three tier wrappers); the two **interactive** skills (`docket-new-change`, `docket-groom-next`) stay inline and only surface an **advisory** recommended model/effort at startup. `docket-convention` is not an agent — it is listed in the skills list of every wrapper except these: `docket-brainstorm-consultant` (ADR-0022), **`docket-plan-writer`**, the `docket-build-*` tier workers, and the `docket-review-*` tier wrappers. Of these wrapper-bearing exceptions the plan-writer performs exactly one docket metadata operation (`change.attach-plan`) and the others perform none — a boundary each worker contract's own *Scope* states normatively; the consultant wraps nothing at all (see *Composition*). A wrapper is a thin installed file: it carries the resolved `model` and `effort`, when they resolve, and lists the skill and `docket-convention` in its skills list; the skill body stays the single source of behavior. Because a subagent cannot pause to ask a human, every autonomous wrapper carries an **abort-and-report** rule: an unmet precondition or blocking ambiguity is surfaced and stopped on — never turned into an interactive prompt. The Go install generates each wrapper from the compiled built-in model/effort table plus the global configuration's overrides, and installs it at user level, never per repository. A **directly-invoked** autonomous skill is still routed to its pinned wrapper by a harness dispatch mechanism, so the pin holds either way; mechanics live in [`references/agent-layer.md`](references/agent-layer.md). -**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on `<type>/<slug>`** (the committed plan + backlink + `Docket-Plan-Path:` trailer), which the parent verifies before attaching; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. +**Composition.** `docket-implement-next` dispatches `docket-adr` (step 6) — **foreground** (the parent suspends until the child returns), **unconditional**, and resting on **git state** on `origin/docket`, re-read after a re-sync: that is the whole contract, never an in-context return. Its step-0 implementation preflight is **not a dispatch**: it runs the `maintenance.preflight` operation inline — the implementation-scope sweep (the `maintenance.sweep` behavior at `--scope implementation`, so startup still no longer implies a full historical sweep) plus a compact post-sweep read, one process, one protocol-v1 envelope keyed on its `preflight` verdict — and it is **selection-path only**: an invocation naming exactly one explicit change id skips the preflight and goes directly to `context.implementation --id` and the claim/resume path, with a bounded closeout for only that change's own merged-but-unclosed dependencies. `docket-implement-next` also dispatches `docket-plan-writer` (step 4) — foreground and unconditional on the same blocking terms, but hybrid: its receipt (`PLAN_PATH=<path>`) flows back in-context, while the proof is **git state on the `docket` branch** (`plan:` set, the plan file with its backlink at the tip), which the parent verifies after a re-sync; unavailable dispatch falls back `halt`, per *Dispatch-capability resolution*. Feature roles use the agent-layer scope matrix. `docket-auto-groom` dispatches `docket-auto-groom-critic` for its adversarial gate on the same terms, but its verdict flows back **in-context as the dispatch's return** — never via git state and never via agent messaging, because a dispatched groom is not registered under its skill name and no name-addressed delivery to it resolves. **Foreground means the parent *actively blocks* on the child's return — it may never background a dispatched or forked child and *yield* to await a task-notification** (a forked/subagent skill has no channel to receive one, ADR-0024): yielding returns a **half-done run that the caller reads as `completed`**. Reciprocally, a caller must **not** read a bare `completed` as proof the child finished: it verifies the child's git-state transition and **never adopts or commits a child's uncommitted working-tree files**. For an interactive dispatch that obligation is mechanical: the managed `docket:dispatch` block in each harness's parent-facing instructions file brackets the dispatch with the run tracker (`run.start`, then `run.verdict` on the returning run) and re-dispatches only when that verdict authorizes it (`run-retry-once`, or a `run-continue` continuation). `docket-finalize-change` dispatches `docket-rebase-resolver` on a finalize-gate rebase conflict and `docket-integration-repair` on a red rebased suite — also foreground, but their reports flow **back to finalize in-context** to gate the merge, acting in the feature worktree. Each uses wrapper-resolved model/effort; never restate literal model IDs or efforts. Several generated wrappers wrap **no skill**: `docket-auto-groom-critic`, `docket-rebase-resolver`, and `docket-integration-repair` each load only `docket-convention`; `docket-brainstorm-consultant` and `docket-plan-writer` load **no convention either** — the consultant authors prose and performs zero docket operations (ADR-0022), the plan-writer invokes `superpowers:writing-plans` at runtime, never as a preload. All are auto-discovered by the Go install's `agents/docket-*.md` glob. **Configuring the layer** — global model/effort pins, `agent_harnesses`, model IDs (ADR-0015), wrapper shapes, and dispatch blocks — is a separate read: **read [`references/agent-layer.md`](references/agent-layer.md) now (blocking) before configuring agent model/effort pins or `agent_harnesses`, or debugging the agent install.** @@ -120,7 +120,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: | Role | Skill | Invoked by | Final artifact — stop-point | |---|---|---|---| | brainstorm | `superpowers:brainstorming` | `docket-new-change` §2, `docket-groom-next` | a spec file at the configured spec path; stop at the spec | -| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the feature branch, recorded in `plan:` | +| plan | `superpowers:writing-plans` | `docket-implement-next` §4 | a plan file on the `docket` branch, recorded in `plan:` | | build | `docket-build` | `docket-implement-next` §5 | the plan executed on the feature branch | | review | `docket-review` | `docket-implement-next` §6 | a whole-branch review before the PR opens, over a branch whose build evidence is green | @@ -154,7 +154,7 @@ docket's workflow steps are **roles**, each done by one fixed skill: <adrs_dir>/ # default docs/adrs/ — flat; ADRs are NEVER archived <NNNN>-<slug>.md # immutable once Accepted (only its status: line ever changes) README.md # generated ADR index -<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (feature-branch build files; NEVER archived) +<results_dir>/ # default docs/results/ — required close-out artifacts (one per implemented change, trivial included) (on the `docket` branch; NEVER archived) <YYYY-MM-DD>-<slug>-results.md ``` @@ -162,7 +162,7 @@ The `archive/` filename date prefix is **UTC**: the **merge commit's** date for All of the above lives on the `docket` branch, written through the persistent, gitignored **`.docket/` metadata worktree** (deliberately not under `.worktrees/` — slug collisions, prune blast radius; see *Branch model*). -**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the feature branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `results:` **field** is set only in the main tree through the `change.attach-results` operation — never cross the streams. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. +**Results artifact shape and lifecycle.** The results file is a required close-out artifact for every implemented change, trivial included. Its template opens with a required `**Human action:**` statement immediately after the title (a settled assessment at finalization) and carries a required `## Outcome` section plus the conditional sections `## Human actions and testing` (items labeled Important or Optional; optional walkthroughs may cover automated behavior), `## Verification performed`, and `## Known issues and follow-ups` — omit any conditional section, subsections and all, when it would hold no substantive content, and never pad one with `None`/`N/A` filler. The results **file** lives on the `docket` branch at `<results_dir>/<YYYY-MM-DD>-<slug>-results.md`; the `change.attach-results` operation writes the file and the `results:` **field** together, in one commit. The coordinator captures findings at defined checkpoints during implementation (docket-implement-next's Step 6.5 is normative), not in a single final pass. Follow-ups recorded here hold out-of-scope work for human triage — the run checks proposed and deferred changes and names any match, but never mints a change, issue, ADR, or learning automatically. ### Change manifest (frontmatter at the top of each change file) @@ -184,8 +184,8 @@ related: [4, 6] # cross-links the reconcile pass reads discovered_from: [62] # change id(s) whose work surfaced this one; informational like related:, never a readiness gate adrs: [24] # ADRs this change cites or produces spec: # superpowers design doc path; set at brainstorm (propose) time, on the `docket` branch -plan: # plan FILE lives on the feature branch; this FIELD is set in the main tree at build time -results: # results FILE on the feature branch; this FIELD set by change.attach-results at the first checkpoint, required before the implemented transition +plan: # plan FILE on the `docket` branch; FILE and FIELD written together by change.attach-plan at build time +results: # results FILE on the `docket` branch; FILE and FIELD written together by change.attach-results at the first checkpoint, required before the implemented transition trivial: false # true = no spec needed (small mechanical change); still build-ready auto_groomable: # `true` makes a stub auto-groomable; unset or `false` means not branch_prefix: # optional one unqualified branch-path component; overrides <type> at mint, consumed only at claim, inert once branch: is populated, survives reclaim @@ -201,31 +201,31 @@ claimed_at: # UTC ISO-8601 claim lease (YYYY-MM-DDTHH:MM:SSZ); sta the sole source of truth for that relationship — the parent-side link is derived at render time — and is never copied into `related:` or `depends_on:`. -**Merged plans and results are frozen build records.** Once a change's PR merges, its `plan:` and +**Closed-out plans and results are frozen build records.** Once a change is closed out, its `plan:` and `results:` files are never hand-edited again — not to correct a stale line reference, not to update a -superseded instruction. The only writes they receive afterward re-stamp the generated `docket:backlink` block (the -`artifact.backlink` operation, and `finalize.closeout`'s integration-ref backlink leg); authored -content never changes. +superseded instruction. The only later write re-stamps the generated `docket:backlink` block, done by +the archive transaction; authored content never changes. They record what a build was *told* to do at the time it ran, which is the only thing that makes a completed run auditable; editing one destroys that record while silently changing what a re-read of the artifact would say the build was asked for. Corrections go in a new -change, never in the merged artifact. +change, never in the frozen artifact. ### Change body sections `change.create` writes `## Artifacts`, `## Why`, `## What changes`, and `## Out of scope`; `## Open questions` and every later section are added by later operations. -- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer: `artifact.backlink` writes the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). +- `## Artifacts` — **first body section** (immediately after the frontmatter closing `---`, above `## Why`). Marker-bounded (`<!-- docket:artifacts:start (generated — do not hand-edit) -->` / `<!-- docket:artifacts:end -->`); rendered from frontmatter by the Go app-layer link-block renderer inside the owning metadata transaction; **never hand-edited** — the renderer is the sole writer. Seeded empty by `change.create`; regenerated after every frontmatter field write. Its **reciprocal** is the `docket:backlink` block (markers `<!-- docket:backlink:start … -->` / `<!-- docket:backlink:end -->`) stamped at the TOP of each artifact (spec, plan, results, PR body) pointing home to the change on the `docket` branch, from one shared renderer. Same-branch links (rows and file backlinks) are relative; the PR body's are absolute. `change.attach-plan`, `change.attach-results`, and `change.groom` write the file blocks, `pr.publish` the PR body's, which `finalize.closeout` (retried by `finalize.cleanup`, `maintenance.sweep`) or `repository.repair` with `--pr-backlinks` repoints at the archived record (ADRs excluded, back-referenced by `change:`). - `## Why` — the motivation, as detailed as warranted (no length limit). - `## What changes` — scope of the work. - `## Out of scope` — explicit non-goals. - `## Open questions` — unknowns to resolve during reconcile/design. - `## Reconcile log` — dated entries appended by the implementer's reconcile pass. -- `## Closeout notes` — final-only, **optional**, and the **final authored body section** of an archived record. Written solely by the `finalize.closeout` operation from its structured request (`verification_outcomes` / `late_findings`, rendered as `### Verification` / `### Late findings` bullet lists); never hand-edited, copied to a stacked descendant, or a link-bearing artifact. The merged `results:` file stays a frozen build record — the freeze rule above is unchanged. +- `## Closeout notes` — final-only, **optional**, and the **final authored body section** of an archived record. Written solely by the `finalize.closeout` operation from its structured request (`verification_outcomes` / `late_findings`, rendered as `### Verification` / `### Late findings` bullet lists); never hand-edited, copied to a stacked descendant, or a link-bearing artifact. The closed-out `results:` file stays a frozen build record — the freeze rule above is unchanged. - `## Reclaim log` — dated entries appended by the `change.reclaim` operation when an expired-lease, no-branch claim self-heals back to `proposed`. - `## Auto-groom blocked` — dated abstain record written by `change.groom` `outcome: abstain`; contents and lifecycle (including removal by `outcome: re-enable`) are defined by the *Autonomous grooming* shared definition below. - `## Finalize blocked` — dated record appended by `docket-finalize-change` when a gate failure leaves a change needing a human; presence drives the board's `finalize blocked — needs you` cell. It is a visible note that never stops finalize selection or merge, so the next finalize run retries the change. The clearing rule is owned by `docket-finalize-change` and not restated here. - `## Run halted` — written by the `change.halt` operation when an autonomous run stops needing a human (the `halted` disposition); the heading is **bare**, never dated — the reader is a whole-line match, so the date sits inside the body. **Marker-section state**, in the same family as `## Auto-groom blocked` and `## Finalize blocked`: while the section is present the `run.verify` operation reports `run-halted`, which is what makes a `halted` disposition verifiable in git rather than a claim in a completion report. It is removed by the `change.resume-halted` operation, and by the claim when a halted change is claimed again. +- `## Build evidence` — the build-evidence block for the certified feature head, written by `change.mark-implemented`, `finalize.publish`, and `evidence.recertify`; never hand-edited. - `## Why deferred` / `## Why killed` — added when entering those states. The change body is a **PM-altitude proposal** (intent + scope). Detailed design lives in the linked superpowers spec; the task breakdown in the linked superpowers plan. Different zoom levels, no duplication. @@ -330,7 +330,7 @@ Compressed rules (detail in the reference): the promotion tiering criterion is ### Derived views (shared definition) -The derived views over the change files — the `inline` board, each change's `## Artifacts` link block, and the reciprocal per-artifact `docket:backlink` block — are rendered by the **Go app inside the owning metadata transaction** (the board and `## Artifacts` block re-render atomically in the same commit that writes the record they reflect) or via the **`artifact.backlink` operation** for the backlink blocks. Each generated block has a **sole writer** and is **never hand-edited** (ADR-0012). +The derived views over the change files — the `inline` board, each change's `## Artifacts` link block, and the reciprocal per-artifact `docket:backlink` block — are rendered by the **Go app inside the owning metadata transaction** (the board, `## Artifacts` block, and file backlinks re-render atomically in the same commit that writes the record they reflect). Each generated block has a **sole writer** and is **never hand-edited** (ADR-0012). ### Bootstrap guard (first-run safety) @@ -350,8 +350,8 @@ At startup, after resolving config, fetch origin and evaluate a 2×2 over two pr Metadata (change files, `BOARD.md`, ADRs, specs) commits to the `docket` branch via the persistent **`.docket/` worktree** (ADR-0099: docket is the sole metadata topology) — and is **always pushed to its remote immediately** (the planning surface stays browsable on the remote). -A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the plan + results + code and **never modifies** docket metadata. +A change's feature branch is minted at claim as `<type>/<slug>`, or `<branch_prefix>/<slug>` when the override is present; after claim the recorded `branch:` is the sole source of truth and is never reconstructed. That branch is **ALWAYS cut from `origin/<integration_branch>`** — the `docket` branch only receives bookkeeping commits, never where code branches start — with **one exception, stated here rather than left to contradict this rule**: a change carrying `stacked_on:` is cut from its **resolved effective base** (its parent's unmerged branch), and its PR targets that base. The resolution, the `stacked-merged` state, the parent's finalize gate, and the killed-parent policy are owned by [`references/stacked-changes.md`](references/stacked-changes.md) — **when the change at hand carries `stacked_on:` or has stacked children, read it now (blocking)**. The feature branch adds only the spec copy and the code and **never modifies** docket metadata. The `.docket` metadata worktree has the repo's shared git hooks disabled (worktree-scoped `core.hooksPath` → an empty docket-owned dir, set by the `repository.prepare` operation), so machine-generated bookkeeping commits coexist with a hook framework on the integration branch; feature-branch code commits still run the team's hooks. -On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code, plans, and results through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. +On a final transition (`done` *or* `killed`), the driving skill runs the shared **close-out** sequence — archive → re-render → cleanup → board. Archived records and ADRs stay on the `docket` branch; the integration branch gets code and specs through PRs alone. Ordering and per-caller failure postures live in **[`references/close-out.md`](references/close-out.md) — read it before driving any final transition.** The close-out embeds no integration-branch sync. Instead, the `repository.sync-integration` operation runs once at the end of the finalize workflow and once at the end of both maintenance-sweep scopes (full and implementation), fast-forwarding a clean primary checkout that is already on the configured integration branch to the freshly fetched tip and reporting an explicit skip otherwise. diff --git a/skills/docket-convention/references/close-out.md b/skills/docket-convention/references/close-out.md index 411f4885b..27ef0b513 100644 --- a/skills/docket-convention/references/close-out.md +++ b/skills/docket-convention/references/close-out.md @@ -30,12 +30,12 @@ before the first read; every commit pushes immediately. ``` `--input` carries only the optional authored closeout notes (`verification_outcomes`, - `late_findings`; `-` for stdin); the merged `results:` file is read from the verified merge, not - passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a + `late_findings`; `-` for stdin); the `results:` file already sits on the `docket` branch and is + never passed. Trust the typed outcome: `done-archived` (or `stacked-merged` / `root-archived` for a stack) ⇒ the change is marked done and relocated to the dated archive path — idempotent if already archived, including across a day boundary. This ONE metadata commit atomically owns the - archive move, the `## Artifacts` re-render, the re-stamp of **every metadata-resident back-link - including the spec** (which lives on the metadata ref), and the inline board render; a typed + archive move, the `## Artifacts` re-render, the re-stamp of **every metadata-resident back-link** + (spec, plan, and results all live on the metadata ref), and the inline board render; a typed refusal or process failure writes nothing and aborts per the caller's posture, with **no partial caller-owned follow-up**. It still relocates the change file in its own step, so concurrent done drivers converge tree-identically (see *Determinism invariant*). The frozen step-2 re-render and @@ -58,28 +58,27 @@ before the first read; every commit pushes immediately. Trust the typed outcome: `applied` ⇒ archived — an idempotent no-op if already archived, including across a day boundary (it reuses the existing dated filename). This ONE metadata commit atomically owns the archive move, the refreshed `updated:` date, the spliced `## Why killed` - section, the `## Artifacts` re-render, the retargeted spec back-link, and the inline board render + section, the `## Artifacts` re-render, the retargeted spec, plan, and results back-links, and the inline board render — so the step-2 re-render and step-4 board pass below carry **nothing** for the kill path, exactly as `finalize.closeout` owns them for the done path. A wrong `revision` or an illegal source status returns a typed refusal that writes nothing (a lost conflict-checked write is `contended`; see *Determinism invariant*). -2. **Artifact block + spec back-link — owned atomically by step 1, no separate caller commit.** - Both close-out transactions re-render the archived record's `## Artifacts` block (plan/results - re-point to the integration branch at final state) **and** re-stamp every metadata-resident - back-link — the spec's `docket:backlink` block included — retargeted to the +2. **Artifact block + back-links — owned atomically by step 1, no separate caller commit.** + Both close-out transactions re-render the archived record's `## Artifacts` block (relative + same-branch rows, plus an absolute `Spec (merged)` row for a merged change) **and** re-stamp every + metadata-resident back-link — spec, plan, and results — retargeted to the now-**archived** change path, **in the same step-1 metadata commit** as the archive: - On the **done** path the step-1 `finalize.closeout` operation transaction owns this restamp atomically. - On the **kill** path the step-1 `change.kill` operation transaction owns it identically — it - re-renders the `## Artifacts` block and retargets the linked spec's `docket:backlink` block in - its one commit. + re-renders the `## Artifacts` block and retargets the linked spec, plan, and results + `docket:backlink` blocks in its one commit. So **no separate caller re-render or back-link commit runs for either path** — the skill never - invokes a facade renderer and never hand-edits a managed block. Nothing is copied onto the - integration branch; a spec that lives on the metadata ref is restamped in that same step-1 - commit. A typed refusal (malformed markers, + invokes a facade renderer and never hand-edits a managed block. Nothing touches the integration + branch. A typed refusal (malformed markers, missing artifact) leaves the file untouched and aborts per the caller's posture — surface it, never hand-edit the block. diff --git a/skills/docket-convention/references/stacked-changes.md b/skills/docket-convention/references/stacked-changes.md index d1b5197b9..454573868 100644 --- a/skills/docket-convention/references/stacked-changes.md +++ b/skills/docket-convention/references/stacked-changes.md @@ -110,7 +110,7 @@ A change whose base does not resolve is **not build-ready**: the board reads At the branch cut, the resolved effective base replaces `origin/<integration_branch>` — and only there. Everything else about the feature branch is unchanged: it is cut after claim and reconcile, -carries only plan + results + code, and never modifies docket metadata. +carries only the spec copy and code, and never modifies docket metadata. ``` workspace.prepare --id <id> --revision <revision> # cuts <type>/<slug> from the effective base; resolve argv from the capability catalog diff --git a/skills/docket-finalize-change/SKILL.md b/skills/docket-finalize-change/SKILL.md index 9e0d9bdd5..77eda9238 100644 --- a/skills/docket-finalize-change/SKILL.md +++ b/skills/docket-finalize-change/SKILL.md @@ -77,7 +77,7 @@ If the candidate has open child PRs targeting this change's branch, they must be The `finalize.rebase` operation with `--id <id> --revision <revision> --head <feature head>`. It writes an ownership-scoped receipt before any Git mutation, rebases the feature branch onto the exact effective base under owned refs, then composes the local gate. Route on `disposition`: -- `unchanged` / `rebased` — the rebase completed and the gate was satisfied (skipped on exact-head PR evidence for a no-op, or run and passed). Carry forward the returned `attempt` token, the resulting `head`, and the gate report's `evidence` block (present when the suite ran; otherwise reuse the exact-head PR-body evidence). Go to step 6. +- `unchanged` / `rebased` — the rebase completed and the gate was satisfied (skipped on exact-head record evidence for a no-op, or run and passed). Carry forward the returned `attempt` token, the resulting `head`, and the gate report's `evidence` block (present when the suite ran; otherwise reuse the exact-head evidence from the change record's `## Build evidence` section). Go to step 6. - `conflicted` — the rebase stopped at the reported `unmerged_paths`. Enter the resolver rounds below. - `waiting` with `reason: gate-waiting` — the rebase completed and the local suite is still running under the detached supervisor; the owned receipt now carries the drive continuation. Re-run the **identical** `finalize.rebase` invocation (same `--id --revision --head`); it recovers the completed rebase from the receipt and advances the **same** drive — on `passed` it mints the evidence block and returns `unchanged`/`rebased` as a single-slice run does. Never re-enter through `gate drive advance`, mint a run root, or carry the continuation yourself — the receipt does. Repeat until a terminal disposition; the driver's observation budget bounds the polling, and expiry surfaces as `blocked` with `reason: gate-halted`. A `waiting` from `finalize.rebase-continue` re-enters the same way: through `finalize.rebase`, never another `rebase-continue`. - `failed` with `reason: gate-failed` — the rebase completed but the local suite is **red** at the rebased head. Enter the repair path (step 5). @@ -109,7 +109,7 @@ A resolver that returns `disposition: stuck`, or a resolver dispatch that is una ### 4. The local gate -The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebase runs the full resolved suite unless the rebase was a **no-op and** the PR body carries **green** build-evidence for the **exact** current head **whose recorded command equals the resolved `finalize.test_command`**, in which case the run is skipped and the permit named in the gate report. A **skipped** (`build-gate-off`) build-evidence record never waives finalize's gate — the build role's gate policy is independent of finalize's, so skipped build evidence, or green evidence recorded against a different command, forces the suite to run. There is no strict-ancestor or results-only skip. A passing gate records evidence through the landed `evidence.record` seam with the finalize owner, so the block names `finalize.test_command`, and returns the block in the rebase document; a red gate returns `failed`/`gate-failed` (step 5). +The gate is composed into `finalize.rebase`/`rebase-continue`: a completed rebase runs the full resolved suite unless the rebase was a **no-op and** the change record's `## Build evidence` section carries **green** build-evidence for the **exact** current head **whose recorded command equals the resolved `finalize.test_command`**, in which case the run is skipped and the permit named in the gate report. A **skipped** (`build-gate-off`) build-evidence record never waives finalize's gate — the build role's gate policy is independent of finalize's, so skipped build evidence, or green evidence recorded against a different command, forces the suite to run. There is no strict-ancestor or results-only skip. A passing gate records evidence through the landed `evidence.record` seam with the finalize owner, so the block names `finalize.test_command`, and returns the block in the rebase document; a red gate returns `failed`/`gate-failed` (step 5). `docket gate` owns the gate's mechanics — the supervised run outliving any foreground call, the durable run directory, completion established from that artifact, and the bounded `gate_observation_budget` that fails closed when spent. **One clause of `docket-build`'s *Gate run posture* it cannot own is yours to obey:** whether you may **yield** while the gate runs is decided by *your own* dispatch posture, never by the gate's. Only a top-level session agent, able to receive a resumption signal, may yield and then make short observations. Running as a dispatched or forked child you have no such channel, so you may **never** yield — observe by *blocking* instead, in repeated short foreground reads, control never handed back to your caller mid-gate. Neither a `finalize.rebase` re-entry nor a `gate.drive.advance` slice can tell which you are; a child that yields here parks until a human notices. @@ -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 converges the PR build-evidence block onto that head — authored prose, title, and every other body byte stay byte-identical. 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. ### 8. Merge exactly once @@ -158,16 +158,16 @@ that fallback dropped — a lost note never stops the closeout. No caller-supplied done boolean or archive date: it reloads metadata, reprobes the PR and its destination, derives the UTC archive date from the verified `mergedAt`, and applies one atomic transaction. Route on `disposition`: -- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec backlink + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. +- `done-archived` — an ordinary change merged to the integration branch: marked `done` (only after the merge-commit reachability proof), relocated to the dated archive path, artifact block + spec, plan, and results backlinks + inline board rerendered, validated, committed by explicit path, lease-pushed. Then, outside that transaction, a best-effort follow-up repoints the merged PR's backlink at the archived record. - `stacked-merged` — the PR merged into a live parent's branch: marked `stacked-merged` in place, not archived, branch and workspace retained until the root lands. - `root-archived` — a stack root reached integration and every carried descendant is proven: its chain of merged PR destinations establishes the carry relationship AND its merged work is verified still present in Git — reachable in the pinned integration history, or exact-content at the root's merge result — since a merged destination alone is a relationship, never proof the content shipped. One transaction archives the root and every descendant using the root's merge date, one board render over the final population. One unproven descendant leaves the root recoverable, zero descendant writes. The same follow-up then repoints the root's and each descendant's PR. - `already` — the promised final state already exists (a response-lost success): a keyed no-op, never a duplicate transition. - `children-retarget-required` — a descendant is not yet stacked-merged; return to step 2 (attended) or `halted` (autonomous). -- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). In `docket` mode the metadata transaction lands first and a separate integration-ref leg patches only the existing `docket:backlink` blocks; a failed leg leaves the change truthfully `done` with a `final-backlink-pending` finding that a retry recovers — cleanup (step 10) repairs it, never a reason to redo the merge. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. +- `contended` / `blocked` / `unknown` — a lost race, an illegal source status or destination mismatch, or an unobservable probe; re-read context (`contended`) or stop (`halted`). Close-out is one metadata commit plus the best-effort PR-description backlink repoint; nothing touches the integration branch. A PR description that could not be repointed is a `pr-backlink-pending` warning on an otherwise-final result: report it, never redo the merge; cleanup (step 10) and the maintenance sweep retry it. ### 10. Cleanup — Docket-owned resources only -The `finalize.cleanup` operation with `--id <id>`. An ordered, independently retryable suffix: repair a pending backlink (artifact files and the PR description) first, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. +The `finalize.cleanup` operation with `--id <id>`. An ordered, independently retryable suffix: repair the PR description's backlink first when it is pending, remove the workspace from manifest facts, delete the local branch only when its exact recorded tip is worktree-detached and contained in the verified merge chain, and delete the remote ref only under the exact lease after a fresh probe shows no open child PR targets it. Every probe treats present, cleanly absent, and unknown as three outcomes — an injected or real probe error retains the resource with a pending result and `children-retarget-required`/a retention reason, never a destroy. A non-final change refuses (the one pre-final exception is restoring an aborted owned rebase). Stacked-merged changes retain their workspace and branches until the root closes. Cleanup failure never unwinds the merge. Once the workspace leg finishes, any disposition (`cleaned`, `pending`, `children-retarget-required`) may carry a `workspace-remnant` warning naming a leftover folder to delete by hand — name it in the final report; a `workspace-blocked` finding names what blocked the workspace. ### 11. Integration sync — best-effort, end-of-run diff --git a/skills/docket-status/SKILL.md b/skills/docket-status/SKILL.md index 4398d8679..5dbe2142c 100644 --- a/skills/docket-status/SKILL.md +++ b/skills/docket-status/SKILL.md @@ -121,6 +121,6 @@ The pass reads nothing and writes nothing under `learnings/`, and nothing refres ### Findings -Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a broken spec/plan/results link, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. +Flag what the pass reports (do not auto-fix unless asked). This skill never runs a checker directly — it invokes the `maintenance.sweep` / `status` operations and reads their `findings`. The `status` read reports configuration diagnostics, `parse-failed`, record validation findings (such as `change-stack-cycle`), `artifact-missing` for a spec/plan/results link on neither the `docket` branch nor, for a plan or results closed before the move, the integration branch, and `branch-malformed`. Derived-view drift (`board-stale`, `adr-index-stale`, `artifact-links-stale`, and their `-malformed` forms) is reported by the `repository.check` operation and repaired through `repository.repair`. A stale claim surfaces as a `maintenance.sweep` reclaim entry, applied only when `reclaim.auto` is true. A stalled dependency surfaces in the backlog digest as a change with `waiting-dependency` readiness and its `unmet_dependencies`. Each finding's code and shape are owned by the `schema` operation. One judgment check stays in-model, on top of the operations: `blocked_by:` re-examination (see *Judgment follow-ups* above) — warn-only, never auto-fix. From 678857ca553c0659bbe1e85578509df8d118f842 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:57:43 -0400 Subject: [PATCH 17/28] docs: build artifacts live on the metadata branch; the spec ships with the PR Living docs describe the current layout: plan, results, and build evidence on the metadata branch (evidence in the record's ## Build evidence section), the spec copy as the feature branch's first commit, relative same-branch links converted by repository repair, close-out retargeting every backlink in the archive commit with no integration-branch push. Glossary entries rewritten; the artifact backlink example, the retired `docket artifact` noun, and the stale verify-run alias line are removed. Residual retired-token hits (git grep -F over the whole tree): - Docket-Plan-Path: docs/codex/fixtures/root-entry/certification.md (dated 2026-09-01 certification record, point-in-time); testdata/bash-upgrade tarballs (frozen corpus). - Docket-Results-Path, closeout-backlink, prBodyEvidence, lifecycleBranch, commitPlanFile: no hits outside point-in-time records. - artifact.backlink: internal/repoguard/budgets_test.go re-baseline history comment (historical rationale). - artifact backlink: internal/app/finalize_closeout_integration_test.go comment describing the metadata-branch retarget (current behavior). - final-backlink-pending: retired-vocabulary guard row only. - skip_results_only_delta: the obsolete-key tombstone (config schema, decode, tests), prose-contract absent pins, the upgrade guide's "ignores it, remove it" row, and frozen testdata corpora. --- README.md | 4 +- docs/comparison/ai-native-sdlc-playbook.md | 8 +- docs/concepts/build-tiers-and-gate.md | 14 ++-- docs/concepts/finalize-sequencer.md | 24 +++--- docs/concepts/reconcile.md | 4 +- docs/concepts/skills-agents-dispatch.md | 5 +- docs/concepts/two-branches.md | 15 ++-- docs/guide/building-without-supervision.md | 6 +- docs/guide/capturing-work.md | 2 +- docs/guide/landing-changes.md | 17 +++-- docs/guide/proving-the-build.md | 20 ++--- docs/guide/reviewing-before-the-human.md | 6 +- docs/guide/where-the-metadata-lives.md | 24 +++--- docs/reference/cli.md | 1 - docs/reference/glossary.md | 87 ++++++++++++---------- docs/reference/skills-and-agents.md | 2 +- 16 files changed, 132 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 792f2e154..cb907ec55 100644 --- a/README.md +++ b/README.md @@ -35,8 +35,8 @@ the audit trail — and the whole interface between you and the autonomous skill |---|---|---| | Plan | The change file: why, what changes, out of scope, dependencies, priority, type | `docs/changes/active/` on the `docket` metadata branch; rendered on `BOARD.md` | | Design | The spec, linked from the change (or `trivial: true` for small mechanical work) | `docs/superpowers/specs/` on the metadata branch | -| Build | A dated reconcile log, the implementation plan, one verified commit per task | The change body; the plan on the feature branch | -| Test | The build-evidence record — suite command, result, head SHA — plus the results file and the review's disposition table | The PR body; `docs/results/` | +| Build | A dated reconcile log, the implementation plan, one verified commit per task | The change body and `docs/superpowers/plans/` on the metadata branch; the commits on the feature branch | +| Test | The build-evidence record — suite command, result, head SHA — plus the results file and the review's disposition table | The change body's `## Build evidence` section and `docs/results/` on the metadata branch; the table in the PR body | | Deploy | The merge (proven reachable), the archived change record, a cleaned branch and worktree, a re-rendered board | `docs/changes/archive/`; `BOARD.md` | ## Why docket: plans rot diff --git a/docs/comparison/ai-native-sdlc-playbook.md b/docs/comparison/ai-native-sdlc-playbook.md index f833623e5..1a83aa87f 100644 --- a/docs/comparison/ai-native-sdlc-playbook.md +++ b/docs/comparison/ai-native-sdlc-playbook.md @@ -63,7 +63,7 @@ fail-closed operations. In return docket has depth the playbook never mentions. | Capability | Playbook | docket | Status | |---|---|---|---| -| Plan committed before code | Plan-mode interview; `plan.md` committed. | `docket-plan-writer` authors via the `plan` role, commits on the feature branch with a trailer; the parent verifies from git facts; `change attach-plan` records it. | Both | +| Plan committed before code | Plan-mode interview; `plan.md` committed. | `docket-plan-writer` authors via the `plan` role and attaches it on the metadata branch through `change attach-plan`; the parent verifies from git facts. | Both | | Human approves the plan | The engineer validates before code. | Deliberately absent; the human's checkpoint is the PR. | Playbook only (by design) | | Refresh a stale change before planning | Not described. | The reconcile pass after claim, before the worktree: re-reads against related/archived changes, ADRs, and code; rewrites scope; `## Reconcile log`; kills obsolete, halts invalidated. | docket only | | Institutional knowledge file | `CLAUDE.md`; mistake twice → into the file. | `AGENTS.md` is the promotion destination for learnings; criterion *will the agent know to search for this?*; human-gated. | Both | @@ -82,7 +82,7 @@ fail-closed operations. In return docket has depth the playbook never mentions. | Capability | Playbook | docket | Status | |---|---|---|---| | Agent verifies before human review | Tests, builds, screenshots; quantifiable targets. | The build gate runs `build.test_command` once after all tasks; unconfigured halts. | Both | -| Verification output as proof of done | Output pasted before "complete". | Build-evidence record (command, result, head SHA, time) minted from the run directory, written into the PR body, required by the reviewer, read by finalize. | Both | +| Verification output as proof of done | Output pasted before "complete". | Build-evidence record (command, result, head SHA, time) minted from the run directory, written into the change record's `## Build evidence` section, required by the reviewer, read by finalize. | Both | | Suite outlives one foreground call | Not described. | `docket gate drive` slices; `gate_observation_budget` fails closed; forked children block. | docket only | | Red suite repaired in-loop | Implied. | One synthetic integration-repair task, one tier above default. | Both | | Block test edits during a fix | A hook forbids editing tests in fix tasks. | Prose contract only. | Playbook only | @@ -131,8 +131,8 @@ fail-closed operations. In return docket has depth the playbook never mentions. |---|---|---| | 1 | `intent.md` | Change file on `docket`: frontmatter + Why / What changes / Out of scope / Open questions; board row. | | 2 | `spec.md` | Spec file on `docket` with a backlink header; `spec:` and `## Artifacts` on the change; or `trivial: true`. | -| 3 | `plan.md` + the diff | `## Reconcile log` and `reconciled: true`; plan on the feature branch (frozen once merged); one verified commit per task. | -| 4 | PR with test results | Build-evidence record in the PR body; results file; review disposition table. | +| 3 | `plan.md` + the diff | `## Reconcile log` and `reconciled: true`; plan on the metadata branch (frozen once closed out); one verified commit per task. | +| 4 | PR with test results | Build-evidence record in the change record; results file; review disposition table in the PR body. | | 5 | Merged PR / commit | Merge proven reachable; change archived as `archive/<date>-<id>-<slug>.md` with optional `## Closeout notes`; branch and worktree cleaned; board re-rendered. | | 6 | Incident record → new `intent.md` | ADRs; learnings findings; discovered work in the run report → human-filed change. Nothing from production. | diff --git a/docs/concepts/build-tiers-and-gate.md b/docs/concepts/build-tiers-and-gate.md index 7cd076e73..4e43ece06 100644 --- a/docs/concepts/build-tiers-and-gate.md +++ b/docs/concepts/build-tiers-and-gate.md @@ -18,7 +18,7 @@ all the pieces are assembled — and leave proof that they did, so the next read trusts a record rather than a claim. Docket answers both halves with routing and a gate. The **plan** — the -task-by-task breakdown a build follows, written on the feature branch — is a +task-by-task breakdown a build follows, written on the metadata branch — is a list of tasks, and each is routed to a **build tier**: one of four workers (economy, standard, premium, max) chosen by risk. After the last task lands, the **build gate** — the full test-suite run at the end of a @@ -83,10 +83,10 @@ build that must be green before review — runs the whole suite once and records build halts with the remedy `docket repository configure-tests` (add `--command "<cmd>"` when discovery cannot find the suite). - Build evidence is minted by `docket evidence record` from the passed run and - checked by `docket evidence verify`. It lives in the pull request body's - build-evidence block and is never committed, so the reviewer reads a durable - record of the gate run instead of trusting a worker's word that the suite - passed. + checked by `docket evidence verify`. The reviewer reads that record of the + gate run instead of trusting a worker's word that the suite passed, and when + the change is marked implemented the record lands in the change file's + `## Build evidence` section on the metadata branch, never in the code. ## The invariants @@ -100,8 +100,8 @@ build that must be green before review — runs the whole suite once and records only the tests a single task enumerated. - The gate's suite command is read from configuration (`build.test_command`), never from a second copy, so it tests the exact checkout under review. -- Build evidence is recorded in the pull request body before review begins, so - review rests on a recorded gate run rather than a claim. +- Build evidence is recorded before review begins, so review rests on a + recorded gate run rather than a claim. - An empty test command never reads as a red suite: it halts with a remedy instead of manufacturing a repair task. diff --git a/docs/concepts/finalize-sequencer.md b/docs/concepts/finalize-sequencer.md index 53f1f6d00..fad770792 100644 --- a/docs/concepts/finalize-sequencer.md +++ b/docs/concepts/finalize-sequencer.md @@ -38,7 +38,7 @@ to it. │ └─(red)─► integration repair: minimal fix, then retest │ (at most finalize.repair_max_attempts) ▼ - finalize publish: push the rebased head, update the PR's build evidence + finalize publish: push the rebased head, record its build evidence │ ▼ finalize merge: rebase, merge commit, or squash @@ -63,8 +63,8 @@ to it. test by combining two correct changes. A red suite here goes to a bounded integration-repair step — a minimal fix, never a weakened test — and the suite is re-run, at most `finalize.repair_max_attempts` times, before the sequence - continues. When the rebase was a no-op and the branch already carries green - build evidence for that exact head, produced by the same test command, the + continues. When the rebase was a no-op and the change record already carries + green build evidence for that exact head, produced by the same test command, the suite is not run again. - A repair that turns the suite green merges like any other green change; the run report and the archived record's closeout notes name what broke and the @@ -73,8 +73,9 @@ to it. repair removes the approval and the merge waits for a fresh one. - `finalize.gate: off` skips the rebase and the retest entirely; the remaining steps still run in order. -- Publish pushes the rebased head and updates the build-evidence block in the - pull request body, so the evidence names the exact commit that will merge. +- Publish pushes the rebased head and records its build evidence in the change + record's `## Build evidence` section on the `docket` branch, so the evidence + names the exact commit that will merge. The pull request body is not touched. - The merge is a policy gate, kept separate from the mechanical ones: whether a human approval is required is configured ahead of time (`finalize.require_pr_approval`), and the single-maintainer path is branch @@ -83,19 +84,18 @@ to it. squash. - Closeout proves the merge landed, then marks the change `done` and archives it on the `docket` branch (a stacked change merged into its parent is marked - `stacked-merged` instead), and retargets the backlinks in the change's spec, plan, - and results files to the archived record. It also repoints the backlink at the - top of the merged pull request's description to the archived record. A + `stacked-merged` instead), and in that same metadata commit retargets the + backlinks in the change's spec, plan, and results files to the archived record. + It also repoints the backlink at the top of the merged pull request's + description to the archived record, best-effort. A description edit that fails leaves a `pr-backlink-pending` finding (the change stays `done`), and `docket finalize cleanup` and the maintenance sweep retry it. The full maintenance sweep (`--scope full`) also repoints, without a preview, any merged pull request whose description backlink still names a change's old `active/` path, however long ago that change was archived; `docket repository repair --pr-backlinks` is the explicit way to preview and - repoint them all at once. Nothing is copied to the - integration branch. A backlink retarget that fails leaves a - `final-backlink-pending` finding (the change stays `done`), which - `docket finalize cleanup` repairs. + repoint them all at once. Nothing is pushed to the integration branch at + close-out: it receives only what a pull request merges. - Cleanup removes the feature branch and its worktree, and is fail-closed: it never leaves the repository half-destroyed, so an interrupted close-out is recoverable rather than a worktree gone with its change not closed out. diff --git a/docs/concepts/reconcile.md b/docs/concepts/reconcile.md index c0d39c76a..c55838875 100644 --- a/docs/concepts/reconcile.md +++ b/docs/concepts/reconcile.md @@ -55,7 +55,9 @@ taken — and the first line of code. and not just in the builder's head. - The refreshed spec is read from the **metadata branch** — the `docket` git branch where the backlog, specs, and decisions are stored, separate - from the code — during the build, never carried on the feature branch. + from the code — during the build. The feature branch carries only a copy + of it, its first commit, so the spec reaches the integration branch with + the pull request. Reconcile is docket's one addition to the standard build chain that other AI-native workflows do not describe. The diff --git a/docs/concepts/skills-agents-dispatch.md b/docs/concepts/skills-agents-dispatch.md index 84b0e488f..bbe1afa6b 100644 --- a/docs/concepts/skills-agents-dispatch.md +++ b/docs/concepts/skills-agents-dispatch.md @@ -59,9 +59,8 @@ and review (`docket-build`, `docket-review`). The roles are fixed. A workflow step names the agent it wants and dispatches it. Most dispatches hand their result back in the dispatch return. Three do not: the ADR, status, and -plan-writer agents record their result in git — an ADR or a board refresh on the -`docket` branch, a plan committed on the feature branch — and the caller reads -it from there. +plan-writer agents record their result in git — an ADR, a board refresh, or a +plan on the `docket` branch — and the caller reads it from there. Whether a dispatch capability actually exists is resolved on the machine, by trying it, never guessed from a tool name. Where dispatch is genuinely diff --git a/docs/concepts/two-branches.md b/docs/concepts/two-branches.md index 4a07d0619..f8b5e327f 100644 --- a/docs/concepts/two-branches.md +++ b/docs/concepts/two-branches.md @@ -49,13 +49,16 @@ before building, live under `.docket/` on the metadata branch. So does every accepted; the **board**, the generated overview of every change and its state, never edited by hand; and the **learnings**, the learnings ledger of lessons from past builds, curated by a human. Where changes and ADRs sit inside `.docket/` -follows the `changes_dir` and `adrs_dir` settings. Your code checkout never sees -any of them. +follows the `changes_dir` and `adrs_dir` settings. The implementation plan, the +results file, and the build evidence a build produces are written there too. +Your code checkout sees none of them, except a copy of the change's spec, which +the feature branch carries as its first commit so the spec merges with the code. -When a change closes out, it is archived on the metadata branch only. Nothing is -copied to the integration branch; the plan and results files that reached it -through the feature branch's pull request keep a backlink to the change's record -on the `docket` branch. +Links between files on the metadata branch are relative paths. When a change +closes out, it is archived on the metadata branch only, and the same metadata +commit retargets the backlinks in its spec, plan, and results files to the +archived record. Nothing is pushed to the integration branch: it receives only +what a pull request merges. There is one metadata layout. A repository that has never used docket has no metadata branch, and docket refuses to work in it rather than half-initialize diff --git a/docs/guide/building-without-supervision.md b/docs/guide/building-without-supervision.md index 0927d96dd..8a1d63fe3 100644 --- a/docs/guide/building-without-supervision.md +++ b/docs/guide/building-without-supervision.md @@ -75,8 +75,10 @@ A single implement-next run walks a fixed path and then stops: stuck — see [Keeping the backlog honest](./keeping-the-backlog-honest.md). 3. **Reconcile.** The step above — freshen the change against reality, or kill or escalate it. 4. **Plan.** It cuts a feature branch, creates an isolated working copy of the repo on that branch - (a *worktree*), and authors a **plan** on that branch (the task-by-task breakdown a build - follows, written on the feature branch). The plan lives with the code, not with the backlog. + (a *worktree*), commits a copy of the change's spec as the branch's first commit, and authors a + **plan** (the task-by-task breakdown a build follows). The plan lives with the backlog on the + metadata branch, not with the code; so do the results file and the build evidence, so the pull + request carries only the spec copy and the code. 5. **Build.** It works the plan task by task, test-first, committing as it goes (the next section covers how it decides how hard to work on each task). 6. **Review.** Before the pull request opens, a bounded reviewer reads the whole branch — see diff --git a/docs/guide/capturing-work.md b/docs/guide/capturing-work.md index baf610285..8eb68d5f1 100644 --- a/docs/guide/capturing-work.md +++ b/docs/guide/capturing-work.md @@ -25,7 +25,7 @@ write in prose. As you meet them, the manifest fields are: - `type` — which category of work this is (see [Typing your work](#typing-your-work), below). - `depends_on` — the ids of other changes that must be `done` before this one can start. - `spec`, `plan`, `results` — the design document a change links to, written before building; - the task-by-task breakdown a build follows, written on the feature branch; and the required + the task-by-task breakdown a build follows, written on the metadata branch; and the required close-out record of what a build actually did (one per implemented change, trivial included). Filled in as the change moves through its life. - `trivial` — a flag that says "this is small and mechanical enough to skip the design step." diff --git a/docs/guide/landing-changes.md b/docs/guide/landing-changes.md index 3702ffe72..39f45e7f1 100644 --- a/docs/guide/landing-changes.md +++ b/docs/guide/landing-changes.md @@ -16,24 +16,27 @@ its pull request is approved or merged, `docket-finalize-change`: branch. A conflict goes to a conflict resolver; 2. re-runs the test suite on the rebased branch (the finalize gate). A red suite goes to a repair pass, capped by `finalize.repair_max_attempts`; -3. pushes the rebased head and updates the build evidence in the pull request - (`docket finalize publish`); +3. pushes the rebased head and records its build evidence in the change record's + `## Build evidence` section (`docket finalize publish`); 4. merges the pull request with the first merge method the repository permits: rebase, then merge commit, then squash. On a private repository whose GitHub plan has no branch rules, the method comes from the repository settings alone; -5. marks the change `done` and archives it on the `docket` branch, refreshing the **board** (the - generated overview of every change and its state, never edited by hand); +5. marks the change `done` and archives it on the `docket` branch in one commit that also retargets + the backlinks in its spec, plan, and results files, refreshing the **board** (the generated + overview of every change and its state, never edited by hand). The backlink at the top of the + merged pull request's description is repointed best-effort; 6. cleans up the change's worktree and feature branch. -Nothing is copied onto the integration branch at close-out: the change record, its spec, and its -decisions stay on the `docket` branch. +Nothing is pushed to the integration branch at close-out: it already holds what the pull request +merged (the spec copy and the code), and the change record, its plan, results, and decisions stay on +the `docket` branch. The retest is the load-bearing step. The build's own tests certified the branch as it stood when the build finished; the rebase re-checks it against whatever merged in the meantime, so a branch that was green in isolation but conflicts with newer work cannot land a broken integration. `finalize.gate` is the on/off switch for that finalize gate — leave it `local` (the default) unless you trust each pull request's own continuous-integration checks, in which case `off` skips the local rebase-and-retest. -When the rebase changed nothing and the pull request already carries green build evidence for that +When the rebase changed nothing and the change record already carries green build evidence for that exact head, recorded with the same test command, the retest is skipped. The step-by-step mechanism, and what happens when the rebased suite reds, is [Finalize as a sequencer](../concepts/finalize-sequencer.md); re-greening after the rebase is covered diff --git a/docs/guide/proving-the-build.md b/docs/guide/proving-the-build.md index d0b4f15f4..182042791 100644 --- a/docs/guide/proving-the-build.md +++ b/docs/guide/proving-the-build.md @@ -43,19 +43,20 @@ Two `build:` config keys shape this step, both settable in any config layer: A green gate does not just pass silently — it leaves **build evidence** (the immutable record of that gate run, read by the reviewer). `docket evidence record` mints it from the passed run, and -`docket evidence verify` checks it against the branch head. It lives in the pull request body's -build-evidence block and is never committed to the repository. The record captures the command that ran, its result, the exact +`docket evidence verify` checks it against the branch head. It lives in the change record's +`## Build evidence` section on the metadata branch, never in your code. The record captures the command that ran, its result, the exact branch head it ran against, and a timestamp. The reviewer verifies that this record is present, green, and pinned to the exact head it is reviewing; if it is missing, malformed, or stale, the reviewer returns a blocker and refuses to certify — running the suite itself is never the reviewer's job. -That same record carries forward into the pull request body, where the close-out sequence reads it -back. This is what lets a clean merge skip a redundant test run: when the pre-merge rebase changes +When the change is marked implemented, that same record is written into the change record, where +the close-out sequence reads it back. This is what lets a clean merge skip a redundant test run: when the pre-merge rebase changes nothing and the recorded evidence is green and still pinned to the head being merged, the post-rebase run is skipped and the skip is logged. The concrete payoff is that the whole path from build to merge runs the suite **once** when nothing has to be fixed or rebased — the record, not a re-run, carries -the proof between steps. +the proof between steps. The results file lives on the metadata branch too, so recording a results +checkpoint after the gate never moves the feature head and never forces a retest. ### Re-certifying after a follow-up commit @@ -67,11 +68,12 @@ in-place recovery is: It reruns the configured `build.test_command` (only the build command — never finalize's) in the change's feature worktree at the current published head, records and verifies fresh evidence, and -replaces only the build-evidence block on the existing PR. The change stays `implemented`; nothing -is committed, pushed, rebased, or merged. Preconditions: a clean feature worktree whose local head, +replaces the change record's `## Build evidence` section in one metadata commit. The change stays +`implemented`; the pull request description is not edited, and nothing is pushed, rebased, or +merged. Preconditions: a clean feature worktree whose local head, remote feature head, and single open PR head all agree — an unpushed follow-up must be published -through the normal workflow first. `build.gate: off` records truthful skipped evidence (the PR -block is untouched); a failed or halted gate reports repair work and never touches the PR. The run +through the normal workflow first. `build.gate: off` records truthful skipped evidence; a failed or +halted gate reports repair work and never touches the change record. The run charges one attempt against the change's `build.max_attempts` suite budget. The build command itself must leave the worktree clean. The pre-publish cleanliness recheck is the diff --git a/docs/guide/reviewing-before-the-human.md b/docs/guide/reviewing-before-the-human.md index ee151dffa..e81d9f524 100644 --- a/docs/guide/reviewing-before-the-human.md +++ b/docs/guide/reviewing-before-the-human.md @@ -41,7 +41,7 @@ is unchanged — it returns the finding list and a one-line verdict, and never f Two axes are kept deliberately apart: - **Character** picks the build tier, using the same routing rubric the build applies to a **plan** - (the task-by-task breakdown a build follows, written on the feature branch) task — so a subtle + (the task-by-task breakdown a build follows, written on the metadata branch) task — so a subtle one-line fix is not handed to a cheap model just for being labelled minor. - **Severity** picks only the *failure posture*: a blocker that cannot be fixed halts the run, while an important or minor that cannot be fixed falls back to a line in the pull request body. Fix routing @@ -95,12 +95,12 @@ runs on the branch as it lands, and the reviewer is the human-style reader who t instead of re-running it. That boundary is made durable by the **build evidence** (the immutable record of that gate run, -carried in the pull request body and read by the reviewer). On green, the gate emits the command it ran, the result, the exact branch head, and +read by the reviewer and kept in the change record's `## Build evidence` section). On green, the gate emits the command it ran, the result, the exact branch head, and a timestamp; the reviewer verifies the record is present, green, and pinned to the exact head it is reviewing, and returns an `unverified-build-state` blocker if it is missing, malformed, or stale — running the suite itself is never the remedy. When a review-feedback follow-up commit has already been pushed to the open PR and only the evidence went stale, `docket evidence recertify --id <id>` -re-runs the build gate at the new head and refreshes the PR's evidence block in place — no re-entry +re-runs the build gate at the new head and refreshes the change record's evidence in place — no re-entry into implement-next and no merge-time re-gate needed. How that record is minted and carried forward is [Proving the build](./proving-the-build.md); the tier ladder and the gate verdict as a mechanism are [Build tiers and the suite gate](../concepts/build-tiers-and-gate.md). diff --git a/docs/guide/where-the-metadata-lives.md b/docs/guide/where-the-metadata-lives.md index 83e0b3abe..021cd1117 100644 --- a/docs/guide/where-the-metadata-lives.md +++ b/docs/guide/where-the-metadata-lives.md @@ -27,14 +27,15 @@ Two branches divide the work, and neither touches the other's history: reviewable on the remote at all times. Every bit of planning churn lands here and never touches your code history. - Your **integration branch** — the branch code lands on, usually `main` (or `develop` under - GitFlow) — stays code-only. It holds your code and the build artifacts that arrive with each pull - request (the plan and results files). Nothing is copied onto it when a change closes out. + GitFlow) — stays code-only. It receives exactly what each pull request carries: a copy of the + change's spec and the code. Nothing is pushed to it when a change closes out. A change's feature branch is always cut from the integration branch — unless the change is a **stacked change** (a change built on another change's unmerged branch rather than on the integration branch), in which case it is cut from that parent's branch and targets it while the parent is still open, then moves onto the integration branch once the parent is `done`. Either way, -the feature branch carries only plan, results, and code, and never modifies planning records. +the feature branch carries only the spec copy (its first commit) and code, and never modifies +planning records. ## Where each artifact lives @@ -43,19 +44,22 @@ Each kind of record has one home: | Record | Lives on | Reaches the integration branch | |---|---|---| | Change file (manifest + body) | metadata branch | never | -| Spec | metadata branch | never | +| Spec | metadata branch | a copy, as the feature branch's first commit, through the pull-request merge | | ADR | metadata branch | never | | Learnings | metadata branch | never | | Board | metadata branch | never | -| Plan | feature branch | through the pull-request merge | -| Results | feature branch | through the pull-request merge | +| Plan | metadata branch | never | +| Results | metadata branch | never | +| Build evidence | the change file's `## Build evidence` section | never | | Code | feature branch | through the pull-request merge | | `.docket.yml` | committed with your code | already there | -The split to notice: plan, results, and code are **build artifacts** that live on the feature -branch and ride onto the integration branch through the ordinary pull-request merge. Every planning -record — the change file, spec, ADRs, learnings, and the board — stays on the metadata branch for -good, including after the change closes out; the archive lives there too. +The split to notice: only the code and a copy of the spec ride a pull request onto the integration +branch. The plan, the results file, and the build evidence are written on the metadata branch as +the build runs, so recording a results checkpoint never moves the feature branch. Every planning +record stays on the metadata branch for good, including after the change closes out; the archive +lives there too. Links between files on the metadata branch are relative paths, so they keep +working after a record is archived; `docket repository repair` converts older absolute ones. ## `integration_branch` and GitFlow diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 3990274bd..13eef003b 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -24,7 +24,6 @@ Each line is a pointer: the command, what its verbs govern, and where to read th - **`docket adr`** — record, supersede, and reverse architecture decisions. Verbs: `docket adr --help`. - **`docket agent`** — enter harness agent roles (`docket agent enter` starts a Codex role as a foreground root thread). Verbs: `docket agent --help`. -- **`docket artifact`** — render docket-managed blocks into workflow artifacts. Verbs: `docket artifact --help`. - **`docket capabilities`** — emit the binary's complete executable command catalog (read-only, repository-independent). Verbs: `docket capabilities --help`. - **`docket change`** — create and transition changes in the backlog. Verbs: `docket change --help`. diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index df420a155..4611ec869 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -55,8 +55,9 @@ docket repository configure-tests --command "sh ./test.sh" # set both gates to ### Archived record -A change's archived file (plus its results) once it reaches a final status, `done` or `killed`. It -stays on the metadata branch; the integration branch gets code, plans, and results through PRs alone. +A change's archived file once it reaches a final status, `done` or `killed`. It stays on the +metadata branch with its spec, plan, and results; the integration branch gets only what PRs merge +(the code and a copy of the spec). **Used for:** historical browsing. `archive/` on the metadata branch keeps every closed change. @@ -79,9 +80,9 @@ docket repository prepare --repo-dir . --json # the verdict surfaces as this e The branch a single change's code is built on, minted at claim as `<type>/<slug>` (or `<branch_prefix>/<slug>`) and recorded in the change file's `branch:` field. -**Used for:** carrying the code, plan, and results of one change. It never modifies docket -metadata. Branches are named by slug, not id — read the change's `branch:` field rather than -grepping for the number. Status output carries no branch or PR; the change file and the workspace +**Used for:** carrying the code of one change, after a first commit that copies its spec. It never +modifies docket metadata. Branches are named by slug, not id — read the change's `branch:` field +rather than grepping for the number. Status output carries no branch or PR; the change file and the workspace inspection do. ```sh @@ -287,24 +288,26 @@ docket change claim --id 412 --revision <that-revision> A derived view is anything rendered from the change files rather than authored: the board, each change's `## Artifacts` link block, and the `docket:backlink` block stamped at the top of every -spec, plan, results file, and PR body. Each has exactly one writer and is never hand-edited. -When a change closes out, every one of these backlinks, the one in the merged PR's description -included, is repointed to the archived record. +spec, plan, results file, and PR body. Each has exactly one writer and is never hand-edited. Links +between files on the metadata branch are relative; the PR row, the `Spec (merged)` row, and the +PR-body links are absolute. When a change closes out, the archive commit retargets every backlink +on the metadata branch, and the merged PR's description backlink is repointed best-effort. **Used for:** keeping links between a change and its artifacts correct in both directions. ```sh -docket artifact backlink --artifact docs/results/<file>.md --change docs/changes/active/0412-<slug>.md +docket repository check # reports records whose links are stale or absolute +docket repository repair # re-renders them in one metadata commit ``` ### Frozen build record -A merged change's `plan:` and `results:` files. After the PR merges, nobody hand-edits them again, -not even to fix a stale line reference. +A closed-out change's `plan:` and `results:` files. After the change closes out, nobody hand-edits +them again, not even to fix a stale line reference. **Used for:** keeping a completed run auditable: they record what the build was told to do at the -time. Corrections go in a new change. The only writer allowed afterwards is `artifact.backlink`, -which re-stamps the generated backlink block. +time. Corrections go in a new change. Only docket touches them afterwards, and only their +generated backlink block. ### Id / slug @@ -372,14 +375,14 @@ hand edit leaves the board stale. ### Plan -The task-by-task breakdown a build follows, written on the feature branch. +The task-by-task breakdown a build follows, written on the metadata branch. -**Used for:** routing each task to a build tier. The plan file lives on the feature branch; the -`plan:` field is attached on the metadata branch. A merged plan is a frozen build record — never -hand-edited afterwards. +**Used for:** routing each task to a build tier. `change attach-plan` writes the plan file with its +backlink and sets the `plan:` field in one metadata commit; a re-plan replaces the content. A +closed-out plan is a frozen build record — never hand-edited afterwards. ```sh -docket change attach-plan --id 412 --revision <v> --path docs/superpowers/plans/<file>.md --commit <sha> +docket change attach-plan --id 412 --revision <v> --path docs/superpowers/plans/<file>.md --markdown <file> ``` ### Marker section @@ -398,11 +401,12 @@ The close-out record of what a build actually did — required for every impleme trivial included. **Used for:** telling the human what to check (`**Human action:**`, `## Outcome`, verification, -known follow-ups). The file lives in `<results_dir>` on the feature branch; the `results:` field is -attached on the metadata branch. +known follow-ups). `change attach-results` writes the file in `<results_dir>` on the metadata +branch with its backlink and sets the `results:` field; each later checkpoint is one more metadata +commit that replaces the content, and never moves the feature branch. ```sh -docket change attach-results --id 412 --revision <v> --path docs/results/<file>.md --commit <sha> +docket change attach-results --id 412 --revision <v> --path docs/results/<file>.md --markdown <file> ``` ### Results `**Human action:**` line @@ -418,7 +422,9 @@ assessment is pending; the final results must give a settled answer. The design document a change links to, written before building. **Used for:** giving the build everything it needs to implement without guessing. Stored on the -metadata branch and linked from the `spec:` field; produced by a brainstorm or by auto-groom. +metadata branch and linked from the `spec:` field; produced by a brainstorm or by auto-groom. A copy +is the feature branch's first commit (`workspace commit-spec`), so it merges with the code; after +the merge, the `## Artifacts` block adds a `Spec (merged)` row linking that copy. ### Stub @@ -711,13 +717,15 @@ to act on. Neither fails the run, and neither is part of the build gate in your ### Build evidence The immutable record of a passed [build gate](#build-gate) run, minted by `evidence record` and -checked by `evidence verify`. It certifies an exact tested commit and lives in the PR body's -build-evidence block; it is never committed. With `build.gate: off` the record says `skipped` -(`build-gate-off`). When finalize re-tests a head, `evidence record --owner finalize` records +checked by `evidence verify`. It certifies an exact tested commit and lives in the change record's +`## Build evidence` section on the metadata branch, written by `change mark-implemented`, +`finalize publish`, and `evidence recertify`; the PR body carries none. With `build.gate: off` the +record says `skipped` (`build-gate-off`). When finalize re-tests a head, `evidence record --owner finalize` records `finalize.test_command` instead, and is never `skipped`. **Used for:** letting review and finalize trust a record rather than a worker's word. Adding a -commit after the evidence was recorded makes it stale (`evidence-unverified`). +commit to the feature branch after the evidence was recorded makes it stale +(`evidence-unverified`); a results checkpoint never does. ```sh docket evidence record --id 412 --head <sha> --run <run-dir> @@ -818,14 +826,16 @@ docket change mark-implemented --id 412 --revision <v> --head <sha> --pr <url> - ### Plan writer -`docket-plan-writer` — the agent implement-next dispatches to invoke the plan skill, commit the -plan with its backlink on the feature branch, and return `PLAN_PATH=<path>`. +`docket-plan-writer` — the agent implement-next dispatches to invoke the plan skill, attach the +plan through `change attach-plan` (which writes it with its backlink on the metadata branch), and +return `PLAN_PATH=<path>`. ### PR publish Creates the change's pull request for a published feature head, or adopts the one that already -exists. It writes docket's backlink and build-evidence blocks into the body. Before calling GitHub -it checks that the local head, the remote head, the evidence head, and the requested head all +exists. It writes docket's backlink block and absolute links to the change's plan and results on +the metadata branch into the body; the body carries no build evidence. Before calling GitHub it +checks that the local head, the remote head, the evidence head, and the requested head all match. **Used for:** opening the PR at the end of a build. It never makes a duplicate PR, and your @@ -850,11 +860,11 @@ docket maintenance preflight --json The supported fix when a follow-up commit (for example, one answering review feedback) is pushed to an `implemented` change's open PR and the build evidence goes stale (`evidence-unverified`). It -re-runs `build.test_command` at the current published head and replaces only the PR's -build-evidence block. +re-runs `build.test_command` at the current published head and replaces the change record's +`## Build evidence` section. **Used for:** refreshing the proof without re-entering implement-next. The change stays -`implemented`, and nothing is committed, pushed, or merged. It needs a clean feature worktree whose +`implemented`; its only write is that one metadata commit, and nothing is pushed or merged. It needs a clean feature worktree whose local, remote, and PR heads all agree. It charges one attempt against `build.max_attempts`. ```sh @@ -876,8 +886,8 @@ docket change reconcile --input reconcile.json ### Run verify -A read-only check of one change's claim-to-implemented postconditions (committed plan and results, -evidence, PR, status) that reports a closed verdict. The convention calls it **verify-run**. Its +A read-only check of one change's claim-to-implemented postconditions (plan and results on the +metadata branch, the record's build evidence, PR, status) that reports a closed verdict. Its verdicts are `run-complete`, `run-unclaimed`, `run-incomplete`, `run-halted`, and `run-waiting`, all with exit code 0, so key on the verdict, never the exit code. @@ -1322,7 +1332,8 @@ drain **does merge**. Naming ids bounds the run and authorizes merges `require_p How finalize validates the rebased branch before merging: it rebases onto the change's effective base and re-runs the suite. `finalize.gate` is `local` (rebase, then run `finalize.test_command` here; the default) or `off` (skip the rebase and retest and trust the PR's CI). A no-op rebase whose -build evidence is green for the exact head, with the same command, skips the suite. +recorded build evidence in the change record is green for the exact head, with the same command, +skips the suite. **Used for:** never merging a stale branch untested. It shares the worktree's single [worktree lock](#worktree-lock), so it can be refused with `worktree-busy`. @@ -1330,8 +1341,8 @@ build evidence is green for the exact head, with the same command, skips the sui ### Finalize publish The finalize step that pushes the rebased (or repaired) head to the remote feature branch, using -the receipt's exact lease. It then updates the PR's build-evidence block to match that head and -leaves the rest of the PR body byte-identical. +the receipt's exact lease. It then records that head's build evidence in the change record's +`## Build evidence` section; the PR body is not touched. **Used for:** getting the head that finalize retested onto the PR before the merge. It never creates a second PR. An `unknown` probe (`rewrite-unknown` / `pr-probe-failed`) stops the run with diff --git a/docs/reference/skills-and-agents.md b/docs/reference/skills-and-agents.md index f0ab7fd13..c0a2d2219 100644 --- a/docs/reference/skills-and-agents.md +++ b/docs/reference/skills-and-agents.md @@ -57,7 +57,7 @@ seventeen, installed for your user only. - **docket-finalize-change** — dispatch wrapper for the finalize sequence. - **docket-implement-next** — dispatch wrapper for the autonomous backlog-drainer. - **docket-integration-repair** — re-green the suite after finalize's rebase within the configured repair-attempt budget (default 6). -- **docket-plan-writer** — write and commit the implementation plan on the feature branch. +- **docket-plan-writer** — write the implementation plan and attach it on the metadata branch. - **docket-rebase-resolver** — resolve rebase conflicts during finalize's rebase gate. - **docket-review-deep** — the deep tier of the whole-branch reviewer. - **docket-review-lean** — the lean tier of the whole-branch reviewer. From 77b759ca4af828b8e5221c1d91846e3e09df45b3 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 02:58:28 -0400 Subject: [PATCH 18/28] docs(results): change 0530 build checkpoint --- ...evidence-on-the-metadata-branch-results.md | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md diff --git a/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md new file mode 100644 index 000000000..502680a51 --- /dev/null +++ b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md @@ -0,0 +1,23 @@ +<!-- docket:backlink:start (generated — do not hand-edit) --> +> ↩ **[Change 0530 — Keep plan, results, and build evidence on the metadata branch, and ship the spec with the PR](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0530-keep-plan-results-and-build-evidence-on-the-metadata-branch.md)** +<!-- docket:backlink:end --> +# Keep plan, results, and build evidence on the metadata branch, and ship the spec with the PR — Results + +**Human action:** Pending — the build is complete and the whole-branch review has not run yet. Install ordering (below) will need a human regardless. + +## Outcome + +Build artifacts move off the PR. After this change a change's plan and results files are written to the `docket` metadata branch at their existing paths by `change.attach-plan` / `change.attach-results` (which now carry the Markdown with `--markdown`), and build evidence lives in a `## Build evidence` section of the change record instead of the PR description. The feature branch's first commit is a copy of the spec (new `workspace.commit-spec` operation), so a PR carries the spec copy plus code and nothing else. Links between artifacts on the same branch are relative, and the post-merge backlink commit to `main`, the `Docket-Plan-Path` trailer, `artifact.backlink`, and the `finalize.skip_results_only_delta` key are retired. + +Departures from the spec: + +- `pr.publish` writes no evidence anywhere. The spec listed it as a writer; `change.mark-implemented` already records the same verified evidence in a metadata transaction, and a second write at publish would move the record revision between the two steps. `pr.publish` still verifies `--evidence` against the head. +- `finalize.publish` with skipped (`build.gate: off`) evidence now records it in the change record. Previously it refused with `body-assembly-failed`. +- `workspace.commit-spec` builds the commit in a private index and fast-forwards the branch by compare-and-swap rather than staging in the worktree, so a failed commit leaves the workspace untouched. +- The PR description gains a generated block of absolute links to the plan and results on the metadata branch, right after the backlink. + +## Known issues and follow-ups + +### This run used the previous flow + +This change was built with the previously installed binary, so its own plan and results ride this feature branch and its evidence sits in the PR description, as the spec's cutover rule requires. Changes in progress or implemented when this merges must finish on the old binary before the new one is installed. From 349f7866b68bd0ffc65c3c13fd3b30b6bc6f3032 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 03:18:13 -0400 Subject: [PATCH 19/28] fix(attach): key attach idempotency on the submitted record revision Review finding (important): change_attach.go changeAttachMetadata keyed its idempotency RequestID and digest on (kind, id, path, content) only. The engine's scanForRequest searches the whole metadata ancestry, so attaching X, then Y, then X again replayed the first X receipt and left Y stored while the caller saw applied. Fold the submitted revision into the digest payload and (hashed) into the RequestID: a lost-response retry at the same revision still replays, while earlier bytes at a later revision are a new request. TestIntegrationWorkflowRepoChangeAttachRevertToEarlierContent pins both. --- internal/app/change_attach.go | 30 +++++++++----- internal/app/workflow_integration_test.go | 48 +++++++++++++++++++++++ 2 files changed, 68 insertions(+), 10 deletions(-) diff --git a/internal/app/change_attach.go b/internal/app/change_attach.go index 4bfd7416a..0c645cd85 100644 --- a/internal/app/change_attach.go +++ b/internal/app/change_attach.go @@ -134,13 +134,17 @@ type ChangeAttachRequest struct { } // attachDigestPayload is the idempotency digest payload: the promised state a -// retry must match — the change id, the artifact path, and Blob, the sha256 of -// the stored artifact bytes. Keying on the content (not merely the path) means a -// retry after the content changed is NOT a replay. +// retry must match — the change id, the artifact path, Blob (the sha256 of the +// stored artifact bytes), and Revision, the submitted record revision. Keying on +// the content means a retry after the content changed is NOT a replay; keying on +// the revision means re-attaching earlier content at a later record revision is a +// new request rather than a replay of the earlier receipt (the engine scans the +// whole metadata ancestry for a matching request). type attachDigestPayload struct { - Blob string `json:"blob"` - ID int `json:"id"` - Path string `json:"path"` + Blob string `json:"blob"` + ID int `json:"id"` + Path string `json:"path"` + Revision string `json:"revision"` } // ChangeAttachResult is the protocol-v1 document both attach operations return. @@ -270,11 +274,17 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string } // (7) Open the exact-revision, idempotency-keyed transaction. The promised - // state is (id, path, stored bytes), so a lost-response retry of the same - // body replays and a different body is a new request. + // state is (id, path, stored bytes, submitted revision), so a lost-response + // retry of the same body at the same revision replays, while a different + // body, or earlier bytes resubmitted at a later revision, is a new request. sum := sha256.Sum256(artifact) contentID := hex.EncodeToString(sum[:]) - digest, derr := canonicalDigest(opKey, attachDigestPayload{Blob: contentID, ID: req.ID, Path: req.Path}) + // The request id carries a hash of the submitted revision, never the raw + // string: the revision is only checked non-empty here (the CAS decides the + // rest), and the request id must stay within the engine's id charset. + revSum := sha256.Sum256([]byte(req.Revision)) + revisionID := hex.EncodeToString(revSum[:]) + digest, derr := canonicalDigest(opKey, attachDigestPayload{Blob: contentID, ID: req.ID, Path: req.Path, Revision: req.Revision}) if derr != nil { return attachRefusal(opKey, ResultInternalError, kind, ReasonStatusInternalError, derr.Error()) } @@ -299,7 +309,7 @@ func changeAttachMetadata(ctx context.Context, deps PlanningDeps, repoDir string Revision: transaction.ExpectedRevision{Kind: transaction.RevisionBlob, ObjectID: gitcli.ObjectID(req.Revision)}, }}, Idempotency: &transaction.IdempotencyKey{ - RequestID: fmt.Sprintf("attach-%s-%d-%s", kind, req.ID, contentID[:16]), + RequestID: fmt.Sprintf("attach-%s-%d-%s-%s", kind, req.ID, revisionID[:16], contentID[:16]), Digest: digest, }, Loader: newPlanningLoader(eff), diff --git a/internal/app/workflow_integration_test.go b/internal/app/workflow_integration_test.go index a96243a75..512417fa5 100644 --- a/internal/app/workflow_integration_test.go +++ b/internal/app/workflow_integration_test.go @@ -233,6 +233,54 @@ func TestIntegrationWorkflowRepoChangeAttachPlanMetadataHappyPath(t *testing.T) } } +// TestIntegrationWorkflowRepoChangeAttachRevertToEarlierContent proves the +// attach idempotency key folds in the submitted record revision: attaching X, +// then Y, then X again at the record's current revision writes X (the engine +// scans the whole metadata ancestry, so a content-only key would replay the +// first X receipt and leave Y stored), while a lost-response retry of the last +// attach at its original revision still replays without a new commit. +func TestIntegrationWorkflowRepoChangeAttachRevertToEarlierContent(t *testing.T) { + const resultsPath = "docs/results/2026-08-17-widget-results.md" + f := attachSetup(t) + bodyX := "# Widget — Results\n\n## Outcome\n\nFirst checkpoint X.\n" + bodyY := "# Widget — Results\n\n## Outcome\n\nSecond checkpoint Y.\n" + want := func(body string) string { + return attachBacklinkBlock(f.id, "A change", f.recPath, resultsPath) + "\n" + body + } + attach := func(rev, body string) ChangeAttachResult { + t.Helper() + res := ChangeAttachResults(f.ctx, f.deps, f.invocation, + ChangeAttachRequest{ID: f.id, Revision: rev, Path: resultsPath, Markdown: []byte(body)}) + if res.Result != ResultApplied { + t.Fatalf("attach = %q (reason %q msg %q findings %v)", res.Result, res.Reason, res.Message, res.Findings) + } + return res + } + current := func() string { return blobRevisionAt(t, f.repo.origin, "docket", f.recPath) } + + attach(f.revision, bodyX) + attach(current(), bodyY) + if got, _ := originFile(t, f.repo.origin, "docket", resultsPath); got != want(bodyY) { + t.Fatalf("after Y, stored results =\n%q\nwant\n%q", got, want(bodyY)) + } + revBeforeX := current() + third := attach(revBeforeX, bodyX) + if got, _ := originFile(t, f.repo.origin, "docket", resultsPath); got != want(bodyX) { + t.Fatalf("re-attaching earlier content X left stored results =\n%q\nwant\n%q", got, want(bodyX)) + } + + // A lost-response retry of the third attach (same revision, same bytes) + // replays its receipt: same commit, no new metadata commit. + tip := originTip(t, f.repo.origin, "docket") + retry := attach(revBeforeX, bodyX) + if retry.Revision != third.Revision { + t.Errorf("retry committed %s, want the replayed %s", retry.Revision, third.Revision) + } + if got := originTip(t, f.repo.origin, "docket"); got != tip { + t.Errorf("a lost-response retry moved the metadata branch %s -> %s", tip, got) + } +} + // TestIntegrationWorkflowRepoChangeAttachResultsCheckpointContent proves // change.attach-results runs checkpoint-phase content validation: a raw template // scaffold refuses with results-content-invalid and writes nothing, while a From 19b8cea77c65c3f9a159d4970547612248d91253 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 03:19:28 -0400 Subject: [PATCH 20/28] fix(implement-next): re-read the record revision before the post-dispatch claim refresh Review finding (important): Step 4 refreshed the claim with the pre-dispatch revision after the plan-writer's change.attach-plan had already moved the record, so the refresh came back contended and stopped the build. The parent now verifies the return, re-reads the revision on the re-synced tree, and refreshes with it; the post-build refresh in Step 5 does the same, since a build checkpoint (change.attach-results) also moves the record. --- internal/assets/embedded/manifest.json | 6 +++--- .../embedded/tree/skills/docket-implement-next/SKILL.md | 4 ++-- skills/docket-implement-next/SKILL.md | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 9e9fe5ce2..106a03d1d 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:29038749d4581e85a127b62c3c47afde32c5722288c9f54547349fd19ad00781", + "asset_set_id": "sha256:069ce2ba6aad7682aa8c7b120488a81a4c45e05b88af2fdb7fd2dbd62dad369c", "entries": [ { "path": ".docket.example.yml", @@ -378,8 +378,8 @@ "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56301, - "sha256": "ecbdb78ff2a8474d1594b4ada9f67ce1872aa3ea8842c78357b8f07feafcb0d2" + "size": 56526, + "sha256": "6cce54ba5140326a7a05d15f35659e3fd8e5b018b013a8591e69559448ce1349" }, { "path": "skills/docket-implement-next/references/edge-paths.md", 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 a7164f2d5..796ab7936 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -86,11 +86,11 @@ It also supplies the change id, title, exact record revision, synchronized chang **Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. +**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id <id>`) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build -**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns — 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. `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 diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index a7164f2d5..796ab7936 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -86,11 +86,11 @@ It also supplies the change id, title, exact record revision, synchronized chang **Verification (parent).** The returned path is a **claim, not proof**. Re-sync `.docket/` (the `repository.prepare` operation), then verify from git on the metadata branch that `plan:` equals the returned path, the file exists at the metadata tip under `docs/superpowers/plans/` with a backlink to this change, and that the feature worktree is clean with HEAD equal to the pre-dispatch HEAD. On a malformed return, a missing or mismatched `plan:`, a missing file or backlink, a dirty worktree, or a moved HEAD: **halt** (`## Run halted` + the `halted` disposition) — **never adopt**, repair, or commit the child's output, and never retry with the parent or a weaker model. The attach refuses an unsafe path, an unbalanced backlink, an unresolved placeholder, a `plan:` naming another path (`artifact-path-mismatch`), or a path holding another change's file (`artifact-path-occupied`), writing nothing; a lost-response retry with the same bytes replays its applied receipt. -**Continue.** After the dispatch returns, **refresh the claim again** (the `change.refresh-claim` operation) to stamp the plan→build seam, then a `PLAN_PATH` return MUST be verified, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. +**Continue.** After the dispatch returns, a `PLAN_PATH` return MUST be verified; the child's attach moved the record, so on the re-synced tree re-read its revision (the `context.implementation` operation with `--id <id>`) and **refresh the claim again** with it (the `change.refresh-claim` operation) to stamp the plan→build seam, then the parent MUST proceed into Step 5 — `PLAN_PATH` is a sub-step receipt, **never** a terminal disposition, and neither the child's return nor Step 4's postcondition may be reported as `advanced`. **Dispatch fallback (`halt`):** plan-writer dispatch unavailable — established only per the convention's *Dispatch-capability resolution*, never from a tool name — is **`halt`**: the change stays `in-progress` with `claimed_at` refreshed and the halt reason recorded. Build workers and reviewers read the plan from the metadata worktree (`.docket/<plan path>`, after a re-sync); the archive transaction retargets every metadata backlink in its own commit. ### Step 5 — Build -**Refresh the claim** (the `change.refresh-claim` operation) immediately before this long **build-role invocation** and again when it returns — 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. `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 From 756d75d7e1046425eef4c1a2f7b5bbd65a336d9a Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 03:21:20 -0400 Subject: [PATCH 21/28] fix(render): keep legacy killed records' plan/results rows on the feature branch Review finding (legacyIntegrationArtifacts): a pre-cutover killed record with plan:/results: set rendered relative metadata-branch links to files that only ever lived on its feature branch, so repository check/repair would rewrite them into dead links. legacyArtifactBranch now keeps a killed record without a "## Build evidence" section on its feature branch, the pre-cutover rendering; a killed record with the section stays relative. --- internal/render/artifacts.go | 46 ++++++++++++++++---------- internal/render/artifacts_test.go | 55 +++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 17 deletions(-) diff --git a/internal/render/artifacts.go b/internal/render/artifacts.go index dd3da1f47..336d7d52b 100644 --- a/internal/render/artifacts.go +++ b/internal/render/artifacts.go @@ -35,19 +35,19 @@ const ( // record, so their rows link relatively from the record file (RelativeLink). // docs/changes/active and docs/changes/archive are siblings, so the block is // byte-identical before and after archiving, and it renders the same with or -// without a web URL. Two rows are absolute on the integration branch, because -// their files are there: +// without a web URL. Some rows are absolute on another branch, because their +// files are there: // -// - Spec (merged): the spec copy that merged with the PR, for a done change -// that carries the "## Build evidence" section (built on the -// metadata-branch flow). -// - Plan and Results of a legacy record (see legacyIntegrationArtifacts), -// whose files merged to the integration branch before plan and results -// moved to the metadata branch. +// - Spec (merged): the spec copy that merged with the PR, on the integration +// branch, for a done change that carries the "## Build evidence" section +// (built on the metadata-branch flow). +// - Plan and Results of a legacy record (see legacyArtifactBranch), whose +// files stayed on the integration or feature branch because the change +// closed out before plan and results moved to the metadata branch. func ArtifactBlockContent(c domain.Change, snap domain.Snapshot, link LinkContext) (string, error) { var rows []string recPath := c.Path() - legacy := legacyIntegrationArtifacts(c) + legacyBranch, legacy := legacyArtifactBranch(c, link) if p := c.Spec().Value; p != "" { rows = append(rows, relativeRow("Spec", recPath, p)) @@ -62,7 +62,7 @@ func ArtifactBlockContent(c domain.Change, snap domain.Snapshot, link LinkContex switch { case a.path == "": case legacy: - rows = append(rows, absoluteRow(a.label, a.path, link.IntegrationBranch, link)) + rows = append(rows, absoluteRow(a.label, a.path, legacyBranch, link)) default: rows = append(rows, relativeRow(a.label, recPath, a.path)) } @@ -89,13 +89,25 @@ func ArtifactBlockContent(c domain.Change, snap domain.Snapshot, link LinkContex return b.String(), nil } -// legacyIntegrationArtifacts reports whether a change's plan and results live -// on the integration branch: a done change WITHOUT the "## Build evidence" -// section closed out before plan and results moved to the metadata branch, so -// its files merged with its PR and never move again. Every other change's plan -// and results are on the metadata branch. -func legacyIntegrationArtifacts(c domain.Change) bool { - return c.Status() == domain.StatusDone && !c.HasBuildEvidence() +// legacyArtifactBranch reports whether a change's plan and results live off +// the metadata branch, and on which branch. A closed change WITHOUT the +// "## Build evidence" section closed out before plan and results moved to the +// metadata branch, so its files never move again: a done change's merged with +// its PR to the integration branch; a killed change's stayed on its feature +// branch (an unset branch: yields "", which BlobURLOnBranch resolves to the +// metadata branch, the rendering such a record has always had). Every other +// change's plan and results are on the metadata branch. +func legacyArtifactBranch(c domain.Change, link LinkContext) (string, bool) { + if c.HasBuildEvidence() { + return "", false + } + switch c.Status() { + case domain.StatusDone: + return link.IntegrationBranch, true + case domain.StatusKilled: + return c.Branch().Value, true + } + return "", false } // relativeRow renders a same-branch Spec/Plan/Results row: the link text is the diff --git a/internal/render/artifacts_test.go b/internal/render/artifacts_test.go index 1b1789717..f11454398 100644 --- a/internal/render/artifacts_test.go +++ b/internal/render/artifacts_test.go @@ -358,6 +358,61 @@ func TestArtifactBlockLegacyDoneKeepsIntegrationRows(t *testing.T) { } } +// TestArtifactBlockLegacyKilledKeepsFeatureBranchRows pins the legacy killed +// rule: a killed change with no "## Build evidence" section predates the +// metadata-branch cutover, so its plan and results only ever lived on its +// feature branch, and their rows stay absolute there, exactly as rendered +// before the cutover (relative rows would be dead links). Its spec and ADR +// rows are relative like every other record's. A killed change WITH the +// section was built after the cutover: its plan and results rows are relative, +// and it gets no "Spec (merged)" row. +// +// Mutation probe: drop the killed arm of the legacy predicate -> the absolute +// Plan/Results asserts must redden. +func TestArtifactBlockLegacyKilledKeepsFeatureBranchRows(t *testing.T) { + c := fullChange(domain.StatusKilled, false, domain.LocationArchive, deltaArchivePath) + + got, err := render.ArtifactBlockContent(c, adrSnapshot(), githubLink) + if err != nil { + t.Fatalf("ArtifactBlockContent: %v", err) + } + want := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | [2026-08-16-delta-change.md](https://github.com/danielhanold/docket/blob/fix/delta-change/docs/superpowers/plans/2026-08-16-delta-change.md) |\n" + + "| Results | [2026-08-16-delta-change-results.md](https://github.com/danielhanold/docket/blob/fix/delta-change/docs/results/2026-08-16-delta-change-results.md) |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if got != want { + t.Fatalf("legacy killed block mismatch:\n--- got ---\n%s\n--- want ---\n%s", got, want) + } + + gotNoWeb, err := render.ArtifactBlockContent(c, adrSnapshot(), relativeLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(no web URL): %v", err) + } + wantNoWeb := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | `docs/superpowers/plans/2026-08-16-delta-change.md` |\n" + + "| Results | `docs/results/2026-08-16-delta-change-results.md` |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if gotNoWeb != wantNoWeb { + t.Fatalf("legacy killed block (no web URL) mismatch:\n--- got ---\n%s\n--- want ---\n%s", gotNoWeb, wantNoWeb) + } + + withEvidence := fullChange(domain.StatusKilled, true, domain.LocationArchive, deltaArchivePath) + gotEv, err := render.ArtifactBlockContent(withEvidence, adrSnapshot(), githubLink) + if err != nil { + t.Fatalf("ArtifactBlockContent(killed with evidence): %v", err) + } + wantEv := "| Artifact | Link |\n|---|---|\n" + + "| Spec | [2026-08-16-delta-change-design.md](../../superpowers/specs/2026-08-16-delta-change-design.md) |\n" + + "| Plan | [2026-08-16-delta-change.md](../../superpowers/plans/2026-08-16-delta-change.md) |\n" + + "| Results | [2026-08-16-delta-change-results.md](../../results/2026-08-16-delta-change-results.md) |\n" + + "| ADRs | [ADR-0001](../../adrs/0001-first-decision.md), [ADR-0002](../../adrs/0002-second-decision.md) |\n" + if gotEv != wantEv { + t.Fatalf("killed-with-evidence block mismatch:\n--- got ---\n%s\n--- want ---\n%s", gotEv, wantEv) + } +} + // TestArtifactBlockDoneWithEvidenceAddsSpecMerged: a done change that carries // the "## Build evidence" section was built after the cutover — its plan and // results stay on the metadata branch (relative rows), and its spec copy From 4349e2732ac5b8d6295c5dad57193afb9dad53c6 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 03:22:06 -0400 Subject: [PATCH 22/28] docs: address review findings F4-F5 F4: cli.md workspace summary names the commit-spec verb. F5: edge-paths/SKILL.md say pr.publish owns the PR-body back-link and plan/results links blocks; drop the stale skill-side claim. Regenerate embedded twins. --- docs/reference/cli.md | 4 ++-- internal/assets/embedded/manifest.json | 10 +++++----- .../tree/skills/docket-implement-next/SKILL.md | 2 +- .../docket-implement-next/references/edge-paths.md | 2 +- skills/docket-implement-next/SKILL.md | 2 +- skills/docket-implement-next/references/edge-paths.md | 2 +- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 13eef003b..679b37786 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -58,5 +58,5 @@ Each line is a pointer: the command, what its verbs govern, and where to read th - **`docket uninstall`** — remove recorded harness integrations while retaining the CLI and repository setup. Verbs: `docket uninstall --help`. - **`docket version`** — report the binary's build identity. Verbs: `docket version --help`. -- **`docket workspace`** — prepare, inspect, and publish feature workspaces for in-progress - changes. Verbs: `docket workspace --help`. +- **`docket workspace`** — prepare, commit the spec copy onto, inspect, and publish feature + workspaces for in-progress changes. Verbs: `docket workspace --help`. diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 106a03d1d..377129e6d 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:069ce2ba6aad7682aa8c7b120488a81a4c45e05b88af2fdb7fd2dbd62dad369c", + "asset_set_id": "sha256:b84bdfaa74114da2c57781eb1170323027708535fd824efb12d6c2960f5973ff", "entries": [ { "path": ".docket.example.yml", @@ -378,15 +378,15 @@ "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56526, - "sha256": "6cce54ba5140326a7a05d15f35659e3fd8e5b018b013a8591e69559448ce1349" + "size": 56486, + "sha256": "f07e47fad931a33b98293b9361de6387ab23d16ab281cf9d5c6a9e76ae45b0cf" }, { "path": "skills/docket-implement-next/references/edge-paths.md", "role": "skill", "mode": 420, - "size": 8312, - "sha256": "d189ca4a4c4de2548e3514ae5ee29e430e6119f9e4fe06621cafee0176b5ee86" + "size": 8269, + "sha256": "8788cab626aae2baa10e938abcb7e07ed88965a4caedb298622cd47bd3ec6af7" }, { "path": "skills/docket-implement-next/references/fix-pass.md", 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 796ab7936..4be41dfe1 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -137,7 +137,7 @@ 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`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the review disposition table; `pr.publish` adds 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. 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. 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 ea60c1da8..0f6719548 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 @@ -101,7 +101,7 @@ to force a workspace, a commit, or a HEAD move. **Best-effort PR→issue reference.** If the change carries an `issue:` value, add a plain `#<issue>` reference to the PR body — but **never `Closes #N`**, so merging the PR never auto-closes the referenced issue. Skip silently when `issue:` is unset — the reference is a one-time courtesy, not a build gate. -**PR-body back-link.** When docket authors the PR body, prepend a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic; skill-side, since the renderer's contract excludes the PR body). Best-effort — never block the PR on it. +**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. **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. diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index 796ab7936..4be41dfe1 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -137,7 +137,7 @@ 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`), the **back-link line** (`↩ Change <padded-id> — <title>`), and the review disposition table; `pr.publish` adds 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. 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. diff --git a/skills/docket-implement-next/references/edge-paths.md b/skills/docket-implement-next/references/edge-paths.md index ea60c1da8..0f6719548 100644 --- a/skills/docket-implement-next/references/edge-paths.md +++ b/skills/docket-implement-next/references/edge-paths.md @@ -101,7 +101,7 @@ to force a workspace, a commit, or a HEAD move. **Best-effort PR→issue reference.** If the change carries an `issue:` value, add a plain `#<issue>` reference to the PR body — but **never `Closes #N`**, so merging the PR never auto-closes the referenced issue. Skip silently when `issue:` is unset — the reference is a one-time courtesy, not a build gate. -**PR-body back-link.** When docket authors the PR body, prepend a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic; skill-side, since the renderer's contract excludes the PR body). Best-effort — never block the PR on it. +**PR-body back-link.** `pr.publish` renders and upserts a **back-link line** pointing home to the change on the `docket` branch — a first body line of the shape `↩ Change <padded-id> — <title>` linking to the change file on `docket` (built with the same blob-or-bare-path logic) — plus the plan/results links block; never author either in the body. **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. From 940d461b232c0e8390ad5e76d672821355286024 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 03:22:54 -0400 Subject: [PATCH 23/28] docs(results): change 0530 review and final consolidation --- ...evidence-on-the-metadata-branch-results.md | 60 ++++++++++++++++--- 1 file changed, 52 insertions(+), 8 deletions(-) diff --git a/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md index 502680a51..e294d02f5 100644 --- a/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md +++ b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md @@ -3,21 +3,65 @@ <!-- docket:backlink:end --> # Keep plan, results, and build evidence on the metadata branch, and ship the spec with the PR — Results -**Human action:** Pending — the build is complete and the whole-branch review has not run yet. Install ordering (below) will need a human regardless. +**Human action:** Yes. Before installing the binary built from this merge, let every other change that is in progress or implemented finish on the current binary, then run `docket repository repair` once to convert old absolute links. ## Outcome -Build artifacts move off the PR. After this change a change's plan and results files are written to the `docket` metadata branch at their existing paths by `change.attach-plan` / `change.attach-results` (which now carry the Markdown with `--markdown`), and build evidence lives in a `## Build evidence` section of the change record instead of the PR description. The feature branch's first commit is a copy of the spec (new `workspace.commit-spec` operation), so a PR carries the spec copy plus code and nothing else. Links between artifacts on the same branch are relative, and the post-merge backlink commit to `main`, the `Docket-Plan-Path` trailer, `artifact.backlink`, and the `finalize.skip_results_only_delta` key are retired. +Build artifacts no longer ride the PR. `change.attach-plan` and `change.attach-results` now take the Markdown (`--markdown`) and write the plan and results files to the `docket` metadata branch at their existing paths, in one metadata commit that also sets `plan:` / `results:` and stamps the backlink. Build evidence lives in a `## Build evidence` section of the change record instead of the PR description. The merge gate, clear-block, the rebase no-op skip, and `run.verify` all read it there. A new `workspace.commit-spec` operation commits a copy of the spec as the feature branch's first commit, so a PR contains the spec copy plus code and nothing else. + +Links between artifacts on the same branch are now relative, so archiving no longer breaks them. Old `done` and `killed` records keep their absolute plan and results rows, because those files live on `main` or on the old feature branch. Close-out no longer pushes a backlink commit to `main`, and a guard test fails if any operation pushes the integration branch outside a PR merge. Also retired: `artifact.backlink`, the `Docket-Plan-Path` trailer, and the `finalize.skip_results_only_delta` key, which now draws an "obsolete setting, remove it" warning. Departures from the spec: -- `pr.publish` writes no evidence anywhere. The spec listed it as a writer; `change.mark-implemented` already records the same verified evidence in a metadata transaction, and a second write at publish would move the record revision between the two steps. `pr.publish` still verifies `--evidence` against the head. -- `finalize.publish` with skipped (`build.gate: off`) evidence now records it in the change record. Previously it refused with `body-assembly-failed`. -- `workspace.commit-spec` builds the commit in a private index and fast-forwards the branch by compare-and-swap rather than staging in the worktree, so a failed commit leaves the workspace untouched. -- The PR description gains a generated block of absolute links to the plan and results on the metadata branch, right after the backlink. +- `pr.publish` writes no evidence anywhere. `change.mark-implemented` already records the same verified evidence in a metadata transaction, and a second write at publish time would change the record revision between the two steps. `pr.publish` still refuses to publish an uncertified head. +- `finalize.publish` with skipped evidence (`build.gate: off`) now records it in the change record. Before, it refused with `body-assembly-failed`. +- The PR description gains a generated block of absolute links to the plan and results on the metadata branch, placed right after the backlink. If an author had moved the backlink lower in the description, it moves back to the top. +- `workspace.commit-spec` builds its commit in a private index and moves the branch with a compare-and-swap. A failed commit leaves the workspace untouched, and the commit date comes from git rather than the injected clock. +- `finalize.cleanup` no longer declares a metadata-write effect; it only edits the PR description and deletes refs. + +## Human actions and testing + +### Important — Install only after in-flight changes finish + +Changes that are in progress or implemented when this merges still expect evidence in the PR description and plan/results on their feature branch. The new binary reads neither. If one is caught mid-flight, `evidence.recertify` writes the record section, and its plan and results can be re-attached with the new `--markdown` operations. + +1. Run `docket status` and list changes with status `in-progress` or `implemented`. + Expected: none, or only changes you are prepared to finish first. +2. Finish (or recertify) each one with the current binary, then install the new binary per AGENTS.md "Rebuild the binary after a merge to main". + Expected: `docket version` reports the merged commit. + +### Important — Convert legacy absolute links once + +1. After installing, run `docket repository check`. + Expected: artifact-link drift findings for records whose `## Artifacts` block still carries absolute same-branch links. +2. Run `docket repository repair` and approve. + Expected: one metadata commit; re-running `repository check` shows no artifact-link drift, and a second repair changes nothing. + +### Optional — Watch one full run end to end + +Run the next build-ready change through implement-next and finalize with the new binary. +Expected: the PR diff holds only the spec copy and code; the plan, the results file, and a `## Build evidence` section in the change record exist on `docket`; `main` gains no commit after the merge. + +## Verification performed + +- Full suite (`go run ./cmd/docket development test`) green at the build-gate head; the final head is certified again before the PR opens. The budget report showed only `PARALLEL-SENSITIVE` screening lines (finalize e2e, app-merge integration, race), no serial-confirmed breach. +- Each task added focused tests, and the new gates (record-evidence readers, the integration-push guard, the attach replay key, the legacy link rule) were mutation-tested: removing the guarded behavior turned the test red. +- Whole-branch review (deep tier): 5 findings (2 important, 3 minor), all fixed in-branch. ## Known issues and follow-ups -### This run used the previous flow +### Old absolute backlinks inside metadata spec files are not converted + +`repository repair` converts `## Artifacts` blocks, not the backlink block at the top of spec files already on the `docket` branch. Those keep working as links, but code that checks whether an artifact "points home" no longer recognizes the absolute form. A plan or results re-attach over such a file, or a mark-implemented on results whose backlink was never re-stamped, could refuse. Neither should happen after cutover, because attach, groom, kill, and close-out all stamp relative backlinks. Suspected, not observed. Workaround: re-groom or re-attach the artifact so its backlink is re-stamped. + +### Step 6.5 checkpoint prose may pass a stale revision + +The implement-next skill now re-reads the record revision after the plan and build dispatches, because those steps commit to the record. The results-checkpoint mechanics (item 4) still say `--revision <revision>` without saying to re-read it. An agent following the text literally could get `contended` and need to re-read. Suspected. Suggested next action: tighten that line in a later edit to the skill. + +### Minor test and message leftovers + +Some older `evidence.recertify` refusal tests still assert that no PR edit happened. Recertify can no longer edit a PR, so those assertions can never fail. A `--markdown` read error is reported as "reading --record". `gitcli.Client.CommitChangedPaths` no longer has a caller. None of these affects behavior. + +### This change was built on the previous flow -This change was built with the previously installed binary, so its own plan and results ride this feature branch and its evidence sits in the PR description, as the spec's cutover rule requires. Changes in progress or implemented when this merges must finish on the old binary before the new one is installed. +As the spec's cutover rule requires, this change's own plan and results ride its feature branch, and its evidence sits in the PR description. From 3b8c9e4a8be0f4688ab4ef12f7588d7adc009230 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 05:44:39 -0400 Subject: [PATCH 24/28] fix(cli): a raw-file read error names the flag the caller passed The shared non-JSON file reader always said "reading --record", so a bad --markdown, --evidence, or --request path was reported against a flag the caller never passed. Each caller now hands the reader its own flag name. --- internal/cli/agent.go | 2 +- internal/cli/change.go | 4 ++-- internal/cli/change_test.go | 15 +++++++++++++++ internal/cli/evidence.go | 17 +++++++++-------- internal/cli/finalize.go | 2 +- internal/cli/pr.go | 2 +- 6 files changed, 29 insertions(+), 13 deletions(-) diff --git a/internal/cli/agent.go b/internal/cli/agent.go index 695dc344c..0209f46da 100644 --- a/internal/cli/agent.go +++ b/internal/cli/agent.go @@ -42,7 +42,7 @@ func newAgentCommand(info buildinfo.Info, setResult func(app.OperationResult)) * if err != nil || !st.IsDir() { return fmt.Errorf("--cwd must name an existing directory") } - request, err := readRecordSource(c.InOrStdin(), requestSource) + request, err := readRecordSource(c.InOrStdin(), "request", requestSource) if err != nil { return err } diff --git a/internal/cli/change.go b/internal/cli/change.go index f74ed5c4c..970de52e2 100644 --- a/internal/cli/change.go +++ b/internal/cli/change.go @@ -312,7 +312,7 @@ func newMarkImplementedSubcommand(setResult func(app.OperationResult)) *cobra.Co prRef, _ := c.Flags().GetString("pr") evSource, _ := c.Flags().GetString("evidence") - record, err := readRecordSource(c.InOrStdin(), evSource) + record, err := readRecordSource(c.InOrStdin(), "evidence", evSource) if err != nil { return err } @@ -366,7 +366,7 @@ func changeAttachSubcommand(verb, short, markdownUsage string, run func(c *cobra revision, _ := c.Flags().GetString("revision") artifactPath, _ := c.Flags().GetString("path") src, _ := c.Flags().GetString("markdown") - markdown, err := readRecordSource(c.InOrStdin(), src) + markdown, err := readRecordSource(c.InOrStdin(), "markdown", src) if err != nil { return err } diff --git a/internal/cli/change_test.go b/internal/cli/change_test.go index 6df3c5f38..c0f8645d5 100644 --- a/internal/cli/change_test.go +++ b/internal/cli/change_test.go @@ -416,6 +416,21 @@ func TestChangeAttachFlagsRequired(t *testing.T) { } } +// TestChangeAttachUnreadableMarkdownNamesFlag proves an unreadable artifact body +// is reported against the flag the caller passed (--markdown), never another +// command's flag name from the shared raw-file reader. +func TestChangeAttachUnreadableMarkdownNamesFlag(t *testing.T) { + missing := filepath.Join(testsupport.TempDir(t), "absent.md") + for _, sub := range []string{"attach-plan", "attach-results"} { + _, errS, code := runCLI(t, "change", sub, "--id", "7", + "--revision", "1234123412341234123412341234123412341234", + "--path", "docs/x.md", "--markdown", missing) + if code == 0 || !strings.Contains(errS, "reading --markdown") || strings.Contains(errS, "--record") { + t.Fatalf("%s: err=%q code=%d, want a read error naming --markdown", sub, errS, code) + } + } +} + // TestChangeAttachCommandsReachOperation proves both attach commands decode their // flags and reach the operation, which returns exactly one protocol-v1 document // naming it. A bare tempdir is no docket repo, so the operation fails past its diff --git a/internal/cli/evidence.go b/internal/cli/evidence.go index e5a9edac4..08336ddbb 100644 --- a/internal/cli/evidence.go +++ b/internal/cli/evidence.go @@ -75,7 +75,7 @@ func newEvidenceCommand(setResult func(app.OperationResult)) *cobra.Command { RunE: func(c *cobra.Command, _ []string) error { source, _ := c.Flags().GetString("record") head, _ := c.Flags().GetString("head") - body, err := readRecordSource(c.InOrStdin(), source) + body, err := readRecordSource(c.InOrStdin(), "record", source) if err != nil { return err } @@ -119,21 +119,22 @@ func newEvidenceCommand(setResult func(app.OperationResult)) *cobra.Command { return evidenceCmd } -// readRecordSource reads the raw evidence-record bytes from source — "-" for -// stdin, any other value a filesystem path. Unlike the JSON request decoders, -// the record file is authored Markdown bytes the evidence codec reparses, so it -// is read verbatim with no decoding. -func readRecordSource(stdin io.Reader, source string) ([]byte, error) { +// readRecordSource reads the raw bytes of a non-JSON file input from source — +// "-" for stdin, any other value a filesystem path. Unlike the JSON request +// decoders, the file is authored bytes its operation reparses or stores, so it +// is read verbatim with no decoding. flag is the caller's own flag name, so a +// read error names the flag the caller actually passed. +func readRecordSource(stdin io.Reader, flag, source string) ([]byte, error) { if source == "-" { body, err := io.ReadAll(stdin) if err != nil { - return nil, fmt.Errorf("reading --record from stdin: %w", err) + return nil, fmt.Errorf("reading --%s from stdin: %w", flag, err) } return body, nil } body, err := os.ReadFile(source) if err != nil { - return nil, fmt.Errorf("reading --record %q: %w", source, err) + return nil, fmt.Errorf("reading --%s %q: %w", flag, source, err) } return body, nil } diff --git a/internal/cli/finalize.go b/internal/cli/finalize.go index c2611f2a8..40c0f55e5 100644 --- a/internal/cli/finalize.go +++ b/internal/cli/finalize.go @@ -529,7 +529,7 @@ func newFinalizePublishSubcommand(setResult func(app.OperationResult)) *cobra.Co attempt, _ := c.Flags().GetString("attempt") head, _ := c.Flags().GetString("head") evSource, _ := c.Flags().GetString("evidence") - ev, err := readRecordSource(c.InOrStdin(), evSource) + ev, err := readRecordSource(c.InOrStdin(), "evidence", evSource) if err != nil { return err } diff --git a/internal/cli/pr.go b/internal/cli/pr.go index c111257b5..9aba9a264 100644 --- a/internal/cli/pr.go +++ b/internal/cli/pr.go @@ -63,7 +63,7 @@ func newPRCommand(setResult func(app.OperationResult)) *cobra.Command { if err != nil { return err } - evidence, err := readRecordSource(c.InOrStdin(), evSource) + evidence, err := readRecordSource(c.InOrStdin(), "evidence", evSource) if err != nil { return err } From b0ba53f42b9b78a17c3d404714c6895a80cd38a5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 05:44:39 -0400 Subject: [PATCH 25/28] test(recertify): refusal tests assert the change record was not written Recertify can no longer edit a PR, so the refusal tests' no-PR-edit asserts could never fail. They now assert the record, recertify's only durable write, is unchanged. --- .../evidence_recertify_integration_test.go | 84 +++++++++---------- 1 file changed, 42 insertions(+), 42 deletions(-) diff --git a/internal/app/evidence_recertify_integration_test.go b/internal/app/evidence_recertify_integration_test.go index b70337f83..68c88f0b0 100644 --- a/internal/app/evidence_recertify_integration_test.go +++ b/internal/app/evidence_recertify_integration_test.go @@ -91,33 +91,40 @@ func recertifyFixture(t *testing.T, gate FinalizeGate) (*rebaseFixture, *fakePub return f, gh, deps, WorkspaceDeps{Service: f.svc} } +// requireRecordUnchanged fails when a refused recertify wrote the change record +// — the only durable write recertify makes, since it never edits the PR. +func requireRecordUnchanged(t *testing.T, f *rebaseFixture, before string) { + t.Helper() + if got := f.remoteRecordBytes(t); got != before { + t.Fatalf("a refused recertify wrote the change record:\n%s", got) + } +} + // TestIntegrationEvidenceEvidenceRecertifyRefusesNotImplemented: any non-implemented status is -// blocked before any probe of the gate or PR edit (acceptance 3). +// blocked before any probe of the gate or record write (acceptance 3). func TestIntegrationEvidenceEvidenceRecertifyRefusesNotImplemented(t *testing.T) { f := setupRebaseFixtureStatus(t, planRepoModes()[0], "in-progress") gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), f.finalizeDeps(gh, &fakeGate{}), WorkspaceDeps{Service: f.svc}, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultBlocked || res.Reason != ReasonRecertifyNotImplemented { t.Fatalf("result = %s/%s; want blocked/%s", res.Result, res.Reason, ReasonRecertifyNotImplemented) } - if gh.ensNext != 0 { - t.Fatalf("a refused recertify edited the PR") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyWorkspace: uncommitted work blocks (never -// gated over, never published) — acceptance 3. +// gated over, never recorded) — acceptance 3. func TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyWorkspace(t *testing.T) { - f, gh, deps, wdeps := recertifyFixture(t, &fakeGate{}) + f, _, deps, wdeps := recertifyFixture(t, &fakeGate{}) + before := f.remoteRecordBytes(t) writeRepoFile(t, f.wp, "dirty.txt", "uncommitted\n") res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultBlocked || res.Reason != ReasonRecertifyWorkspaceDirty { t.Fatalf("result = %s/%s; want blocked/%s", res.Result, res.Reason, ReasonRecertifyWorkspaceDirty) } - if gh.ensNext != 0 { - t.Fatalf("a refused recertify edited the PR") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRefusesUnpublishedFollowUp: a local follow-up commit @@ -125,7 +132,8 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyWorkspace(t *testing.T) // refuses (publish first through the existing workflow) rather than certify a // head the PR does not hold. func TestIntegrationEvidenceEvidenceRecertifyRefusesUnpublishedFollowUp(t *testing.T) { - f, gh, deps, wdeps := recertifyFixture(t, &fakeGate{}) + f, _, deps, wdeps := recertifyFixture(t, &fakeGate{}) + before := f.remoteRecordBytes(t) writeRepoFile(t, f.wp, "followup.txt", "review fix\n") runGit(t, f.wp, "add", "-A") runGit(t, f.wp, "commit", "-q", "-m", "review fix") @@ -133,9 +141,7 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesUnpublishedFollowUp(t *testi if res.Result == ResultApplied || res.Reason != ReasonRecertifyHeadDisagreement { t.Fatalf("result = %s/%s; want a %s refusal", res.Result, res.Reason, ReasonRecertifyHeadDisagreement) } - if gh.ensNext != 0 { - t.Fatalf("a refused recertify edited the PR") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRefusesClosedOrMismatchedPR: no open PR for the feature @@ -227,7 +233,7 @@ func TestIntegrationEvidenceEvidenceRecertifyAdvancesOneDriveAcrossWaiting(t *te } // TestIntegrationEvidenceEvidenceRecertifyGateFailureAndHalt: a red suite is gate-failed (repair -// work) and a halt is blocked — neither touches the PR (acceptance 3), and a +// work) and a halt is blocked — neither writes the record (acceptance 3), and a // WAITING with no continuation fails closed instead of spinning. func TestIntegrationEvidenceEvidenceRecertifyGateFailureAndHalt(t *testing.T) { cases := []struct { @@ -245,14 +251,13 @@ func TestIntegrationEvidenceEvidenceRecertifyGateFailureAndHalt(t *testing.T) { for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { gate := &fakeGate{result: tc.result, err: tc.err} - f, gh, deps, wdeps := recertifyFixture(t, gate) + f, _, deps, wdeps := recertifyFixture(t, gate) + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != tc.want || res.Reason != tc.reason { t.Fatalf("result = %s/%s; want %s/%s", res.Result, res.Reason, tc.want, tc.reason) } - if gh.ensNext != 0 { - t.Fatalf("a non-passed gate edited the PR") - } + requireRecordUnchanged(t, f, before) }) } } @@ -288,24 +293,26 @@ func TestIntegrationEvidenceEvidenceRecertifyGateOffRecordsSkipped(t *testing.T) } // TestIntegrationEvidenceEvidenceRecertifyRefusesUnconfiguredGate: a local build gate with no -// build.test_command refuses; no suite, no PR edit (acceptance 2). +// build.test_command refuses; no suite, no record write (acceptance 2). func TestIntegrationEvidenceEvidenceRecertifyRefusesUnconfiguredGate(t *testing.T) { gate := &fakeGate{} - f, gh, deps, wdeps := recertifyFixture(t, gate) + f, _, deps, wdeps := recertifyFixture(t, gate) // Config resolves from the pinned default-branch tip (origin/main), never the // invocation working tree, so the build-command-absent policy (gate defaults // local) must be published to origin/main. f.repo.writerAdvance(t, "main", map[string]string{ ".docket.yml": "integration_branch: main\nfinalize:\n test_command: 'go test ./...'\n", }) + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultUnsupportedConfig || res.Reason != ReasonEvidenceUnconfiguredGate { t.Fatalf("result = %s/%s; want unsupported-config/%s", res.Result, res.Reason, ReasonEvidenceUnconfiguredGate) } - if gate.calls != 0 || gh.ensNext != 0 { - t.Fatalf("an unconfigured gate ran the suite or edited the PR") + if gate.calls != 0 { + t.Fatalf("an unconfigured gate ran the suite") } + requireRecordUnchanged(t, f, before) } // movingGate commits to the feature worktree DURING the gate and then reports @@ -329,6 +336,7 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesHeadMovedUnderGate(t *testin f := setupRebaseFixture(t, planRepoModes()[0]) gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gate := &movingGate{f: f, ev: greenBlockFor(t, f.head)} + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), f.finalizeDeps(gh, gate), WorkspaceDeps{Service: f.svc}, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result == ResultApplied || res.Result == ResultNoOp { @@ -337,9 +345,7 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesHeadMovedUnderGate(t *testin if res.Reason != ReasonRecertifyHeadDisagreement && res.Reason != ReasonRecertifyCertifiedInputChanged { t.Fatalf("reason = %q; want a head-disagreement/certified-input-changed refusal", res.Reason) } - if gh.ensNext != 0 { - t.Fatalf("a moved head reached the PR edit") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRefusesForeignCommandEvidence: evidence recording a @@ -347,34 +353,32 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesHeadMovedUnderGate(t *testin // the changed-configuration face of acceptance 3. func TestIntegrationEvidenceEvidenceRecertifyRefusesForeignCommandEvidence(t *testing.T) { gate := &fakeGate{} - f, gh, deps, wdeps := recertifyFixture(t, gate) + f, _, deps, wdeps := recertifyFixture(t, gate) foreign, err := evidence.NewRecord("make other-suite", f.head, time.Date(2026, 9, 16, 0, 0, 0, 0, time.UTC)) if err != nil { t.Fatalf("evidence.NewRecord: %v", err) } gate.result = LocalGateResult{Outcome: FinalizeGatePassed, Evidence: evidence.Render(foreign), RunDir: "/run/x"} + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultBlocked || res.Reason != "certified-input-changed" { t.Fatalf("result = %s/%s; want blocked/certified-input-changed", res.Result, res.Reason) } - if gh.ensNext != 0 { - t.Fatalf("foreign-command evidence reached the PR edit") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRefusesWrongHeadEvidence: gate evidence naming another -// head is stale at verification and never published. +// head is stale at verification and never recorded. func TestIntegrationEvidenceEvidenceRecertifyRefusesWrongHeadEvidence(t *testing.T) { gate := &fakeGate{} - f, gh, deps, wdeps := recertifyFixture(t, gate) + f, _, deps, wdeps := recertifyFixture(t, gate) gate.result = LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenBlockFor(t, f.baseTip), RunDir: "/run/x"} + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), deps, wdeps, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultInvalidState || res.Reason != ReasonRecertifyEvidenceUnverified { t.Fatalf("result = %s/%s; want invalid-state/%s", res.Result, res.Reason, ReasonRecertifyEvidenceUnverified) } - if gh.ensNext != 0 { - t.Fatalf("unverified evidence reached the PR edit") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRecordContended: a PASSED gate whose @@ -384,7 +388,7 @@ func TestIntegrationEvidenceEvidenceRecertifyRefusesWrongHeadEvidence(t *testing // concurrent writer's record untouched. func TestIntegrationEvidenceEvidenceRecertifyRecordContended(t *testing.T) { gate := &fakeGate{} - f, gh, deps, wdeps := recertifyFixture(t, gate) + f, _, deps, wdeps := recertifyFixture(t, gate) gate.result = LocalGateResult{Outcome: FinalizeGatePassed, Evidence: greenBlockFor(t, f.head), RunDir: "/run/x"} var advanced string deps.Planning.Engine = &recordWriteInterposer{inner: f.deps.Engine, before: func() { @@ -404,9 +408,6 @@ func TestIntegrationEvidenceEvidenceRecertifyRecordContended(t *testing.T) { if got := f.remoteRecordBytes(t); got != advanced { t.Fatalf("the contended recertify touched the concurrent writer's record:\n%s", got) } - if gh.ensNext != 0 { - t.Fatalf("recertify edited the PR description") - } } // failingRecordWriteEngine fails the first N record-evidence transactions @@ -443,20 +444,19 @@ func (g *dirtyingGate) RunLocalGate(context.Context, LocalGateRequest) (LocalGat // TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyAfterGate: an untracked, non-ignored file the // build command leaves in the worktree during a PASSED gate flips the // pre-publish cleanliness recheck to workspace-dirty and refuses to publish; the -// PR is never edited. Pins the whole-predicate recheck's clean-worktree leg +// record is never written. Pins the whole-predicate recheck's clean-worktree leg // (documented as the clean-worktree caveat in the guide). func TestIntegrationEvidenceEvidenceRecertifyRefusesDirtyAfterGate(t *testing.T) { f := setupRebaseFixture(t, planRepoModes()[0]) gh := &fakePublishGitHub{repo: retargetRepo(), pr: f.prForHead(f.head, greenEvidenceFor(t, f.baseTip))} gate := &dirtyingGate{f: f, ev: greenBlockFor(t, f.head)} + before := f.remoteRecordBytes(t) res := EvidenceRecertify(context.Background(), f.finalizeDeps(gh, gate), WorkspaceDeps{Service: f.svc}, f.repo.invocation, EvidenceRecertifyRequest{ID: f.id}) if res.Result != ResultBlocked || res.Reason != ReasonRecertifyWorkspaceDirty { t.Fatalf("result = %s/%s; want blocked/%s", res.Result, res.Reason, ReasonRecertifyWorkspaceDirty) } - if gh.ensNext != 0 { - t.Fatalf("a dirty post-gate worktree reached the PR edit") - } + requireRecordUnchanged(t, f, before) } // TestIntegrationEvidenceEvidenceRecertifyRecordFailureThenRetry: a record write From 49d21dbad6cc8d45033d3d2edd2428571ceaead5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 05:44:39 -0400 Subject: [PATCH 26/28] chore(gitcli): delete the caller-less CommitChangedPaths --- internal/gitcli/commitdelta.go | 79 ------------------- .../gitcli/commitdelta_integration_test.go | 76 ------------------ 2 files changed, 155 deletions(-) delete mode 100644 internal/gitcli/commitdelta.go delete mode 100644 internal/gitcli/commitdelta_integration_test.go diff --git a/internal/gitcli/commitdelta.go b/internal/gitcli/commitdelta.go deleted file mode 100644 index 6b21575e3..000000000 --- a/internal/gitcli/commitdelta.go +++ /dev/null @@ -1,79 +0,0 @@ -package gitcli - -import ( - "bytes" - "context" - "errors" - "sort" -) - -// commitDeltaOp labels the commit-delta surface in any Failure. -const commitDeltaOp Operation = "commit-delta" - -// CommitChangedPaths returns the sorted, de-duplicated set of repo-relative -// paths a commit changes relative to its first parent, via -// `diff-tree --no-commit-id --name-only -r -z --no-renames --root <commit>`. -// Rename detection is OFF, so a move surfaces as a delete of the source and an -// add of the destination: a single-artifact predicate must see both sides -// (learning diff-derived-allowlist-needs-no-renames). A root commit (no parent) -// reports every path in its tree (--root). Output is read NUL-delimited so -// hostile path bytes (spaces, tabs, embedded newlines, non-ASCII) and -// core.quotePath=true never corrupt the parse. -func (c *Client) CommitChangedPaths(ctx context.Context, repo Repository, commit ObjectID) ([]RepoPath, error) { - if err := validateObjectID(commit); err != nil { - return nil, newFailure(commitDeltaOp, KindInvalidRequest, "invalid commit id", err) - } - res, f := c.run(ctx, runRequest{ - op: commitDeltaOp, - dir: repo.PrimaryWorktree, - args: []string{"diff-tree", "--no-commit-id", "--name-only", "-r", "-z", "--no-renames", "--root", string(commit)}, - }) - if f != nil { - return nil, f - } - if res.exitCode != 0 { - return nil, newFailure(commitDeltaOp, KindCommandFailed, "diff-tree failed: "+stderrExcerpt(res.stderr), nil).withExitCode(res.exitCode) - } - paths, err := parseNulPaths(res.stdout) - if err != nil { - return nil, newFailure(commitDeltaOp, KindInvalidOutput, "malformed diff-tree output", err) - } - return paths, nil -} - -// parseNulPaths parses the NUL-delimited path list `diff-tree --name-only -z` -// emits — each path is terminated by a NUL, so the final split element is empty -// (a non-empty tail is a truncated record). Empty output is no paths. Results -// are sorted by raw path bytes and de-duplicated. -func parseNulPaths(out []byte) ([]RepoPath, error) { - if len(out) == 0 { - return nil, nil - } - recs := bytes.Split(out, []byte{0}) - if len(recs[len(recs)-1]) != 0 { - return nil, errors.New("gitcli: diff-tree output has an unterminated trailing record") - } - recs = recs[:len(recs)-1] - paths := make([]RepoPath, 0, len(recs)) - for _, rec := range recs { - if len(rec) == 0 { - return nil, errors.New("gitcli: diff-tree emitted an empty path") - } - paths = append(paths, RepoPath(rec)) - } - sort.Slice(paths, func(i, j int) bool { - return bytes.Compare([]byte(paths[i]), []byte(paths[j])) < 0 - }) - out2 := paths[:0] - var last RepoPath - haveLast := false - for _, p := range paths { - if haveLast && p == last { - continue - } - out2 = append(out2, p) - last = p - haveLast = true - } - return out2, nil -} diff --git a/internal/gitcli/commitdelta_integration_test.go b/internal/gitcli/commitdelta_integration_test.go deleted file mode 100644 index cbfdc8ea3..000000000 --- a/internal/gitcli/commitdelta_integration_test.go +++ /dev/null @@ -1,76 +0,0 @@ -//go:build integration - -package gitcli - -import ( - "context" - "strings" - "testing" -) - -// TestCommitChangedPathsSingleAndMultiArtifact proves CommitChangedPaths reports -// exactly the paths a commit changes against its first parent, with rename -// detection OFF: a single-file commit yields one path, a two-file commit yields -// two, and a rename yields the source delete plus the destination add (never one -// collapsed rename record). It is the single-artifact-delta oracle the plan and -// results attachment guards depend on. -func TestIntegrationRepoCommitChangedPathsSingleAndMultiArtifact(t *testing.T) { - requireGit(t) - c := newRealClient(t) - ctx := context.Background() - r := newMainModeRepos(t) - repo := mustDiscover(t, c, r.Writer) - - assertDelta := func(t *testing.T, commit ObjectID, want []string) { - t.Helper() - got, err := c.CommitChangedPaths(ctx, repo, commit) - if err != nil { - t.Fatalf("CommitChangedPaths: %v", err) - } - var gotS []string - for _, p := range got { - gotS = append(gotS, string(p)) - } - if strings.Join(gotS, ",") != strings.Join(want, ",") { - t.Errorf("delta = %v, want %v", gotS, want) - } - } - - // A single new artifact under a plans-shaped directory: exactly one path. - single := r.writerCommit(t, "main", map[string]string{ - "docs/superpowers/plans/2026-08-17-a-plan.md": "# Plan\n", - }) - assertDelta(t, single, []string{"docs/superpowers/plans/2026-08-17-a-plan.md"}) - - // A two-file commit: both paths surface (the multi-artifact-delta guard). - multi := r.writerCommit(t, "main", map[string]string{ - "docs/superpowers/plans/2026-08-17-b-plan.md": "# Plan B\n", - "src/extra.go": "package src\n", - }) - assertDelta(t, multi, []string{"docs/superpowers/plans/2026-08-17-b-plan.md", "src/extra.go"}) - - // A rename with detection off surfaces as a delete + an add — two paths — so a - // single-artifact predicate reddens (learning diff-derived-allowlist-needs-no-renames). - gitOut(t, r.Writer, "mv", "docs/superpowers/plans/2026-08-17-a-plan.md", "docs/superpowers/plans/2026-08-17-renamed.md") - gitOut(t, r.Writer, "commit", "-q", "-m", "rename the plan") - renamed := ObjectID(gitOut(t, r.Writer, "rev-parse", "HEAD")) - assertDelta(t, renamed, []string{ - "docs/superpowers/plans/2026-08-17-a-plan.md", - "docs/superpowers/plans/2026-08-17-renamed.md", - }) -} - -// TestCommitChangedPathsRejectsMalformedID proves an invalid commit id is a typed -// invalid-request Failure, before any process runs. -func TestIntegrationRepoCommitChangedPathsRejectsMalformedID(t *testing.T) { - requireGit(t) - c := newRealClient(t) - r := newMainModeRepos(t) - repo := mustDiscover(t, c, r.Writer) - - _, err := c.CommitChangedPaths(context.Background(), repo, ObjectID("not-a-hash")) - f, ok := AsFailure(err) - if !ok || f.Kind != KindInvalidRequest { - t.Fatalf("err = %v, want an invalid-request Failure", err) - } -} From e49394f338fd2a5523e55e78b6602794c1d8d3c0 Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 05:44:39 -0400 Subject: [PATCH 27/28] docs(implement-next): the results checkpoint re-reads the record revision --- internal/assets/embedded/manifest.json | 6 +++--- .../embedded/tree/skills/docket-implement-next/SKILL.md | 2 +- skills/docket-implement-next/SKILL.md | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 377129e6d..186bfc8d6 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:b84bdfaa74114da2c57781eb1170323027708535fd824efb12d6c2960f5973ff", + "asset_set_id": "sha256:ad12306465402b8f7866e91bcc7985ea3be1f4adcfd4b340012ba175b04bc598", "entries": [ { "path": ".docket.example.yml", @@ -378,8 +378,8 @@ "path": "skills/docket-implement-next/SKILL.md", "role": "skill", "mode": 420, - "size": 56486, - "sha256": "f07e47fad931a33b98293b9361de6387ab23d16ab281cf9d5c6a9e76ae45b0cf" + "size": 56669, + "sha256": "a378c981ecc237c2c81ab55c4a2602ed28a6735dbac98caaac9067ceac1d5199" }, { "path": "skills/docket-implement-next/references/edge-paths.md", 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 4be41dfe1..697f5bd83 100644 --- a/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-implement-next/SKILL.md @@ -121,7 +121,7 @@ The results artifact is **required for every change, trivial included** — ther 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. 2. Read and **preserve** the prior results content — after a re-sync, from `.docket/<results path>` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). 3. Write the update into the scratch file. -4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>` writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. +4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>`, at a revision re-read after a re-sync (the `context.implementation` operation with `--id <id>`) because the claim refresh, plan attach, and every earlier checkpoint move the record, writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. **Evidence sequencing.** Results prose is **never gate evidence**, and a results checkpoint never moves HEAD, so it never stales evidence. Obtain gate evidence for the final code head through the gate driver; any code commit (a fix) still invalidates it. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. diff --git a/skills/docket-implement-next/SKILL.md b/skills/docket-implement-next/SKILL.md index 4be41dfe1..697f5bd83 100644 --- a/skills/docket-implement-next/SKILL.md +++ b/skills/docket-implement-next/SKILL.md @@ -121,7 +121,7 @@ The results artifact is **required for every change, trivial included** — ther 1. Verify ownership and quiescence — no live gate, running worker, or transferred drive. 2. Read and **preserve** the prior results content — after a re-sync, from `.docket/<results path>` when `results:` is set; a checkpoint updates it, never truncates it — except final consolidation, which condenses the review findings (*Review findings* below). 3. Write the update into the scratch file. -4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>` writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. +4. Attach it: the `change.attach-results` operation with `--id <id> --revision <revision> --path <results path> --markdown <file>`, at a revision re-read after a re-sync (the `context.implementation` operation with `--id <id>`) because the claim refresh, plan attach, and every earlier checkpoint move the record, writes the file with its backlink and sets `results:` in one metadata commit that never moves the feature head; later checkpoints keep the **same path**. **Evidence sequencing.** Results prose is **never gate evidence**, and a results checkpoint never moves HEAD, so it never stales evidence. Obtain gate evidence for the final code head through the gate driver; any code commit (a fix) still invalidates it. **Never** rewrite results solely to paste the final gate's SHA or timestamp. A new material finding surfaced by the final gate is preserved in results, the fix/halt policy applies, and evidence is re-established after any commit. From a17fd60a22e00ab2ff999fe1051d3f8cb1df460e Mon Sep 17 00:00:00 2001 From: Daniel Hanold <danny@danielhanold.com> Date: Tue, 6 Oct 2026 05:44:39 -0400 Subject: [PATCH 28/28] docs(results): change 0530 follow-up fixes and backlog match --- ...d-build-evidence-on-the-metadata-branch-results.md | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md index e294d02f5..caca70cd5 100644 --- a/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md +++ b/docs/results/2026-10-06-keep-plan-results-and-build-evidence-on-the-metadata-branch-results.md @@ -47,20 +47,13 @@ Expected: the PR diff holds only the spec copy and code; the plan, the results f - Full suite (`go run ./cmd/docket development test`) green at the build-gate head; the final head is certified again before the PR opens. The budget report showed only `PARALLEL-SENSITIVE` screening lines (finalize e2e, app-merge integration, race), no serial-confirmed breach. - Each task added focused tests, and the new gates (record-evidence readers, the integration-push guard, the attach replay key, the legacy link rule) were mutation-tested: removing the guarded behavior turned the test red. - Whole-branch review (deep tier): 5 findings (2 important, 3 minor), all fixed in-branch. +- Follow-up fixes after the PR opened, for leftovers this change caused: the results-checkpoint step now says to re-read the record revision; the `evidence.recertify` refusal tests now assert that the change record was not written, in place of PR-edit asserts that could no longer fail (mutation-tested: a refusal that writes the record turns them red); a raw-file read error now names the flag the caller passed (`--markdown`, `--evidence`, `--request`, `--record`), pinned by a new attach-verb test that was mutation-tested; and the caller-less `gitcli.Client.CommitChangedPaths` is deleted with its test. ## Known issues and follow-ups ### Old absolute backlinks inside metadata spec files are not converted -`repository repair` converts `## Artifacts` blocks, not the backlink block at the top of spec files already on the `docket` branch. Those keep working as links, but code that checks whether an artifact "points home" no longer recognizes the absolute form. A plan or results re-attach over such a file, or a mark-implemented on results whose backlink was never re-stamped, could refuse. Neither should happen after cutover, because attach, groom, kill, and close-out all stamp relative backlinks. Suspected, not observed. Workaround: re-groom or re-attach the artifact so its backlink is re-stamped. - -### Step 6.5 checkpoint prose may pass a stale revision - -The implement-next skill now re-reads the record revision after the plan and build dispatches, because those steps commit to the record. The results-checkpoint mechanics (item 4) still say `--revision <revision>` without saying to re-read it. An agent following the text literally could get `contended` and need to re-read. Suspected. Suggested next action: tighten that line in a later edit to the skill. - -### Minor test and message leftovers - -Some older `evidence.recertify` refusal tests still assert that no PR edit happened. Recertify can no longer edit a PR, so those assertions can never fail. A `--markdown` read error is reported as "reading --record". `gitcli.Client.CommitChangedPaths` no longer has a caller. None of these affects behavior. +`repository repair` converts `## Artifacts` blocks, not the backlink block at the top of spec files already on the `docket` branch. Those keep working as links, but code that checks whether an artifact "points home" no longer recognizes the absolute form. A plan or results re-attach over such a file, or a mark-implemented on results whose backlink was never re-stamped, could refuse. Neither should happen after cutover, because attach, groom, kill, and close-out all stamp relative backlinks. Suspected, not observed. Workaround: re-groom or re-attach the artifact so its backlink is re-stamped. Backlog match: Fits #533, whose switch between shared and private visibility assumes every metadata link is already relative. The finding is recorded in #533 for its build. ### This change was built on the previous flow