From 67c4054efb937696d1a6732737f7a38dfe2f35fb Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:32:22 -0400 Subject: [PATCH 01/10] docs(plan): change 0461 implementation plan Docket-Plan-Path: docs/superpowers/plans/2026-09-28-0461-allow-editing-an-existing-change-s-title.md --- ...llow-editing-an-existing-change-s-title.md | 985 ++++++++++++++++++ 1 file changed, 985 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-28-0461-allow-editing-an-existing-change-s-title.md diff --git a/docs/superpowers/plans/2026-09-28-0461-allow-editing-an-existing-change-s-title.md b/docs/superpowers/plans/2026-09-28-0461-allow-editing-an-existing-change-s-title.md new file mode 100644 index 000000000..c84942cfa --- /dev/null +++ b/docs/superpowers/plans/2026-09-28-0461-allow-editing-an-existing-change-s-title.md @@ -0,0 +1,985 @@ + +> ↩ **[Change 0461 — Allow editing an existing change's title](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0461-allow-editing-an-existing-change-s-title.md)** + +# Retitle an Existing Change Through change.groom 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. + +**Goal:** Let `change.groom` carry an optional `title` that retitles a `proposed` change in one metadata transaction (writer-quoted `title:`, board, `## Artifacts`, and the linked spec's `docket:backlink` line), renaming nothing. + +**Architecture:** No new operation, no new CLI verb. `ChangeGroomRequest` gains `Title`. One shared validator, `validateTitle` (new file `internal/app/change_title.go`), runs as request-shape validation in both `change.create` and `change.groom`, and keys on the writer's own rune predicate (`document.IllegalTextRune`, newly exported) so the request layer refuses exactly what the writer would. `changeGroomOp.Plan` upserts `title:` in its first patch pass; the candidate snapshot then carries the new title into the artifact block and the board with no new code. A new helper, `restampSpecBacklink`, rewrites only the linked spec's backlink block when the title changed and no whole-body spec write already covers it. Board title cells get `|`-escaping through a shared replacer. + +**Tech Stack:** Go (`internal/app`, `internal/document`, `internal/render`, `internal/repoguard`); table-driven Go tests over the in-memory fake tree; Markdown skill body plus its generated embedded mirror. + +**Spec:** `docs/superpowers/specs/2026-09-28-allow-editing-an-existing-change-s-title-design.md` (on the `docket` metadata branch; readable from the repo root at `.docket/docs/superpowers/specs/2026-09-28-allow-editing-an-existing-change-s-title-design.md`). + +## Global Constraints + +- A title edit renames nothing: slug, record filename, spec path, and `branch:` never change. No code path in this change may write `slug:` or compute a path from the new title. +- Every retitle stays on `proposed` changes. The existing groom gate and its exact complement, the revise gate, are unchanged. Do not add or relax any status check. +- `title` is accepted on the `spec`, `trivial`, `revise`, and `rearm` outcomes and refused on `abstain` with `invalid-title`. +- Empty or absent `title` means "leave the title unchanged". A non-empty `title` alone satisfies revise's effective-edit minimum. +- Title validation: the trimmed title is non-empty (`empty-title`), is a single line with no `\n` / `\r`, and has no control characters (`invalid-title`). It runs as request-shape validation, so a bad title never reaches the engine. +- `change.create` keeps its existing `empty-title` and valid-slug-token checks unchanged. It must never emit `empty-title` twice for one request (`TestRequiredTagMatchesValidator` in `internal/app/schema_tags_test.go` compares the empty-request finding keys to the `docket:"required"` tags). +- The spec backlink re-stamp reads the spec's current bytes from the attempt's own base tree. It takes no `spec_version`, replaces only the `docket:backlink` block, declares a `MutationReplace` only when bytes change, refuses a missing spec file with the existing `spec-file-missing`, and never inserts a missing block. +- Receipt shape and commit subject (`change NNNN groomed ()`) are unchanged. +- New Plan-closure refusal codes are plain strings passed to `refuseGroom`, following the house style of `"spec-file-missing"` / `"not-revisable"`. The new request-shape code `FCInvalidTitle` (`invalid-title`) goes in both the const block and the sorted `AllFindingCodes` list in `internal/app/finding_codes.go`. +- **Test corpus placement:** every test in this plan uses the in-memory fake tree (`groomPlanFor`, `newFakeTree`) or pure functions, so it belongs in the DEFAULT corpus files named below (`change_groom_test.go`, `change_create_test.go`, `change_title_test.go`, `finding_codes_test.go`, `schema_test.go`). None of these tests starts real git. Do not add any to a `*_integration_test.go` file, and do not use a `TestIntegration…` name. Change 0465's guard (`internal/app/nogit_guard_test.go`) forbids real git in the default corpus, and `tests/test_go_integration_contract.sh` rejects tagged tests without the `TestIntegration` prefix (see commit 7e0edd1f1). If an implementer finds a test genuinely needs real git, it goes behind `//go:build integration` in a `*_integration_test.go` file with a `TestIntegrationRecordOps…` name (the `change_groom_integration_test.go` precedent). +- `skills/` is the authored source. `internal/assets/embedded/tree/…` and `internal/assets/embedded/manifest.json` are generated by `go generate ./internal/assets/`. Never hand-edit them. +- Cross-references in maintained source anchor on symbol names or verbatim-quoted clauses, never line numbers (AGENTS.md, ADR-0054). +- Per-task test runs are focused checks and always pass `-count=1` (learning `cached-runner-serves-a-mutated-tree`). The build gate runs the whole suite through the resolved `build.test_command` (`go run ./cmd/docket development test`). +- Test code in this plan is unverified until it has run (learning `plan-supplied-test-code-is-unverified`). Prove each assert can pass, and mutation-test the guarded code by deleting it and watching the assert redden. + +## Review Focus + +The spec's Testing list is covered task by task. These five spec-implied inputs are not on that list, and each gets a pinned test in its owning task: + +1. **An unchanged title on a spec that has no backlink block.** A revise that resends the current title, for example a section edit that echoes `title`, must not refuse `spec-backlink-missing`. The re-stamp is keyed on the title changing, not on `title` being present. → Task 3, `TestChangeGroomPlanUnchangedTitleSkipsRestamp`. +2. **A whitespace-only title on `change.create`.** It must emit `empty-title` exactly once, not once from the existing loop and again from `validateTitle`. A duplicate breaks the schema-tag parity test and reads as two problems. → Task 1. +3. **Line and paragraph separators, NEL, and tab in a title.** U+2028, U+2029, U+0085, and `\t` must be refused as `invalid-title` at the shape layer. The writer refuses U+2028/U+2029/U+0085 at `Apply`, which would surface as an internal error, and a tab would pass the writer but split nothing useful into a board cell. → Task 1. +4. **A title on a revise that also carries `spec_markdown`.** The spec path must be declared once, as the whole-body replace, and carry the new title. The re-stamp must not add a second mutation of the same path. → Task 3, `TestChangeGroomPlanTitleWithSpecBodyRevise`. +5. **A title-only revise of a trivial-verdicted change (no spec).** Only the record (and the board when inline) is written. There is no spec probe and no `spec-file-missing` refusal. → Task 3, `TestChangeGroomPlanTitleOnlyReviseOfTrivialChange`. + +--- + +### Task 1: Shared title validator, `invalid-title` code, and `change.create` wiring + +**Files:** +- Modify: `internal/document/value.go` (export `IllegalTextRune` beside `illegalTextRune`) +- Create: `internal/app/change_title.go` +- Create: `internal/app/change_title_test.go` +- Modify: `internal/app/finding_codes.go` (const block + `AllFindingCodes`) +- Modify: `internal/app/finding_codes_test.go` (`TestShapeValidatorCodesAreRegistered`) +- Modify: `internal/app/change_create.go` (`validateChangeCreateShape`) +- Test: `internal/app/change_create_test.go` + +**Interfaces:** +- Consumes: `document.illegalTextRune` (unexported, `internal/document/value.go`), `FCEmptyTitle`, `validateChangeCreateShape`. +- Produces: `func IllegalTextRune(r rune) bool` in package `document`; `FCInvalidTitle FindingCode = "invalid-title"`; `func validateTitle(title string) (FindingCode, string)` in package `app`, which returns `("", "")` for a valid title. Tasks 2 and 3 rely on these exact names. + +- [ ] **Step 1: Write the failing tests** + +Create `internal/app/change_title_test.go`: + +```go +package app + +import "testing" + +// TestValidateTitle pins the one title-shape rule change.create and change.groom +// share (change 0461): non-empty after trimming, a single line, and no rune the +// frontmatter writer itself refuses — so the request layer never admits a title +// the writer would reject at Apply (learning validator-must-match-the-reader-it-feeds). +func TestValidateTitle(t *testing.T) { + cases := []struct { + name string + title string + code FindingCode + }{ + {"plain", "Allow editing a title", ""}, + {"yaml-hostile punctuation is fine", "Fix: the '#1' bug | now", ""}, + {"leading quote is fine", "'quoted' start", ""}, + {"empty", "", FCEmptyTitle}, + {"whitespace only", " \t ", FCEmptyTitle}, + {"newline", "first\nsecond", FCInvalidTitle}, + {"carriage return", "first\rsecond", FCInvalidTitle}, + {"tab", "a\tb", FCInvalidTitle}, + {"nul", "a\x00b", FCInvalidTitle}, + {"bell", "a\x07b", FCInvalidTitle}, + {"next line (C1)", "a\u0085b", FCInvalidTitle}, + {"line separator", "a\u2028b", FCInvalidTitle}, + {"paragraph separator", "a\u2029b", FCInvalidTitle}, + {"invalid utf-8", "a\xffb", FCInvalidTitle}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + code, msg := validateTitle(c.title) + if code != c.code { + t.Fatalf("validateTitle(%q) code = %q (%s), want %q", c.title, code, msg, c.code) + } + if code != "" && msg == "" { + t.Errorf("validateTitle(%q) refused with an empty message", c.title) + } + }) + } +} +``` + +In `internal/app/change_create_test.go`, add three rows to the `cases` table of `TestChangeCreateRejectsBadShapeWithoutEngineCall` (after the `"empty title"` row): + +```go + {"multi-line title", func(r *ChangeCreateRequest) { r.Title = "Add\na widget" }, "invalid-title"}, + {"control-character title", func(r *ChangeCreateRequest) { r.Title = "Add\x07a widget" }, "invalid-title"}, + {"line-separator title", func(r *ChangeCreateRequest) { r.Title = "Add\u2028a widget" }, "invalid-title"}, +``` + +Append this function to the same file: + +```go +// TestChangeCreateWhitespaceTitleReportsEmptyTitleOnce pins Review Focus 2: the +// shared validateTitle runs only on a non-blank title, so a blank one yields the +// existing empty-title finding exactly once, never a duplicate. +func TestChangeCreateWhitespaceTitleReportsEmptyTitleOnce(t *testing.T) { + req := validChangeCreateRequest() + req.Title = " " + n := 0 + for _, f := range validateChangeCreateShape(req) { + if f.Code == string(FCEmptyTitle) { + n++ + } + if f.Code == string(FCInvalidTitle) { + t.Errorf("blank title also reported %q: %v", FCInvalidTitle, f) + } + } + if n != 1 { + t.Errorf("empty-title reported %d times, want exactly 1", n) + } +} +``` + +In `internal/app/finding_codes_test.go`, `TestShapeValidatorCodesAreRegistered`, add this line after the existing `validateChangeCreateShape(ChangeCreateRequest{BranchPrefix: "a/b"})` line: + +```go + emitted = append(emitted, validateChangeCreateShape(ChangeCreateRequest{Title: "a\nb"})...) +``` + +Then add `FCInvalidTitle` to the `floor` slice (next to `FCEmptyTitle`). + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run 'TestValidateTitle|TestChangeCreateRejectsBadShapeWithoutEngineCall|TestChangeCreateWhitespaceTitleReportsEmptyTitleOnce|TestShapeValidatorCodesAreRegistered'` +Expected: a build failure (`undefined: validateTitle`, `undefined: FCInvalidTitle`). + +- [ ] **Step 3: Export the writer's rune predicate** + +In `internal/document/value.go`, add this immediately after `func illegalTextRune`: + +```go +// IllegalTextRune is the exported view of illegalTextRune: it reports whether r +// may never appear in a field string the writer serializes, apart from the tab +// exemption the writer grants itself. Request validators consult it so they +// refuse exactly the set the writer refuses, never a hand-enumerated copy that +// drifts from it (learning validator-must-match-the-reader-it-feeds). +func IllegalTextRune(r rune) bool { return illegalTextRune(r) } +``` + +- [ ] **Step 4: Register the finding code** + +In `internal/app/finding_codes.go`, add this to the request-shape const block, directly after `FCEmptyTitle`: + +```go + FCInvalidTitle FindingCode = "invalid-title" +``` + +In `AllFindingCodes`, insert `FCInvalidTitle,` between `FCInvalidTargetID,` and `FCInvalidTopics,` to keep the list sorted (`invalid-target-id` < `invalid-title` < `invalid-topics`). + +- [ ] **Step 5: Write the validator** + +Create `internal/app/change_title.go`: + +```go +package app + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/danielhanold/docket/internal/document" +) + +// validateTitle is the one title-shape rule change.create and change.groom +// share (change 0461). A title must be non-empty after trimming (empty-title), +// valid UTF-8, a single line, and free of every rune the frontmatter writer +// refuses — control characters (tab included, though the writer tolerates it: +// a title is one board cell and one backlink line), U+2028/U+2029, and +// U+FFFE/U+FFFF (invalid-title). The rune set is document.IllegalTextRune, the +// writer's own predicate, so a title this admits always serializes. It returns +// ("", "") for a valid title. +func validateTitle(title string) (FindingCode, string) { + if strings.TrimSpace(title) == "" { + return FCEmptyTitle, "title must be non-empty" + } + if !utf8.ValidString(title) { + return FCInvalidTitle, "title must be valid UTF-8" + } + if strings.ContainsAny(title, "\r\n") { + return FCInvalidTitle, "title must be a single line (no line breaks)" + } + for _, r := range title { + if document.IllegalTextRune(r) { + return FCInvalidTitle, fmt.Sprintf("title must not contain control or line-separator characters (found %U)", r) + } + } + return "", "" +} +``` + +- [ ] **Step 6: Wire it into `change.create`** + +In `internal/app/change_create.go`, `validateChangeCreateShape`, add this directly after the `for _, f := range []struct{…}{{"title", req.Title, FCEmptyTitle}, …}` loop. The existing loop keeps owning the blank-title case: + +```go + // The shared title rule (change 0461). A blank title is already reported as + // empty-title by the loop above, so validateTitle runs only on a non-blank + // one and never duplicates that finding. + if strings.TrimSpace(req.Title) != "" { + if code, msg := validateTitle(req.Title); code != "" { + addShape(code, msg) + } + } +``` + +- [ ] **Step 7: Run the tests to verify they pass** + +Run: `go test -count=1 ./internal/app/ -run 'TestValidateTitle|TestChangeCreate|TestShapeValidatorCodesAreRegistered|TestFindingCode|TestRequiredTagMatchesValidator|TestReflectDescriptor' && go test -count=1 ./internal/document/` +Expected: PASS. If the finding-code AST completeness guard or the registry-sort test fails, fix the placement of `FCInvalidTitle`. Do not relax the guard. + +- [ ] **Step 8: Mutation-check** + +Delete the Step 6 block, re-run `go test -count=1 ./internal/app/ -run TestChangeCreateRejectsBadShapeWithoutEngineCall`, and confirm the three new rows go red. Restore the block from a saved copy, not from `git checkout --` (learning `mutation-restore-needs-a-backup-copy`), and re-run to green. + +- [ ] **Step 9: Commit** + +```bash +git add internal/document/value.go internal/app/change_title.go internal/app/change_title_test.go internal/app/finding_codes.go internal/app/finding_codes_test.go internal/app/change_create.go internal/app/change_create_test.go +git commit -m "feat(app): shared title validator; change.create refuses multi-line/control titles (change 0461)" +``` + +--- + +### Task 2: `title` on the `change.groom` request and its shape validation + +**Files:** +- Modify: `internal/app/change_groom.go` (`ChangeGroomRequest`, `validateChangeGroomShape`) +- Modify: `internal/app/finding_codes_test.go` (`TestShapeValidatorCodesAreRegistered`) +- Modify: `internal/app/schema_test.go` (`TestReflectDescriptorChangeGroomRequest`) +- Test: `internal/app/change_groom_test.go` + +**Interfaces:** +- Consumes: `validateTitle`, `FCInvalidTitle`, `FCEmptyTitle` (Task 1); the existing `validReviseRequest`, `validGroomSpecRequest`, `rearmRequest`, `abstainRequest`, and `hasFindingCode` test helpers. +- Produces: `ChangeGroomRequest.Title string` (`json:"title,omitempty"`) and the test helpers `titleOnlyReviseRequest(title string) ChangeGroomRequest` and `titledTrivialRequest(title string) ChangeGroomRequest`, which Task 3 reuses. + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/app/change_groom_test.go`: + +```go +// titleOnlyReviseRequest is a revise carrying nothing but a title — no spec +// body, no spec_version, no section edits (change 0461). +func titleOnlyReviseRequest(title string) ChangeGroomRequest { + r := validReviseRequest() + r.SpecMarkdown, r.SpecVersion, r.Sections = "", "", nil + r.Title = title + return r +} + +// titledTrivialRequest is a well-formed trivial groom of the groomable fixture +// at id 2 that also retitles it. +func titledTrivialRequest(title string) ChangeGroomRequest { + return ChangeGroomRequest{ + ChangeID: 2, + Path: groomPath(2, "add-a-widget"), + Version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Outcome: GroomTrivial, + Title: title, + Sections: []SectionEditRequest{ + {Heading: "## Why", Intent: "replace", Markdown: "Trivial: a rename only.\n"}, + }, + } +} + +func TestChangeGroomTitleShapeValidation(t *testing.T) { + withTitle := func(r ChangeGroomRequest, title string) ChangeGroomRequest { + r.Title = title + return r + } + cases := []struct { + name string + req ChangeGroomRequest + code string // "" means the request must pass shape validation + }{ + {"spec accepts title", withTitle(validGroomSpecRequest(), "Renamed widget"), ""}, + {"trivial accepts title", titledTrivialRequest("Renamed widget"), ""}, + {"rearm accepts title", withTitle(rearmRequest(), "Renamed widget"), ""}, + {"revise accepts a title alone", titleOnlyReviseRequest("Renamed widget"), ""}, + {"revise accepts title with sections", withTitle(validReviseRequest(), "Renamed widget"), ""}, + {"abstain refuses title", withTitle(abstainRequest(), "Renamed widget"), "invalid-title"}, + {"whitespace-only title", titleOnlyReviseRequest(" "), "empty-title"}, + {"multi-line title", titleOnlyReviseRequest("Renamed\nwidget"), "invalid-title"}, + {"control-character title", titleOnlyReviseRequest("Renamed\x00widget"), "invalid-title"}, + {"multi-line title on spec outcome", withTitle(validGroomSpecRequest(), "a\r\nb"), "invalid-title"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + findings := validateChangeGroomShape(c.req) + if c.code == "" { + if len(findings) != 0 { + t.Fatalf("unexpected shape findings: %v", findings) + } + return + } + if !hasFindingCode(findings, c.code) { + t.Errorf("missing finding %q; got %v", c.code, findings) + } + }) + } +} + +// TestChangeGroomEmptyReviseNamesTitle pins the widened empty-revise rule: with +// no spec_markdown, no effective section edit, and no title the revise is still +// refused, and the diagnostic names all three inputs. +func TestChangeGroomEmptyReviseNamesTitle(t *testing.T) { + findings := validateChangeGroomShape(titleOnlyReviseRequest("")) + var msg string + for _, f := range findings { + if f.Code == string(FCEmptyRevise) { + msg = f.Message + } + } + if msg == "" { + t.Fatalf("empty revise not refused; findings %v", findings) + } + for _, want := range []string{"spec_markdown", "section edit", "title"} { + if !strings.Contains(msg, want) { + t.Errorf("empty-revise message %q does not name %q", msg, want) + } + } +} + +func TestChangeGroomBadTitleRefusedWithoutEngineCall(t *testing.T) { + engine := &recordingEngine{} + reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} + deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} + + res := ChangeGroom(context.Background(), deps, "", titleOnlyReviseRequest("two\nlines")) + + 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)) + } + if !hasFindingCode(res.Findings, "invalid-title") { + t.Errorf("missing invalid-title; got %v", res.Findings) + } +} +``` + +In `internal/app/finding_codes_test.go`, `TestShapeValidatorCodesAreRegistered`, add after the other `validateChangeGroomShape` lines: + +```go + emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomAbstain, Title: "x"})...) + emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomRevise, Title: "a\nb"})...) +``` + +In `internal/app/schema_test.go`, `TestReflectDescriptorChangeGroomRequest`, add after the `blocked_note` assertion: + +```go + if f := fieldByKey(t, d, "title"); f.Required || f.Type != "string" { + t.Errorf("title = %+v, want optional string", f) + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run 'TestChangeGroomTitleShapeValidation|TestChangeGroomEmptyReviseNamesTitle|TestChangeGroomBadTitleRefusedWithoutEngineCall|TestReflectDescriptorChangeGroomRequest'` +Expected: a build failure (`unknown field Title in struct literal of type ChangeGroomRequest`). + +- [ ] **Step 3: Add the request field** + +In `internal/app/change_groom.go`, `ChangeGroomRequest`, add this directly after the `BlockedNote` field: + +```go + // Title, when non-empty, retitles the change (change 0461). The spec, + // trivial, revise, and rearm outcomes accept it; abstain refuses it, since an + // abstain cannot rewrite the proposal. Empty leaves the title unchanged. A + // retitle renames nothing: the slug, record path, spec path, and branch stay put. + Title string `json:"title,omitempty"` +``` + +- [ ] **Step 4: Validate it** + +In `validateChangeGroomShape`: + +1. In the `case GroomRevise:` branch, replace + +```go + } else if !hasEffectiveSectionEdit(req.Sections) { + addShape(FCEmptyRevise, "the revise outcome requires a non-empty spec_markdown or at least one replace/remove section edit") + } +``` + +with + +```go + } else if !hasEffectiveSectionEdit(req.Sections) && req.Title == "" { + addShape(FCEmptyRevise, "the revise outcome requires a non-empty spec_markdown, at least one replace/remove section edit, or a title") + } +``` + +2. In the `case GroomAbstain:` branch, after the `if len(req.Sections) > 0 { … }` block, add: + +```go + if req.Title != "" { + addShape(FCInvalidTitle, "title is not accepted by the abstain outcome; an abstain cannot rewrite the proposal") + } +``` + +3. After the `switch req.Outcome { … }` block and before the `blocked_note` check, add: + +```go + // A title retitles the change on every other outcome (change 0461); an empty + // one means "unchanged". Any non-empty title must pass the shared rule, so a + // whitespace-only one is empty-title, never a silent no-op. + if req.Title != "" && req.Outcome != GroomAbstain { + if code, msg := validateTitle(req.Title); code != "" { + addShape(code, msg) + } + } +``` + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `go test -count=1 ./internal/app/ -run 'TestChangeGroom|TestShapeValidatorCodesAreRegistered|TestReflectDescriptor|TestRequiredTagMatchesValidator'` +Expected: PASS, including every pre-existing `TestChangeGroom*` test. `TestChangeGroomReviseShapeValidation`'s existing `"empty revise refused"` rows must still refuse. + +Note: at this point `Plan` ignores `Title`. That is expected and buildable. Task 3 makes it effective. + +- [ ] **Step 6: Mutation-check** + +Change `&& req.Title == ""` back to nothing and confirm `"revise accepts a title alone"` goes red (it gets `empty-revise`). Delete the abstain `title` refusal and confirm `"abstain refuses title"` goes red. Restore from a saved copy. + +- [ ] **Step 7: Commit** + +```bash +git add internal/app/change_groom.go internal/app/change_groom_test.go internal/app/finding_codes_test.go internal/app/schema_test.go +git commit -m "feat(groom): change.groom accepts an optional title; a title alone is a valid revise (change 0461)" +``` + +--- + +### Task 3: Plan writes the title and re-stamps the linked spec's backlink + +**Files:** +- Modify: `internal/app/change_groom.go` (`changeGroomOp.Plan`, new helper `restampSpecBacklink`) +- Test: `internal/app/change_groom_test.go` + +**Interfaces:** +- Consumes: `ChangeGroomRequest.Title`, `titleOnlyReviseRequest`, `titledTrivialRequest` (Task 2); the existing `upsertField` (`internal/app/adr_ops.go`), `treeBlob`, `backlinkBlockName` (`internal/app/artifact_backlink.go`), `backlinkInterior` (`internal/app/change_kill.go`), `render.BacklinkContent`, `refuseGroom`; the test helpers `groomPlanFor`, `baseGroomOp`, `groomedRecordBytes`, `planPaths`, `assertPlanPaths`, `assertGroomReceiptSpecPath`, `reviseFixtureFiles`, `reviseSettledFiles`, `revisableChange`, `trivialChange`, `groomableChange`, `validReviseRequest`, `validGroomSpecRequest`, and the constant `reviseSpecPath`. +- Produces: `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)` and the two Plan refusal codes `spec-backlink-missing` and `spec-backlink-malformed` (Task 5's skill prose names them). + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/app/change_groom_test.go`: + +```go +// reviseSpecBody is the fixture spec's bytes after its backlink block — the part +// a title re-stamp must leave byte-identical. +const reviseSpecBody = "\n\n# Design\n\nThe original design body.\n" + +func TestChangeGroomPlanTitleOnlyReviseRestampsSpecBacklink(t *testing.T) { + files := reviseFixtureFiles() + files["docs/changes/BOARD.md"] = "# Backlog\n\nold\n" + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, titleOnlyReviseRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // One commit: the record, the spec (backlink re-stamp only), and the board. + // The record path itself is unchanged — a retitle renames nothing. + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + reviseSpecPath: transaction.MutationReplace, + "docs/changes/BOARD.md": transaction.MutationReplace, + }) + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + for _, want := range []string{"title: 'Renamed widget'", "updated: '2026-08-16'", "slug: add-a-widget", "spec: '" + reviseSpecPath + "'"} { + if !strings.Contains(rec, want) { + t.Errorf("record missing %q:\n%s", want, rec) + } + } + spec := string(groomedRecordBytes(t, plan, reviseSpecPath)) + if !strings.Contains(spec, "Change 0002 — Renamed widget") { + t.Errorf("spec backlink not re-stamped with the new title:\n%s", spec) + } + if strings.Contains(spec, "old backlink") { + t.Errorf("old backlink line survived:\n%s", spec) + } + if !strings.HasSuffix(spec, reviseSpecBody) { + t.Errorf("spec bytes outside the backlink block changed:\n%s", spec) + } + board := string(groomedRecordBytes(t, plan, "docs/changes/BOARD.md")) + if !strings.Contains(board, "| Renamed widget |") || strings.Contains(board, "A change") { + t.Errorf("board row not retitled:\n%s", board) + } + // A title-only revise replaced no spec body, so the receipt names none. + assertGroomReceiptSpecPath(t, plan, "") +} + +// TestChangeGroomPlanTitleRoundTripsThroughWriter pins ADR-0071 for the new +// field: YAML-hostile punctuation lands writer-quoted and reads back as the +// exact string (the re-stamped backlink is rendered from the reparsed record). +func TestChangeGroomPlanTitleRoundTripsThroughWriter(t *testing.T) { + title := "Fix: the '#1' bug" + plan, opRes := groomPlanFor(t, reviseFixtureFiles(), baseGroomOp([]string{}, titleOnlyReviseRequest(title))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + if !strings.Contains(rec, "title: 'Fix: the ''#1'' bug'\n") { + t.Errorf("title not writer-quoted:\n%s", rec) + } + if spec := string(groomedRecordBytes(t, plan, reviseSpecPath)); !strings.Contains(spec, "Change 0002 — "+title) { + t.Errorf("title did not round-trip into the backlink:\n%s", spec) + } +} + +func TestChangeGroomPlanTitleOnSpecOutcome(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): groomableChange(2, "add-a-widget")} + req := validGroomSpecRequest() + req.Title = "Renamed widget" + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // The new spec path is still minted from the unchanged slug. + newSpec := "docs/superpowers/specs/2026-08-16-add-a-widget-design.md" + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + newSpec: transaction.MutationCreate, + }) + if spec := string(groomedRecordBytes(t, plan, newSpec)); !strings.Contains(spec, "Change 0002 — Renamed widget") { + t.Errorf("new spec's backlink lacks the new title:\n%s", spec) + } + if rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))); !strings.Contains(rec, "title: 'Renamed widget'") { + t.Errorf("record not retitled:\n%s", rec) + } +} + +func TestChangeGroomPlanTitleOnTrivialOutcome(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): groomableChange(2, "add-a-widget")} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, titledTrivialRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // No spec file is touched. + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + if !strings.Contains(rec, "title: 'Renamed widget'") || !strings.Contains(rec, "trivial: true") { + t.Errorf("record not retitled/trivialled:\n%s", rec) + } +} + +// TestChangeGroomPlanTitleWithSpecBodyRevise pins Review Focus 4: the +// whole-body replace already renders the backlink from the groomed record, so +// the spec path is declared exactly once, carrying both the new body and title. +func TestChangeGroomPlanTitleWithSpecBodyRevise(t *testing.T) { + req := validReviseRequest() + req.Title = "Renamed widget" + plan, opRes := groomPlanFor(t, reviseFixtureFiles(), baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + n := 0 + for _, p := range planPaths(plan) { + if p == reviseSpecPath { + n++ + } + } + if n != 1 { + t.Fatalf("spec path declared %d times, want 1: %v", n, planPaths(plan)) + } + spec := string(groomedRecordBytes(t, plan, reviseSpecPath)) + if !strings.Contains(spec, "Change 0002 — Renamed widget") || !strings.Contains(spec, "The revised design body.") { + t.Errorf("spec lacks the new title or the new body:\n%s", spec) + } +} + +// TestChangeGroomPlanTitleOnlyReviseOfTrivialChange pins Review Focus 5: a +// trivial-verdicted change links no spec, so a retitle writes only the record. +func TestChangeGroomPlanTitleOnlyReviseOfTrivialChange(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): trivialChange(2, "add-a-widget")} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, titleOnlyReviseRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) +} + +func TestChangeGroomPlanTitleRestampRefusals(t *testing.T) { + rec := revisableChange(2, "add-a-widget", reviseSpecPath) + cases := []struct { + name string + files map[string]string + code string + }{ + // The re-stamp never silently inserts a block. + {"spec lacks a backlink block", map[string]string{ + groomPath(2, "add-a-widget"): rec, + reviseSpecPath: "# Design\n\nNo backlink here.\n", + }, "spec-backlink-missing"}, + // A dangling start marker fails the document parse. + {"spec backlink markers malformed", map[string]string{ + groomPath(2, "add-a-widget"): rec, + reviseSpecPath: "\n> dangling\n\n# Design\n", + }, "spec-backlink-malformed"}, + // A dangling spec link reuses the existing refusal. + {"spec file missing", map[string]string{ + groomPath(2, "add-a-widget"): rec, + }, "spec-file-missing"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plan, opRes := groomPlanFor(t, c.files, baseGroomOp([]string{}, titleOnlyReviseRequest("Renamed widget"))) + if !opRes.Refused { + t.Fatalf("expected a refusal, got plan files %v", planPaths(plan)) + } + found := false + for _, f := range opRes.Findings { + if f.Code == c.code { + found = true + } + } + if !found { + t.Errorf("missing refusal code %q; got %v", c.code, opRes.Findings) + } + if len(plan.Files) != 0 { + t.Errorf("refused plan still carries files: %v", planPaths(plan)) + } + }) + } +} + +// TestChangeGroomPlanUnchangedTitleSkipsRestamp pins Review Focus 1: the +// re-stamp is keyed on the title CHANGING, so resending the current title with +// a section edit never probes the spec and never refuses a block-less spec. +func TestChangeGroomPlanUnchangedTitleSkipsRestamp(t *testing.T) { + files := reviseFixtureFiles() + files[reviseSpecPath] = "# Design\n\nNo backlink here.\n" + req := titleOnlyReviseRequest("A change") // the fixture's current title + req.Sections = []SectionEditRequest{{Heading: "## What changes", Intent: "replace", Markdown: "Narrowed what.\n"}} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unchanged title refused: %v", opRes.Findings) + } + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) +} + +// TestChangeGroomPlanSameTitleIsNoOp pins that a title-only revise resending the +// current title over a settled tree declares nothing — the engine's clean no-op. +func TestChangeGroomPlanSameTitleIsNoOp(t *testing.T) { + files := reviseSettledFiles(t) + files["docs/changes/BOARD.md"] = "# Backlog\n\nold\n" + boardPlan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, validReviseRequest())) + if opRes.Refused { + t.Fatalf("board-settling revise refused: %v", opRes.Findings) + } + files["docs/changes/BOARD.md"] = string(groomedRecordBytes(t, boardPlan, "docs/changes/BOARD.md")) + + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, titleOnlyReviseRequest("A change"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + if len(plan.Files) != 0 { + t.Errorf("same-title revise declared files %v, want an empty (no-op) plan", planPaths(plan)) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `go test -count=1 ./internal/app/ -run 'TestChangeGroomPlan(Title|UnchangedTitle|SameTitle)'` +Expected: FAIL. The record keeps `title: 'A change'`, the spec path is absent from the plan, and no refusal fires. `TestChangeGroomPlanUnchangedTitleSkipsRestamp` and `TestChangeGroomPlanSameTitleIsNoOp` may already pass; that is fine, since they pin the no-restamp paths. + +- [ ] **Step 3: Patch `title:` in the first patch pass** + +In `changeGroomOp.Plan`, directly after the line `upsertField(&ps, doc1, "updated", document.String(o.clock.Now().UTC().Format("2006-01-02")))`, add: + +```go + if o.req.Title != "" { + // Retitle (change 0461). The writer quotes the scalar, so ADR-0071 holds by + // construction. upsertField tolerates a record lacking the key rather than + // internal-erroring. The slug, record path, and spec path are never + // touched: the candidate snapshot below carries the new title into the + // artifact block and the board with no further code. + upsertField(&ps, doc1, "title", document.String(o.req.Title)) + } +``` + +- [ ] **Step 4: Add the re-stamp helper** + +Add this to `internal/app/change_groom.go`, directly after `assembleSpecFile`: + +```go +// restampSpecBacklink rewrites only the docket:backlink block of the spec at +// specPath so it names gc's (new) title, over the spec's CURRENT bytes on the +// attempt's base tree (change 0461). It takes no spec_version: nothing +// caller-authored is written, and a concurrent spec edit moves the base and +// contends the push instead of being clobbered. A missing file refuses +// spec-file-missing; a spec that does not parse (malformed markers) refuses +// 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) { + blob, _, exists, err := treeBlob(ctx, tree, specPath) + if err != nil { + return nil, false, "", "", err + } + if !exists { + return nil, false, "spec-file-missing", + fmt.Sprintf("change %04d links spec %q but no such file exists on the tree", int(gc.ID()), specPath), nil + } + doc, perr := document.Parse(blob) + if perr != nil { + return nil, false, "spec-backlink-malformed", + fmt.Sprintf("spec %q does not parse, so its docket:backlink block cannot be re-stamped: %v", specPath, perr), nil + } + if _, ok := doc.Block(backlinkBlockName); !ok { + return nil, false, "spec-backlink-missing", + fmt.Sprintf("spec %q has no docket:backlink block to re-stamp with the new title; restore it with `artifact backlink` first", specPath), nil + } + block, err := render.BacklinkContent(gc, link) + if err != nil { + return nil, false, "", "", fmt.Errorf("change groom: rendering spec backlink: %w", err) + } + var ps document.PatchSet + ps.ReplaceBlock(backlinkBlockName, backlinkInterior(block)) + out, aerr := doc.Apply(ps) + if aerr != nil { + return nil, false, "spec-backlink-malformed", + fmt.Sprintf("rewriting the docket:backlink block in %q: %v", specPath, aerr), nil + } + return out, !bytes.Equal(out, blob), "", "", nil +} +``` + +Before relying on the `spec-backlink-malformed` test row, confirm the `document.Parse` behavior on a dangling start marker it assumes. The `artifact.backlink` operation's file comment ("a malformed backlink marker (dangling/out-of-order/nested) fails the document parse") says it fails. If the fixture parses anyway, change the fixture to a shape the parser does reject (for example an end marker before the start marker), and never weaken the assert. + +- [ ] **Step 5: Call it from Plan** + +In `changeGroomOp.Plan`, directly after the `if reviseSpec { … }` block (the whole-body replace) and before `if o.inline {`, add: + +```go + // Title re-stamp (change 0461). The spec outcome and a spec-body revise + // already render the backlink from gc, so this runs only when the title + // 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) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + if code != "" { + return refuseGroom(code, msg) + } + if changed { + files = append(files, transaction.FileMutation{ + Path: gitcli.RepoPath(c.Spec().Value), Kind: transaction.MutationReplace, Bytes: updated, + }) + } + } +``` + +Also update the `Plan` doc comment's closing sentence ("…the groomed change record, the new spec file (spec outcome) or the replaced existing spec file (a spec-body revise), and the re-rendered board…") to add "or the linked spec's re-stamped backlink (a retitle)". + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test -count=1 ./internal/app/ -run 'TestChangeGroom'` +Expected: PASS, both the new tests and every pre-existing `TestChangeGroom*` test. + +- [ ] **Step 7: Mutation-check** + +(a) Delete the Step 5 block: `TestChangeGroomPlanTitleOnlyReviseRestampsSpecBacklink` and `TestChangeGroomPlanTitleRestampRefusals` must go red. +(b) Change the guard's `gc.Title() != c.Title()` to `o.req.Title != ""`: `TestChangeGroomPlanUnchangedTitleSkipsRestamp` must go red. +(c) Drop `&& !reviseSpec`: `TestChangeGroomPlanTitleWithSpecBodyRevise` must go red on the duplicate declaration. +(d) Delete the Step 3 block: the retitle tests must go red. +Restore each from a saved copy and re-run to green. + +- [ ] **Step 8: Commit** + +```bash +git add internal/app/change_groom.go internal/app/change_groom_test.go +git commit -m "feat(groom): a groom title rewrites title: and re-stamps the linked spec's backlink (change 0461)" +``` + +--- + +### Task 4: Board title cells escape `|` and flatten line breaks + +**Files:** +- Modify: `internal/render/board.go` (`boardRepairCell`, new `boardCellReplacer` + `boardTitleCell`, `boardSectionRow`, the archive-row loop in `Board`) +- Test: `internal/render/board_test.go` + +**Interfaces:** +- Consumes: the existing test helpers `proposedChange`, `archivedDone`, `boardFrom`, `domain.NewChange`. +- Produces: `func boardTitleCell(title string) string` (unexported, package `render`). + +- [ ] **Step 1: Write the failing test** + +Append to `internal/render/board_test.go`: + +```go +// TestBoardTitleCellsEscapePipesAndFlattenBreaks pins change 0461's board fix: a +// title is one table cell in every section and in the archive footer, so a +// literal "|" is escaped and a legacy line break (a record predating the title +// validator) is flattened — neither can split or end the row. +func TestBoardTitleCellsEscapePipesAndFlattenBreaks(t *testing.T) { + active := domain.NewChange(proposedChange(1, "pipe", "Keep a | b apart")) + legacy := domain.NewChange(proposedChange(2, "multi", "first\nsecond")) + arch := archivedDone(3, "2026-08-31", "arch", "Old | title") + + out := string(boardFrom(t, active, legacy, arch)) + + for _, want := range []string{"| Keep a \\| b apart |", "| first second |", "| Old \\| title |"} { + if !strings.Contains(out, want) { + t.Errorf("board missing escaped cell %q:\n%s", want, out) + } + } + for _, bad := range []string{"Keep a | b", "Old | title", "first\nsecond"} { + if strings.Contains(out, bad) { + t.Errorf("board carries unescaped title %q:\n%s", bad, out) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `go test -count=1 ./internal/render/ -run TestBoardTitleCellsEscapePipesAndFlattenBreaks` +Expected: FAIL (`board missing escaped cell "| Keep a \| b apart |"`). + +- [ ] **Step 3: Implement** + +In `internal/render/board.go`, replace `boardRepairCell` with: + +```go +// boardCellReplacer flattens line breaks to spaces and escapes a literal pipe, +// so an authored string can never split or end a Markdown table row. Both the +// repair notice and every title cell use it. +var boardCellReplacer = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", "|", "\\|") + +// boardRepairCell flattens a reason into one Markdown table cell: line breaks +// become spaces and a literal pipe is escaped so it cannot split the row. +func boardRepairCell(reason string) string { + return strings.TrimSpace(boardCellReplacer.Replace(reason)) +} + +// boardTitleCell renders a change title as one Markdown table cell (change +// 0461): a "|" is escaped and a line break flattened. Titles written by +// change.create or change.groom are already single-line; the flattening +// defends legacy records. No trimming, so a clean title is byte-identical. +func boardTitleCell(title string) string { + return boardCellReplacer.Replace(title) +} +``` + +In `boardSectionRow`, change `title := c.Title()` to `title := boardTitleCell(c.Title())`. + +In `Board`'s archive loop, change `title: c.Title(),` in the `arcRow{…}` literal to `title: boardTitleCell(c.Title()),`. + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `go test -count=1 ./internal/render/` +Expected: PASS, including `TestBoardGolden`. The frozen corpus has no `|` title, so the golden bytes are unchanged. If `TestBoardGolden` reddens, stop and investigate. Never regenerate the golden to make it pass. + +- [ ] **Step 5: Mutation-check** + +Revert `boardSectionRow` to `c.Title()` and confirm the active-row assertions go red. Separately revert the archive literal and confirm the archive assertion goes red. Restore from a saved copy. + +- [ ] **Step 6: Commit** + +```bash +git add internal/render/board.go internal/render/board_test.go +git commit -m "fix(board): escape | and flatten line breaks in title cells (change 0461)" +``` + +--- + +### Task 5: `docket-groom-next` pointer, glossary note, prose-contract sentinel, embedded mirror + +**Files:** +- Modify: `skills/docket-groom-next/SKILL.md` (Step 4) +- Modify: `docs/reference/glossary.md` (`### Groom outcome \`revise\``) +- Modify: `internal/repoguard/prose_contracts_test.go` (`proseContracts` table) +- Regenerate: `internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md`, `internal/assets/embedded/manifest.json` (via `go generate ./internal/assets/` only) + +**Interfaces:** +- Consumes: the refusal codes `invalid-title`, `empty-title` (Task 1/2), `spec-backlink-missing`, `spec-backlink-malformed` (Task 3). +- Produces: the prose-contract sentinel `change_0461_retitle`. + +- [ ] **Step 1: Write the failing sentinel** + +In `internal/repoguard/prose_contracts_test.go`, add this row to `proseContracts` directly after the existing `change_0382_typed_abstain` row for `skills/docket-groom-next/SKILL.md`: + +```go + // change 0461 — change.groom carries an optional title; a retitle renames + // nothing (slug, record path, spec path, and branch stay put). + {sentinel: "change_0461_retitle", file: "skills/docket-groom-next/SKILL.md", + present: []string{"also carry `title`", "a `title` alone is a valid revise", "a retitle renames nothing"}}, +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `go test -count=1 ./internal/repoguard/ -run TestProseContracts` +Expected: FAIL (`[change_0461_retitle] … missing "also carry `title`"`). + +- [ ] **Step 3: Edit the skill** + +In `skills/docket-groom-next/SKILL.md`, Step 4, insert this paragraph after list item `6. **Re-arm** …` and before `### Step 5`, with one blank line on each side: + +```markdown +**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. +``` + +- [ ] **Step 4: Edit the glossary** + +In `docs/reference/glossary.md`, `### Groom outcome \`revise\``, append this sentence to the end of the `**Used for:**` paragraph (after "…contends instead of being overwritten."): + +```markdown +A `title` alone is also a valid revise: it rewrites `title:`, the board row, and the spec's backlink line, and renames nothing — the slug and every path stay put. +``` + +- [ ] **Step 5: Regenerate the embedded mirror** + +Run: `go generate ./internal/assets/` +Then run `git status --porcelain` and confirm that the only generated changes are `internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md` and `internal/assets/embedded/manifest.json`, both mechanical. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `go test -count=1 ./internal/repoguard/ ./internal/assets/...` +Expected: PASS (the prose contract, the asset-bundle drift guard, and the rest). + +- [ ] **Step 7: Mutation-check** + +Delete the words "a retitle renames nothing" from the skill paragraph, re-run `go test -count=1 ./internal/repoguard/ -run TestProseContracts`, and confirm `change_0461_retitle` goes red. Restore the text, re-run `go generate ./internal/assets/`, and confirm the test is green again. + +- [ ] **Step 8: Commit** + +```bash +git add skills/docket-groom-next/SKILL.md docs/reference/glossary.md internal/repoguard/prose_contracts_test.go internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md internal/assets/embedded/manifest.json +git commit -m "docs(skills): groom-next passes title when a groom renames the change (change 0461)" +``` + +--- + +## Spec coverage map + +| Spec requirement | Task | +|---|---| +| `ChangeGroomRequest.Title`, schema picks it up | 2 | +| Outcome acceptance table; abstain → `invalid-title` | 2 | +| `empty-revise` widened to title; diagnostic names all three | 2 | +| Shared `validateTitle` in create + groom; `empty-title` / `invalid-title` | 1, 2 | +| Field patch through the writer (ADR-0071) | 3 | +| Derived views (artifact block, board) via candidate snapshot | 3 (asserted on the board) | +| Spec backlink re-stamp: no `spec_version`, `spec-file-missing`, missing block refused, declare only on change | 3 | +| Same-title no-op | 3 | +| Board `\|` escaping in active + archive rows; newline flattening | 4 | +| `docket-groom-next` Step 4 pointer | 5 | +| Mutation-test the re-stamp and the escaping | 3 Step 7, 4 Step 5 | From 3715843f07965144dbcfb2f8eafd6a25342aff27 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:37:09 -0400 Subject: [PATCH 02/10] feat(app): shared title validator; change.create refuses multi-line/control titles (change 0461) --- internal/app/change_create.go | 8 ++++++ internal/app/change_create_test.go | 23 +++++++++++++++++ internal/app/change_title.go | 35 +++++++++++++++++++++++++ internal/app/change_title_test.go | 41 ++++++++++++++++++++++++++++++ internal/app/finding_codes.go | 2 ++ internal/app/finding_codes_test.go | 3 ++- internal/document/value.go | 7 +++++ 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 internal/app/change_title.go create mode 100644 internal/app/change_title_test.go diff --git a/internal/app/change_create.go b/internal/app/change_create.go index 1c9056866..9f009efb5 100644 --- a/internal/app/change_create.go +++ b/internal/app/change_create.go @@ -301,6 +301,14 @@ func validateChangeCreateShape(req ChangeCreateRequest) []StatusFinding { addShape(f.code, f.name+" must be non-empty") } } + // The shared title rule (change 0461). A blank title is already reported as + // empty-title by the loop above, so validateTitle runs only on a non-blank + // one and never duplicates that finding. + if strings.TrimSpace(req.Title) != "" { + if code, msg := validateTitle(req.Title); code != "" { + addShape(code, msg) + } + } for _, coll := range []struct { name string ids []int diff --git a/internal/app/change_create_test.go b/internal/app/change_create_test.go index b43bc6db4..33481498b 100644 --- a/internal/app/change_create_test.go +++ b/internal/app/change_create_test.go @@ -96,6 +96,9 @@ func TestChangeCreateRejectsBadShapeWithoutEngineCall(t *testing.T) { }{ {"short request id", func(r *ChangeCreateRequest) { r.RequestID = "short" }, "invalid-request_id"}, {"empty title", func(r *ChangeCreateRequest) { r.Title = "" }, "empty-title"}, + {"multi-line title", func(r *ChangeCreateRequest) { r.Title = "Add\na widget" }, "invalid-title"}, + {"control-character title", func(r *ChangeCreateRequest) { r.Title = "Add\x07a widget" }, "invalid-title"}, + {"line-separator title", func(r *ChangeCreateRequest) { r.Title = "Add\u2028a widget" }, "invalid-title"}, {"blank why", func(r *ChangeCreateRequest) { r.Why = " " }, "empty-why"}, {"empty what", func(r *ChangeCreateRequest) { r.WhatChanges = "" }, "empty-what_changes"}, {"empty out of scope", func(r *ChangeCreateRequest) { r.OutOfScope = "" }, "empty-out_of_scope"}, @@ -594,3 +597,23 @@ func TestChangeCreatePayloadOmitsUnsetDraftScalars(t *testing.T) { } } } + +// TestChangeCreateWhitespaceTitleReportsEmptyTitleOnce pins Review Focus 2: the +// shared validateTitle runs only on a non-blank title, so a blank one yields the +// existing empty-title finding exactly once, never a duplicate. +func TestChangeCreateWhitespaceTitleReportsEmptyTitleOnce(t *testing.T) { + req := validChangeCreateRequest() + req.Title = " " + n := 0 + for _, f := range validateChangeCreateShape(req) { + if f.Code == string(FCEmptyTitle) { + n++ + } + if f.Code == string(FCInvalidTitle) { + t.Errorf("blank title also reported %q: %v", FCInvalidTitle, f) + } + } + if n != 1 { + t.Errorf("empty-title reported %d times, want exactly 1", n) + } +} diff --git a/internal/app/change_title.go b/internal/app/change_title.go new file mode 100644 index 000000000..f9cc6018a --- /dev/null +++ b/internal/app/change_title.go @@ -0,0 +1,35 @@ +package app + +import ( + "fmt" + "strings" + "unicode/utf8" + + "github.com/danielhanold/docket/internal/document" +) + +// validateTitle is the one title-shape rule change.create and change.groom +// share (change 0461). A title must be non-empty after trimming (empty-title), +// valid UTF-8, a single line, and free of every rune the frontmatter writer +// refuses — control characters (tab included, though the writer tolerates it: +// a title is one board cell and one backlink line), U+2028/U+2029, and +// U+FFFE/U+FFFF (invalid-title). The rune set is document.IllegalTextRune, the +// writer's own predicate, so a title this admits always serializes. It returns +// ("", "") for a valid title. +func validateTitle(title string) (FindingCode, string) { + if strings.TrimSpace(title) == "" { + return FCEmptyTitle, "title must be non-empty" + } + if !utf8.ValidString(title) { + return FCInvalidTitle, "title must be valid UTF-8" + } + if strings.ContainsAny(title, "\r\n") { + return FCInvalidTitle, "title must be a single line (no line breaks)" + } + for _, r := range title { + if document.IllegalTextRune(r) { + return FCInvalidTitle, fmt.Sprintf("title must not contain control or line-separator characters (found %U)", r) + } + } + return "", "" +} diff --git a/internal/app/change_title_test.go b/internal/app/change_title_test.go new file mode 100644 index 000000000..6eaea7832 --- /dev/null +++ b/internal/app/change_title_test.go @@ -0,0 +1,41 @@ +package app + +import "testing" + +// TestValidateTitle pins the one title-shape rule change.create and change.groom +// share (change 0461): non-empty after trimming, a single line, and no rune the +// frontmatter writer itself refuses — so the request layer never admits a title +// the writer would reject at Apply (learning validator-must-match-the-reader-it-feeds). +func TestValidateTitle(t *testing.T) { + cases := []struct { + name string + title string + code FindingCode + }{ + {"plain", "Allow editing a title", ""}, + {"yaml-hostile punctuation is fine", "Fix: the '#1' bug | now", ""}, + {"leading quote is fine", "'quoted' start", ""}, + {"empty", "", FCEmptyTitle}, + {"whitespace only", " \t ", FCEmptyTitle}, + {"newline", "first\nsecond", FCInvalidTitle}, + {"carriage return", "first\rsecond", FCInvalidTitle}, + {"tab", "a\tb", FCInvalidTitle}, + {"nul", "a\x00b", FCInvalidTitle}, + {"bell", "a\x07b", FCInvalidTitle}, + {"next line (C1)", "a\u0085b", FCInvalidTitle}, + {"line separator", "a
b", FCInvalidTitle}, + {"paragraph separator", "a
b", FCInvalidTitle}, + {"invalid utf-8", "a\xffb", FCInvalidTitle}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + code, msg := validateTitle(c.title) + if code != c.code { + t.Fatalf("validateTitle(%q) code = %q (%s), want %q", c.title, code, msg, c.code) + } + if code != "" && msg == "" { + t.Errorf("validateTitle(%q) refused with an empty message", c.title) + } + }) + } +} diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index 32096861e..3216f2a4e 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -105,6 +105,7 @@ const ( FCInvalidStackedOn FindingCode = "invalid-stacked_on" FCInvalidBranchPrefix FindingCode = "invalid-branch_prefix" FCEmptyTitle FindingCode = "empty-title" + FCInvalidTitle FindingCode = "invalid-title" FCEmptyWhy FindingCode = "empty-why" FCEmptyWhatChanges FindingCode = "empty-what_changes" FCEmptyOutOfScope FindingCode = "empty-out_of_scope" @@ -263,6 +264,7 @@ var AllFindingCodes = []FindingCode{ FCInvalidStackedOn, FindingCode("invalid-successor-id"), FCInvalidTargetID, + FCInvalidTitle, FCInvalidTopics, FindingCode("lease-not-expired"), FCLocalMetadataAhead, diff --git a/internal/app/finding_codes_test.go b/internal/app/finding_codes_test.go index bccac5249..4ebcf75cf 100644 --- a/internal/app/finding_codes_test.go +++ b/internal/app/finding_codes_test.go @@ -225,6 +225,7 @@ func TestShapeValidatorCodesAreRegistered(t *testing.T) { var emitted []StatusFinding emitted = append(emitted, validateChangeCreateShape(ChangeCreateRequest{StackedOn: &zero})...) emitted = append(emitted, validateChangeCreateShape(ChangeCreateRequest{BranchPrefix: "a/b"})...) + emitted = append(emitted, validateChangeCreateShape(ChangeCreateRequest{Title: "a\nb"})...) emitted = append(emitted, validateADRRecordShape(adrContent)...) emitted = append(emitted, validateADRReplaceShape(ADRReplaceRequest{Target: ADRTarget{ID: 0}, Successor: adrContent})...) emitted = append(emitted, validateLearningRecordShape(LearningRecordRequest{Topics: []string{""}})...) @@ -255,7 +256,7 @@ func TestShapeValidatorCodesAreRegistered(t *testing.T) { // reach. A miss means the mint path drifted from the registered constant. floor := []FindingCode{ FCInvalidRequestID, FCInvalidStackedOn, FCInvalidBranchPrefix, - FCEmptyTitle, FCEmptyWhy, FCEmptyWhatChanges, FCEmptyOutOfScope, + FCEmptyTitle, FCInvalidTitle, FCEmptyWhy, FCEmptyWhatChanges, FCEmptyOutOfScope, FCEmptyContext, FCEmptyDecision, FCEmptyConsequences, FCEmptyAlternatives, FCInvalidChangeDotID, FCEmptyChangePath, FCEmptyChangeVersion, FCInvalidTargetID, FCEmptyTargetPath, FCEmptyTargetVersion, diff --git a/internal/document/value.go b/internal/document/value.go index d7e57e0a3..37c63a5ee 100644 --- a/internal/document/value.go +++ b/internal/document/value.go @@ -79,6 +79,13 @@ func illegalTextRune(r rune) bool { return unicode.IsControl(r) || r == 0x2028 || r == 0x2029 || r == 0xfffe || r == 0xffff } +// IllegalTextRune is the exported view of illegalTextRune: it reports whether r +// may never appear in a field string the writer serializes, apart from the tab +// exemption the writer grants itself. Request validators consult it so they +// refuse exactly the set the writer refuses, never a hand-enumerated copy that +// drifts from it (learning validator-must-match-the-reader-it-feeds). +func IllegalTextRune(r rune) bool { return illegalTextRune(r) } + // validate reports whether v is representable in the closed model. The whole // value — every element of a sequence included — is checked before any caller // serializes a byte, so a defective tail item cannot yield a partial document. From 950fd6dcdf2bf222851e9220dbbb3716e1b3b06d Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:39:10 -0400 Subject: [PATCH 03/10] feat(groom): change.groom accepts an optional title; a title alone is a valid revise (change 0461) --- internal/app/change_groom.go | 22 ++++++- internal/app/change_groom_test.go | 100 +++++++++++++++++++++++++++++ internal/app/finding_codes_test.go | 2 + internal/app/schema_test.go | 3 + 4 files changed, 125 insertions(+), 2 deletions(-) diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index 53a4bcaad..53134a57c 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -112,6 +112,12 @@ type ChangeGroomRequest struct { // note must not carry a column-zero "## " heading or an unterminated fence. BlockedNote string `json:"blocked_note,omitempty"` + // Title, when non-empty, retitles the change (change 0461). The spec, + // trivial, revise, and rearm outcomes accept it; abstain refuses it, since an + // abstain cannot rewrite the proposal. Empty leaves the title unchanged. A + // retitle renames nothing: the slug, record path, spec path, and branch stay put. + Title string `json:"title,omitempty"` + DependsOn []int `json:"depends_on"` Related []int `json:"related"` DiscoveredFrom []int `json:"discovered_from"` @@ -322,8 +328,8 @@ func validateChangeGroomShape(req ChangeGroomRequest) []StatusFinding { if msg := specMarkdownShapeProblem(req.SpecMarkdown); msg != "" { addShape(FCInvalidSpecMarkdown, msg) } - } else if !hasEffectiveSectionEdit(req.Sections) { - addShape(FCEmptyRevise, "the revise outcome requires a non-empty spec_markdown or at least one replace/remove section edit") + } else if !hasEffectiveSectionEdit(req.Sections) && req.Title == "" { + addShape(FCEmptyRevise, "the revise outcome requires a non-empty spec_markdown, at least one replace/remove section edit, or a title") } case GroomAbstain: if strings.TrimSpace(req.BlockedNote) == "" { @@ -337,6 +343,9 @@ func validateChangeGroomShape(req ChangeGroomRequest) []StatusFinding { if len(req.Sections) > 0 { addShape(FCInvalidSections, "sections are not accepted by the abstain outcome; an abstain cannot rewrite the proposal") } + if req.Title != "" { + addShape(FCInvalidTitle, "title is not accepted by the abstain outcome; an abstain cannot rewrite the proposal") + } for _, rel := range []struct { name string set bool @@ -365,6 +374,15 @@ func validateChangeGroomShape(req ChangeGroomRequest) []StatusFinding { addShape(FCInvalidOutcome, fmt.Sprintf("outcome %q must be one of spec, trivial, revise, abstain, rearm", req.Outcome)) } + // A title retitles the change on every other outcome (change 0461); an empty + // one means "unchanged". Any non-empty title must pass the shared rule, so a + // whitespace-only one is empty-title, never a silent no-op. + if req.Title != "" && req.Outcome != GroomAbstain { + if code, msg := validateTitle(req.Title); code != "" { + addShape(code, msg) + } + } + // blocked_note is the abstain entry's body; nothing else reads it, so it is // refused anywhere else rather than silently ignored. if req.Outcome != GroomAbstain && req.BlockedNote != "" { diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index 95c726b53..77baa03bc 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -1306,3 +1306,103 @@ func TestChangeGroomPlanRearmRemovesSectionBeforeFollowingSection(t *testing.T) t.Errorf("following section not preserved byte-identically:\n%s", rec) } } + +// titleOnlyReviseRequest is a revise carrying nothing but a title — no spec +// body, no spec_version, no section edits (change 0461). +func titleOnlyReviseRequest(title string) ChangeGroomRequest { + r := validReviseRequest() + r.SpecMarkdown, r.SpecVersion, r.Sections = "", "", nil + r.Title = title + return r +} + +// titledTrivialRequest is a well-formed trivial groom of the groomable fixture +// at id 2 that also retitles it. +func titledTrivialRequest(title string) ChangeGroomRequest { + return ChangeGroomRequest{ + ChangeID: 2, + Path: groomPath(2, "add-a-widget"), + Version: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + Outcome: GroomTrivial, + Title: title, + Sections: []SectionEditRequest{ + {Heading: "## Why", Intent: "replace", Markdown: "Trivial: a rename only.\n"}, + }, + } +} + +func TestChangeGroomTitleShapeValidation(t *testing.T) { + withTitle := func(r ChangeGroomRequest, title string) ChangeGroomRequest { + r.Title = title + return r + } + cases := []struct { + name string + req ChangeGroomRequest + code string // "" means the request must pass shape validation + }{ + {"spec accepts title", withTitle(validGroomSpecRequest(), "Renamed widget"), ""}, + {"trivial accepts title", titledTrivialRequest("Renamed widget"), ""}, + {"rearm accepts title", withTitle(rearmRequest(), "Renamed widget"), ""}, + {"revise accepts a title alone", titleOnlyReviseRequest("Renamed widget"), ""}, + {"revise accepts title with sections", withTitle(validReviseRequest(), "Renamed widget"), ""}, + {"abstain refuses title", withTitle(abstainRequest(), "Renamed widget"), "invalid-title"}, + {"whitespace-only title", titleOnlyReviseRequest(" "), "empty-title"}, + {"multi-line title", titleOnlyReviseRequest("Renamed\nwidget"), "invalid-title"}, + {"control-character title", titleOnlyReviseRequest("Renamed\x00widget"), "invalid-title"}, + {"multi-line title on spec outcome", withTitle(validGroomSpecRequest(), "a\r\nb"), "invalid-title"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + findings := validateChangeGroomShape(c.req) + if c.code == "" { + if len(findings) != 0 { + t.Fatalf("unexpected shape findings: %v", findings) + } + return + } + if !hasFindingCode(findings, c.code) { + t.Errorf("missing finding %q; got %v", c.code, findings) + } + }) + } +} + +// TestChangeGroomEmptyReviseNamesTitle pins the widened empty-revise rule: with +// no spec_markdown, no effective section edit, and no title the revise is still +// refused, and the diagnostic names all three inputs. +func TestChangeGroomEmptyReviseNamesTitle(t *testing.T) { + findings := validateChangeGroomShape(titleOnlyReviseRequest("")) + var msg string + for _, f := range findings { + if f.Code == string(FCEmptyRevise) { + msg = f.Message + } + } + if msg == "" { + t.Fatalf("empty revise not refused; findings %v", findings) + } + for _, want := range []string{"spec_markdown", "section edit", "title"} { + if !strings.Contains(msg, want) { + t.Errorf("empty-revise message %q does not name %q", msg, want) + } + } +} + +func TestChangeGroomBadTitleRefusedWithoutEngineCall(t *testing.T) { + engine := &recordingEngine{} + reader := &fakeChangeReader{pin: mainModePin([]string{"inline"})} + deps := PlanningDeps{Engine: engine, Reader: reader, Clock: testClock()} + + res := ChangeGroom(context.Background(), deps, "", titleOnlyReviseRequest("two\nlines")) + + 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)) + } + if !hasFindingCode(res.Findings, "invalid-title") { + t.Errorf("missing invalid-title; got %v", res.Findings) + } +} diff --git a/internal/app/finding_codes_test.go b/internal/app/finding_codes_test.go index 4ebcf75cf..e3260910f 100644 --- a/internal/app/finding_codes_test.go +++ b/internal/app/finding_codes_test.go @@ -237,6 +237,8 @@ func TestShapeValidatorCodesAreRegistered(t *testing.T) { emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomOutcome("bogus")})...) emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomAbstain, Sections: []SectionEditRequest{{Heading: "## Why", Intent: "remove"}}})...) emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomTrivial, BlockedNote: "x"})...) + emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomAbstain, Title: "x"})...) + emitted = append(emitted, validateChangeGroomShape(ChangeGroomRequest{Outcome: GroomRevise, Title: "a\nb"})...) emitted = append(emitted, validateChangeReconcileShape(ChangeReconcileRequest{ Sections: map[string]string{"## Not Owned": "x"}, SpecSections: map[string]string{"not a heading": "x"}, diff --git a/internal/app/schema_test.go b/internal/app/schema_test.go index 3776e456f..8a6eee5ef 100644 --- a/internal/app/schema_test.go +++ b/internal/app/schema_test.go @@ -104,6 +104,9 @@ func TestReflectDescriptorChangeGroomRequest(t *testing.T) { if f := fieldByKey(t, d, "blocked_note"); f.Required || f.Type != "string" { t.Errorf("blocked_note = %+v, want optional string", f) } + if f := fieldByKey(t, d, "title"); f.Required || f.Type != "string" { + t.Errorf("title = %+v, want optional string", f) + } // No caller-supplied spec path: the spec pinned is always the one the // record's spec: field links. for _, f := range d.Fields { From 31735f218b15908148b78b31ab8f03397a4c89b7 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:42:15 -0400 Subject: [PATCH 04/10] feat(groom): a groom title rewrites title: and re-stamps the linked spec's backlink (change 0461) --- internal/app/change_groom.go | 73 +++++++++- internal/app/change_groom_test.go | 217 ++++++++++++++++++++++++++++++ 2 files changed, 288 insertions(+), 2 deletions(-) diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index 53134a57c..69afd1837 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -538,8 +538,9 @@ func (o changeGroomOp) Key() transaction.OperationKey { return OperationChangeGr // Plan gates the groom against the attempt's snapshot, splices the owned // proposal sections, patches the typed fields, re-renders the artifact block, // and assembles the closed plan: the groomed change record, the new spec file -// (spec outcome) or the replaced existing spec file (a spec-body revise), and -// the re-rendered board when inline is enabled. +// (spec outcome), the replaced existing spec file (a spec-body revise), or the +// linked spec's re-stamped backlink (a retitle), and the re-rendered board when +// inline is enabled. func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (transaction.MutationPlan, transaction.OperationResult, error) { snap := st.State.Snapshot @@ -669,6 +670,14 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t // ADR ops, which upsert the same field, rather than internal-erroring with a // KindMissingPatchTarget. upsertField(&ps, doc1, "updated", document.String(o.clock.Now().UTC().Format("2006-01-02"))) + if o.req.Title != "" { + // Retitle (change 0461). The writer quotes the scalar, so ADR-0071 holds by + // construction. upsertField tolerates a record lacking the key rather than + // internal-erroring. The slug, record path, and spec path are never + // touched: the candidate snapshot below carries the new title into the + // artifact block and the board with no further code. + upsertField(&ps, doc1, "title", document.String(o.req.Title)) + } if o.req.DependsOn != nil { ps.SetField("depends_on", intSeqValue(o.req.DependsOn)) } @@ -757,6 +766,25 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t } } + // Title re-stamp (change 0461). The spec outcome and a spec-body revise + // already render the backlink from gc, so this runs only when the title + // 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) + if err != nil { + return transaction.MutationPlan{}, transaction.OperationResult{}, err + } + if code != "" { + return refuseGroom(code, msg) + } + if changed { + files = append(files, transaction.FileMutation{ + Path: gitcli.RepoPath(c.Spec().Value), Kind: transaction.MutationReplace, Bytes: updated, + }) + } + } + if o.inline { 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 { @@ -837,6 +865,47 @@ func assembleSpecFile(backlink, markdown string) []byte { return []byte(b.String()) } +// restampSpecBacklink rewrites only the docket:backlink block of the spec at +// specPath so it names gc's (new) title, over the spec's CURRENT bytes on the +// attempt's base tree (change 0461). It takes no spec_version: nothing +// caller-authored is written, and a concurrent spec edit moves the base and +// contends the push instead of being clobbered. A missing file refuses +// spec-file-missing; a spec that does not parse (malformed markers) refuses +// 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) { + blob, _, exists, err := treeBlob(ctx, tree, specPath) + if err != nil { + return nil, false, "", "", err + } + if !exists { + return nil, false, "spec-file-missing", + fmt.Sprintf("change %04d links spec %q but no such file exists on the tree", int(gc.ID()), specPath), nil + } + doc, perr := document.Parse(blob) + if perr != nil { + return nil, false, "spec-backlink-malformed", + fmt.Sprintf("spec %q does not parse, so its docket:backlink block cannot be re-stamped: %v", specPath, perr), nil + } + if _, ok := doc.Block(backlinkBlockName); !ok { + return nil, false, "spec-backlink-missing", + fmt.Sprintf("spec %q has no docket:backlink block to re-stamp with the new title; restore it with `artifact backlink` first", specPath), nil + } + block, err := render.BacklinkContent(gc, link) + if err != nil { + return nil, false, "", "", fmt.Errorf("change groom: rendering spec backlink: %w", err) + } + var ps document.PatchSet + ps.ReplaceBlock(backlinkBlockName, backlinkInterior(block)) + out, aerr := doc.Apply(ps) + if aerr != nil { + return nil, false, "spec-backlink-malformed", + fmt.Sprintf("rewriting the docket:backlink block in %q: %v", specPath, aerr), nil + } + return out, !bytes.Equal(out, blob), "", "", nil +} + // buildGroomCandidate rebuilds the complete snapshot the attempt would see after // the groomed record lands: every existing corpus document reclassified by its // path, with the groomed change's document swapped in at changePath. The diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index 77baa03bc..0bfc907c3 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -1406,3 +1406,220 @@ func TestChangeGroomBadTitleRefusedWithoutEngineCall(t *testing.T) { t.Errorf("missing invalid-title; got %v", res.Findings) } } + +// reviseSpecBody is the fixture spec's bytes after its backlink block — the part +// a title re-stamp must leave byte-identical. +const reviseSpecBody = "\n\n# Design\n\nThe original design body.\n" + +func TestChangeGroomPlanTitleOnlyReviseRestampsSpecBacklink(t *testing.T) { + files := reviseFixtureFiles() + files["docs/changes/BOARD.md"] = "# Backlog\n\nold\n" + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, titleOnlyReviseRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // One commit: the record, the spec (backlink re-stamp only), and the board. + // The record path itself is unchanged — a retitle renames nothing. + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + reviseSpecPath: transaction.MutationReplace, + "docs/changes/BOARD.md": transaction.MutationReplace, + }) + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + for _, want := range []string{"title: 'Renamed widget'", "updated: '2026-08-16'", "slug: add-a-widget", "spec: '" + reviseSpecPath + "'"} { + if !strings.Contains(rec, want) { + t.Errorf("record missing %q:\n%s", want, rec) + } + } + spec := string(groomedRecordBytes(t, plan, reviseSpecPath)) + if !strings.Contains(spec, "Change 0002 — Renamed widget") { + t.Errorf("spec backlink not re-stamped with the new title:\n%s", spec) + } + if strings.Contains(spec, "old backlink") { + t.Errorf("old backlink line survived:\n%s", spec) + } + if !strings.HasSuffix(spec, reviseSpecBody) { + t.Errorf("spec bytes outside the backlink block changed:\n%s", spec) + } + board := string(groomedRecordBytes(t, plan, "docs/changes/BOARD.md")) + if !strings.Contains(board, "| Renamed widget |") || strings.Contains(board, "A change") { + t.Errorf("board row not retitled:\n%s", board) + } + // A title-only revise replaced no spec body, so the receipt names none. + assertGroomReceiptSpecPath(t, plan, "") +} + +// TestChangeGroomPlanTitleRoundTripsThroughWriter pins ADR-0071 for the new +// field: YAML-hostile punctuation lands writer-quoted and reads back as the +// exact string (the re-stamped backlink is rendered from the reparsed record). +func TestChangeGroomPlanTitleRoundTripsThroughWriter(t *testing.T) { + title := "Fix: the '#1' bug" + plan, opRes := groomPlanFor(t, reviseFixtureFiles(), baseGroomOp([]string{}, titleOnlyReviseRequest(title))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + if !strings.Contains(rec, "title: 'Fix: the ''#1'' bug'\n") { + t.Errorf("title not writer-quoted:\n%s", rec) + } + if spec := string(groomedRecordBytes(t, plan, reviseSpecPath)); !strings.Contains(spec, "Change 0002 — "+title) { + t.Errorf("title did not round-trip into the backlink:\n%s", spec) + } +} + +func TestChangeGroomPlanTitleOnSpecOutcome(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): groomableChange(2, "add-a-widget")} + req := validGroomSpecRequest() + req.Title = "Renamed widget" + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // The new spec path is still minted from the unchanged slug. + newSpec := "docs/superpowers/specs/2026-08-16-add-a-widget-design.md" + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + newSpec: transaction.MutationCreate, + }) + if spec := string(groomedRecordBytes(t, plan, newSpec)); !strings.Contains(spec, "Change 0002 — Renamed widget") { + t.Errorf("new spec's backlink lacks the new title:\n%s", spec) + } + if rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))); !strings.Contains(rec, "title: 'Renamed widget'") { + t.Errorf("record not retitled:\n%s", rec) + } +} + +func TestChangeGroomPlanTitleOnTrivialOutcome(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): groomableChange(2, "add-a-widget")} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, titledTrivialRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + // No spec file is touched. + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) + rec := string(groomedRecordBytes(t, plan, groomPath(2, "add-a-widget"))) + if !strings.Contains(rec, "title: 'Renamed widget'") || !strings.Contains(rec, "trivial: true") { + t.Errorf("record not retitled/trivialled:\n%s", rec) + } +} + +// TestChangeGroomPlanTitleWithSpecBodyRevise pins Review Focus 4: the +// whole-body replace already renders the backlink from the groomed record, so +// the spec path is declared exactly once, carrying both the new body and title. +func TestChangeGroomPlanTitleWithSpecBodyRevise(t *testing.T) { + req := validReviseRequest() + req.Title = "Renamed widget" + plan, opRes := groomPlanFor(t, reviseFixtureFiles(), baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + n := 0 + for _, p := range planPaths(plan) { + if p == reviseSpecPath { + n++ + } + } + if n != 1 { + t.Fatalf("spec path declared %d times, want 1: %v", n, planPaths(plan)) + } + spec := string(groomedRecordBytes(t, plan, reviseSpecPath)) + if !strings.Contains(spec, "Change 0002 — Renamed widget") || !strings.Contains(spec, "The revised design body.") { + t.Errorf("spec lacks the new title or the new body:\n%s", spec) + } +} + +// TestChangeGroomPlanTitleOnlyReviseOfTrivialChange pins Review Focus 5: a +// trivial-verdicted change links no spec, so a retitle writes only the record. +func TestChangeGroomPlanTitleOnlyReviseOfTrivialChange(t *testing.T) { + files := map[string]string{groomPath(2, "add-a-widget"): trivialChange(2, "add-a-widget")} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, titleOnlyReviseRequest("Renamed widget"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) +} + +func TestChangeGroomPlanTitleRestampRefusals(t *testing.T) { + rec := revisableChange(2, "add-a-widget", reviseSpecPath) + cases := []struct { + name string + files map[string]string + code string + }{ + // The re-stamp never silently inserts a block. + {"spec lacks a backlink block", map[string]string{ + groomPath(2, "add-a-widget"): rec, + reviseSpecPath: "# Design\n\nNo backlink here.\n", + }, "spec-backlink-missing"}, + // A dangling start marker fails the document parse. + {"spec backlink markers malformed", map[string]string{ + groomPath(2, "add-a-widget"): rec, + reviseSpecPath: "\n> dangling\n\n# Design\n", + }, "spec-backlink-malformed"}, + // A dangling spec link reuses the existing refusal. + {"spec file missing", map[string]string{ + groomPath(2, "add-a-widget"): rec, + }, "spec-file-missing"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + plan, opRes := groomPlanFor(t, c.files, baseGroomOp([]string{}, titleOnlyReviseRequest("Renamed widget"))) + if !opRes.Refused { + t.Fatalf("expected a refusal, got plan files %v", planPaths(plan)) + } + found := false + for _, f := range opRes.Findings { + if f.Code == c.code { + found = true + } + } + if !found { + t.Errorf("missing refusal code %q; got %v", c.code, opRes.Findings) + } + if len(plan.Files) != 0 { + t.Errorf("refused plan still carries files: %v", planPaths(plan)) + } + }) + } +} + +// TestChangeGroomPlanUnchangedTitleSkipsRestamp pins Review Focus 1: the +// re-stamp is keyed on the title CHANGING, so resending the current title with +// a section edit never probes the spec and never refuses a block-less spec. +func TestChangeGroomPlanUnchangedTitleSkipsRestamp(t *testing.T) { + files := reviseFixtureFiles() + files[reviseSpecPath] = "# Design\n\nNo backlink here.\n" + req := titleOnlyReviseRequest("A change") // the fixture's current title + req.Sections = []SectionEditRequest{{Heading: "## What changes", Intent: "replace", Markdown: "Narrowed what.\n"}} + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Fatalf("unchanged title refused: %v", opRes.Findings) + } + assertPlanPaths(t, plan, map[string]transaction.MutationKind{ + groomPath(2, "add-a-widget"): transaction.MutationReplace, + }) +} + +// TestChangeGroomPlanSameTitleIsNoOp pins that a title-only revise resending the +// current title over a settled tree declares nothing — the engine's clean no-op. +func TestChangeGroomPlanSameTitleIsNoOp(t *testing.T) { + files := reviseSettledFiles(t) + files["docs/changes/BOARD.md"] = "# Backlog\n\nold\n" + boardPlan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, validReviseRequest())) + if opRes.Refused { + t.Fatalf("board-settling revise refused: %v", opRes.Findings) + } + files["docs/changes/BOARD.md"] = string(groomedRecordBytes(t, boardPlan, "docs/changes/BOARD.md")) + + plan, opRes := groomPlanFor(t, files, baseGroomOp([]string{"inline"}, titleOnlyReviseRequest("A change"))) + if opRes.Refused { + t.Fatalf("unexpected refusal: %v", opRes.Findings) + } + if len(plan.Files) != 0 { + t.Errorf("same-title revise declared files %v, want an empty (no-op) plan", planPaths(plan)) + } +} From 8da4b4d296a0f87d9967d381d2724dad735c796f Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:43:27 -0400 Subject: [PATCH 05/10] fix(board): escape | and flatten line breaks in title cells (change 0461) --- internal/render/board.go | 20 ++++++++++++++++---- internal/render/board_test.go | 23 +++++++++++++++++++++++ 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/internal/render/board.go b/internal/render/board.go index be3128fbd..ce37345ab 100644 --- a/internal/render/board.go +++ b/internal/render/board.go @@ -521,7 +521,7 @@ func Board(in BoardInput) ([]byte, error) { if len(base) >= 10 { date = base[:10] } - rows = append(rows, arcRow{date: date, base: base, title: c.Title(), id: int(c.ID()), status: c.Status()}) + rows = append(rows, arcRow{date: date, base: base, title: boardTitleCell(c.Title()), id: int(c.ID()), status: c.Status()}) } // date descending, then id descending (sort -k1,1r -k2,2nr). sort.SliceStable(rows, func(i, j int) bool { @@ -610,11 +610,23 @@ func writeRepairNotice(b *strings.Builder, preamble string, entries []BoardUnren } } +// boardCellReplacer flattens line breaks to spaces and escapes a literal pipe, +// so an authored string can never split or end a Markdown table row. Both the +// repair notice and every title cell use it. +var boardCellReplacer = strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", "|", "\\|") + // boardRepairCell flattens a reason into one Markdown table cell: line breaks // become spaces and a literal pipe is escaped so it cannot split the row. func boardRepairCell(reason string) string { - r := strings.NewReplacer("\r\n", " ", "\n", " ", "\r", " ", "|", "\\|") - return strings.TrimSpace(r.Replace(reason)) + return strings.TrimSpace(boardCellReplacer.Replace(reason)) +} + +// boardTitleCell renders a change title as one Markdown table cell (change +// 0461): a "|" is escaped and a line break flattened. Titles written by +// change.create or change.groom are already single-line; the flattening +// defends legacy records. No trimming, so a clean title is byte-identical. +func boardTitleCell(title string) string { + return boardCellReplacer.Replace(title) } // boardSectionRow renders one active change's table row for its rendered @@ -624,7 +636,7 @@ func boardRepairCell(reason string) string { func boardSectionRow(in BoardInput, s BoardSection, c domain.Change) (string, error) { id := int(c.ID()) base := path.Base(c.Path()) - title := c.Title() + title := boardTitleCell(c.Title()) priority := c.RawPriority() ctype := boardTypeCell(c) diff --git a/internal/render/board_test.go b/internal/render/board_test.go index 134d38ca4..eef36f0c2 100644 --- a/internal/render/board_test.go +++ b/internal/render/board_test.go @@ -1297,3 +1297,26 @@ func TestBoardCountsExcludeUnrenderable(t *testing.T) { t.Errorf("unrenderable record leaked into the mermaid graph:\n%s", out) } } + +// TestBoardTitleCellsEscapePipesAndFlattenBreaks pins change 0461's board fix: a +// title is one table cell in every section and in the archive footer, so a +// literal "|" is escaped and a legacy line break (a record predating the title +// validator) is flattened — neither can split or end the row. +func TestBoardTitleCellsEscapePipesAndFlattenBreaks(t *testing.T) { + active := domain.NewChange(proposedChange(1, "pipe", "Keep a | b apart")) + legacy := domain.NewChange(proposedChange(2, "multi", "first\nsecond")) + arch := archivedDone(3, "2026-08-31", "arch", "Old | title") + + out := string(boardFrom(t, active, legacy, arch)) + + for _, want := range []string{"| Keep a \\| b apart |", "| first second |", "| Old \\| title |"} { + if !strings.Contains(out, want) { + t.Errorf("board missing escaped cell %q:\n%s", want, out) + } + } + for _, bad := range []string{"Keep a | b", "Old | title", "first\nsecond"} { + if strings.Contains(out, bad) { + t.Errorf("board carries unescaped title %q:\n%s", bad, out) + } + } +} From 3f49efc601d686f30f59b8bf42d405a33f4710b5 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 16:45:30 -0400 Subject: [PATCH 06/10] docs(skills): groom-next passes title when a groom renames the change (change 0461) --- docs/reference/glossary.md | 3 ++- internal/assets/embedded/manifest.json | 6 +++--- .../assets/embedded/tree/skills/docket-groom-next/SKILL.md | 2 ++ internal/repoguard/budgets_test.go | 2 +- internal/repoguard/prose_contracts_test.go | 4 ++++ skills/docket-groom-next/SKILL.md | 2 ++ 6 files changed, 14 insertions(+), 5 deletions(-) diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 224173d11..b52f8ec7d 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -692,7 +692,8 @@ The `change.groom` outcome that adjusts a change that is already groomed (`propo **Used for:** fixing a just-landed design. Reach it by naming the id to `docket-groom-next`. A spec replace also needs `spec_version` (the spec's blob id), so a concurrent spec edit contends instead of -being overwritten. +being overwritten. A `title` alone is also a valid revise: it rewrites `title:`, the board row, and +the spec's backlink line, and renames nothing — the slug and every path stay put. ```sh # groom.json: {"change_id": 412, "path": "…", "version": "", "outcome": "revise", "spec_markdown": "…", "spec_version": ""} diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 2913fc4a0..5b2ab5fdb 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:27f68e20e117b2fed928af4bacd062f0ee2ee752e6e59196ab0c4e3b2af50e3c", + "asset_set_id": "sha256:3a325b5f786a92434c2e61b4869196374caf79aa6a2bc0abd7306eceab4002ea", "entries": [ { "path": ".docket.example.yml", @@ -399,8 +399,8 @@ "path": "skills/docket-groom-next/SKILL.md", "role": "skill", "mode": 420, - "size": 13628, - "sha256": "fd6785c3d9f88f22fbf81fe271b8f4af338ed7992de2991978ece9513231132d" + "size": 14240, + "sha256": "a64e3de60d119d0e8003e8e038f86a2928e60a4f2f4720f6af57d858b1725e37" }, { "path": "skills/docket-implement-next/SKILL.md", diff --git a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md index d3b78ac7f..baff25057 100644 --- a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md @@ -67,6 +67,8 @@ All six exits reuse existing transitions — this skill introduces no new lifecy 5. **Revise** (explicit-id route only): the change is already groomed and the human wants it adjusted — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `version`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_version`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:` or `trivial:`, so a change cannot flip between spec'd and trivial here. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked`, `spec-file-missing`, `empty-revise`, `empty-spec_version`, version mismatch) writes nothing — surface it. The change stays build-ready. 6. **Re-arm** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: rearm`, the pinned `path` + `version`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-rearm` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-arm. +**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. + ### Step 5 — The transaction lands (no separate board pass) The Step-4 typed op is the whole write: it re-checks the pinned exact `version`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to `origin/docket` under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-brainstorm, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row build-ready; a re-arm returns an abstained row to needs-brainstorm). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `version` from the `status` operation, and — if it is no longer needs-brainstorm (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_version`) and stop only if the change is no longer an already-groomed `proposed` change. STOP — grooming never implements. diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 72db4c099..9d0dadb7d 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,7 +196,7 @@ var skillBudgets = []skillBudget{ {"docket-convention/references/terminal-close-out.md", 240, 2150}, {"docket-finalize-change/SKILL.md", 239, 5647}, // 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 147, 1901}, // 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) - {"docket-groom-next/SKILL.md", 77, 1996}, // 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) + {"docket-groom-next/SKILL.md", 78, 2081}, // 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) {"docket-implement-next/SKILL.md", 214, 8080}, // 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +gate-epoch resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose {"docket-implement-next/references/edge-paths.md", 118, 1554}, // 0410: +resume/recovery + required-results reconciliation; 0375: +gate-epoch resume refusals (78/1091 -> 93/1261); 0448: +named own-dependency closeout moved from SKILL.md (93/1261 -> 118/1554) {"docket-implement-next/references/fix-loop.md", 190, 1958}, // 0410: +findings-to-results checkpoint linkage (see note above) diff --git a/internal/repoguard/prose_contracts_test.go b/internal/repoguard/prose_contracts_test.go index 277fa0e0c..5fd99c00d 100644 --- a/internal/repoguard/prose_contracts_test.go +++ b/internal/repoguard/prose_contracts_test.go @@ -230,6 +230,10 @@ var proseContracts = []proseContract{ absent: []string{"flips the flag back to `true`, and DELETES"}}, {sentinel: "change_0382_typed_abstain", file: "skills/docket-groom-next/SKILL.md", present: []string{"`outcome: rearm`", "`nothing-to-rearm`"}}, + // change 0461 — change.groom carries an optional title; a retitle renames + // nothing (slug, record path, spec path, and branch stay put). + {sentinel: "change_0461_retitle", file: "skills/docket-groom-next/SKILL.md", + present: []string{"also carry `title`", "a `title` alone is a valid revise", "a retitle renames nothing"}}, // change 0389 — implementation-scope sweep + the two completion barriers. // docket-status owns the COMMAND barrier: a backgrounded sweep is observed // to its terminal envelope, never declared done by proxy signals; and an diff --git a/skills/docket-groom-next/SKILL.md b/skills/docket-groom-next/SKILL.md index d3b78ac7f..baff25057 100644 --- a/skills/docket-groom-next/SKILL.md +++ b/skills/docket-groom-next/SKILL.md @@ -67,6 +67,8 @@ All six exits reuse existing transitions — this skill introduces no new lifecy 5. **Revise** (explicit-id route only): the change is already groomed and the human wants it adjusted — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `version`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_version`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:` or `trivial:`, so a change cannot flip between spec'd and trivial here. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked`, `spec-file-missing`, `empty-revise`, `empty-spec_version`, version mismatch) writes nothing — surface it. The change stays build-ready. 6. **Re-arm** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: rearm`, the pinned `path` + `version`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-rearm` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-arm. +**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. + ### Step 5 — The transaction lands (no separate board pass) The Step-4 typed op is the whole write: it re-checks the pinned exact `version`, commits the change record, the spec, the `## Artifacts` block, and the inline board in one metadata commit pushed to `origin/docket` under an exact-lease push — so there is no separate hand-staged commit and **no separate Board pass** (the readiness cell flips from needs-brainstorm, or the row leaves the Proposed section on a kill or defer, in that same commit; a revise keeps the row build-ready; a re-arm returns an abstained row to needs-brainstorm). On a `contended` refusal the op writes nothing: re-sync (re-run the `repository.prepare` operation), re-read the record's `path` + `version` from the `status` operation, and — if it is no longer needs-brainstorm (someone else groomed, killed, or claimed it) — STOP and report rather than overwrite; otherwise re-author and retry. On a revise, also re-read the spec (a fresh `spec_version`) and stop only if the change is no longer an already-groomed `proposed` change. STOP — grooming never implements. From b0571693bf6b265eeee028fa28ad70149e0cbd68 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:00:26 -0400 Subject: [PATCH 07/10] fix(groom): refuse a retitle of a change carrying feature-branch artifacts (change 0461, review fix-1) Finding (important): changeGroomOp.Plan accepted a title on a proposed change that still carries branch:/plan:/results: (a record deferred from in-progress and later revived keeps them). Those artifacts' backlinks embed the title and are identity-checked downstream (backlinkTargets -> backlink-mismatch on attach; verifyImplementedResults -> results-identity-broken on mark-implemented), so the retitle silently broke the later build. Plan now refuses such a request with the new typed finding code not-retitleable (FCNotRetitleable, registered in AllFindingCodes in sorted order) and writes nothing. A title equal to the current one is no retitle and still passes. Mutation-tested: dropping the guard, dropping its title-equality clause, and dropping the registry entry each redden a test. --- internal/app/change_groom.go | 13 +++++++++ internal/app/change_groom_test.go | 44 +++++++++++++++++++++++++++++++ internal/app/finding_codes.go | 6 +++++ 3 files changed, 63 insertions(+) diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index 69afd1837..c872f5e2d 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -564,6 +564,19 @@ func (o changeGroomOp) Plan(ctx context.Context, st transaction.AttemptState) (t o.req.ChangeID, c.Status(), c.Spec().Value, c.Trivial())) } + // 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 + // 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() && + (c.Branch().Value != "" || c.Plan().Value != "" || c.Results().Value != "") { + return refuseGroom(string(FCNotRetitleable), + fmt.Sprintf("change %04d carries feature-branch artifacts (branch %q, plan %q, results %q) whose backlinks embed the title; it cannot be retitled", + o.req.ChangeID, c.Branch().Value, c.Plan().Value, c.Results().Value)) + } + // Revise spec-body decision, resolved before any mutation is assembled: a // non-empty SpecMarkdown replaces the change's EXISTING linked spec — never // a new path — and requires both the link and the file to exist. diff --git a/internal/app/change_groom_test.go b/internal/app/change_groom_test.go index 0bfc907c3..58a4d7b65 100644 --- a/internal/app/change_groom_test.go +++ b/internal/app/change_groom_test.go @@ -1543,6 +1543,50 @@ func TestChangeGroomPlanTitleOnlyReviseOfTrivialChange(t *testing.T) { }) } +// revivedReviseFiles is the revise fixture for a change deferred out of +// in-progress and revived to proposed: it is still an already-groomed proposed +// change, but it keeps its feature-branch artifacts (branch:, plan:), whose +// backlinks embed the title and are identity-checked downstream. +func revivedReviseFiles() map[string]string { + const planPath = "docs/superpowers/plans/2026-08-10-add-a-widget.md" + files := reviseFixtureFiles() + src := groomPath(2, "add-a-widget") + rec := strings.Replace(files[src], "status: proposed\n", "status: proposed\nbranch: 'feat/add-a-widget'\n", 1) + files[src] = strings.Replace(rec, "plan:\n", "plan: '"+planPath+"'\n", 1) + files[planPath] = "# Plan\n" + return files +} + +// 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 +// 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"))) + if !opRes.Refused { + t.Fatalf("retitle of a change with feature-branch artifacts planned: %v", planPaths(plan)) + } + found := false + for _, f := range opRes.Findings { + found = found || f.Code == string(FCNotRetitleable) + } + if !found { + t.Errorf("missing not-retitleable; got %v", opRes.Findings) + } + if len(plan.Files) != 0 { + t.Errorf("refused plan still carries files: %v", planPaths(plan)) + } + + req := validReviseRequest() + req.Title = "A change" // the fixture's current title: no retitle + _, opRes = groomPlanFor(t, revivedReviseFiles(), baseGroomOp([]string{}, req)) + if opRes.Refused { + t.Errorf("unchanged title on a record with feature-branch artifacts refused: %v", opRes.Findings) + } +} + func TestChangeGroomPlanTitleRestampRefusals(t *testing.T) { rec := revisableChange(2, "add-a-widget", reviseSpecPath) cases := []struct { diff --git a/internal/app/finding_codes.go b/internal/app/finding_codes.go index 3216f2a4e..bcc2c86e9 100644 --- a/internal/app/finding_codes.go +++ b/internal/app/finding_codes.go @@ -43,6 +43,11 @@ const ( FCPathMismatch FindingCode = "path-mismatch" FCSectionEditFailed FindingCode = "section-edit-failed" + // FCNotRetitleable refuses a change-groom title that would rename a record + // still carrying feature-branch artifacts (branch:, plan:, results:), whose + // title-bearing backlinks are identity-checked downstream (change 0461). + FCNotRetitleable FindingCode = "not-retitleable" + // change create request-shape findings. FCInvalidSlug FindingCode = "invalid-slug" FCUnknownType FindingCode = "unknown-type" @@ -277,6 +282,7 @@ var AllFindingCodes = []FindingCode{ FindingCode("missing-claim-stamp"), FCMissingRationale, FCNotFound, + FCNotRetitleable, FCNothingToRearm, FCParseFailed, FCPathMismatch, From a9408a6c15cfa481a9c9aced655246dfbe25e740 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:01:29 -0400 Subject: [PATCH 08/10] fix(groom): name the spec-body revise remedy for a missing backlink; escape U+2028/U+2029 test literals (change 0461, review fix) - Minor 1: spec-backlink-missing message named `artifact backlink`, which stamps a feature-worktree file; the remedy is a spec-body revise with spec_markdown + spec_version + title. - Minor 2: TestValidateTitle separator cases used raw invisible characters; use escaped Go literals. --- internal/app/change_groom.go | 2 +- internal/app/change_title_test.go | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/app/change_groom.go b/internal/app/change_groom.go index c872f5e2d..4def52e6a 100644 --- a/internal/app/change_groom.go +++ b/internal/app/change_groom.go @@ -903,7 +903,7 @@ func restampSpecBacklink(ctx context.Context, tree transaction.Tree, specPath st } if _, ok := doc.Block(backlinkBlockName); !ok { return nil, false, "spec-backlink-missing", - fmt.Sprintf("spec %q has no docket:backlink block to re-stamp with the new title; restore it with `artifact backlink` first", specPath), nil + 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_version and title together) to rebuild the backlink block in one transaction", specPath), nil } block, err := render.BacklinkContent(gc, link) if err != nil { diff --git a/internal/app/change_title_test.go b/internal/app/change_title_test.go index 6eaea7832..4ed62edca 100644 --- a/internal/app/change_title_test.go +++ b/internal/app/change_title_test.go @@ -23,8 +23,8 @@ func TestValidateTitle(t *testing.T) { {"nul", "a\x00b", FCInvalidTitle}, {"bell", "a\x07b", FCInvalidTitle}, {"next line (C1)", "a\u0085b", FCInvalidTitle}, - {"line separator", "a
b", FCInvalidTitle}, - {"paragraph separator", "a
b", FCInvalidTitle}, + {"line separator", "a\u2028b", FCInvalidTitle}, + {"paragraph separator", "a\u2029b", FCInvalidTitle}, {"invalid utf-8", "a\xffb", FCInvalidTitle}, } for _, c := range cases { From ee1ade94e17509ed99ca403b2de3d2f3a12aa02a Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:02:07 -0400 Subject: [PATCH 09/10] docs(results): change 0461 results --- ...ting-an-existing-change-s-title-results.md | 60 +++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/results/2026-09-28-allow-editing-an-existing-change-s-title-results.md diff --git a/docs/results/2026-09-28-allow-editing-an-existing-change-s-title-results.md b/docs/results/2026-09-28-allow-editing-an-existing-change-s-title-results.md new file mode 100644 index 000000000..1bc69b237 --- /dev/null +++ b/docs/results/2026-09-28-allow-editing-an-existing-change-s-title-results.md @@ -0,0 +1,60 @@ + +> ↩ **[Change 0461 — Allow editing an existing change's title](https://github.com/danielhanold/docket/blob/docket/docs/changes/active/0461-allow-editing-an-existing-change-s-title.md)** + +# Allow editing an existing change's title — Results + +**Human action:** None required to merge. One optional walkthrough is below if you want to see a retitle end to end. + +## Outcome + +Before this change, nothing could rename a change after it was created. The only route was hand-editing the frontmatter and then running `docket repository migrate` to repair the board. + +`change.groom` now takes an optional `title` field. It works with the `spec`, `trivial`, `revise`, and `rearm` outcomes. A request whose only content is a new `title` is now a valid `revise`. When the title changes, one metadata commit does all of the following: + +- rewrites `title:` and `updated:` through the writer, so YAML quoting stays correct; +- re-renders the change's `## Artifacts` block and the board; +- re-stamps the `docket:backlink` line at the top of the linked spec. Nothing else in the spec file changes. + +A retitle never touches the slug, the file name, the spec path, or `branch:`. + +Refusals: + +- `abstain` with a `title` is refused as `invalid-title`. +- A title containing a line break or a control character is refused as `invalid-title`. This check is shared by `change.create` and `change.groom`, and a whitespace-only title is still `empty-title`. +- A linked spec that has lost its backlink block is refused as `spec-backlink-missing` or `spec-backlink-malformed`. +- A `proposed` change that still has feature-branch artifacts (`branch:`, `plan:`, or `results:`) cannot be retitled and is refused as `not-retitleable`. This case arises when a change is deferred from in-progress and then revived. It was added after review, because the backlinks on those artifacts contain the old title and later identity checks would fail. + +Board title cells now escape `|` and turn line breaks into spaces. That also fixes a table-corruption gap that existing titles could trigger. The `docket-groom-next` skill now tells groomers to pass `title` when a groom renames a change. + +## Human actions and testing + +### Optional — retitle a proposed change end to end + +This lets you see the single-commit retitle on real metadata. The automated tests use an in-memory tree. + +Prerequisites: a docket binary built from this branch, and a scratch `proposed` change that has a spec and has never been claimed. + +1. Write a request file containing `{"id": , "version": "", "outcome": "revise", "title": "New title"}`, then run `docket change groom --input --json`. + Expected: `result: applied`, and exactly one new commit on `docket`. +2. Run `git -C .docket show --stat HEAD`. + Expected: the commit touches the change file, `BOARD.md`, and the spec. In the spec, only the backlink line changed. The change file name keeps its old slug. + +Cleanup: retitle the change back the same way, or kill the scratch change. + +## Verification performed + +- Full suite (`go run ./cmd/docket development test`) passed at the build head, 66 of 66 files. The final certification run on the post-review head is recorded in the PR's build-evidence block. +- Each task was test-driven and mutation-tested by its worker: removing the re-stamp, the title upsert, the guards, or the board escaping made its test fail. +- A deep whole-branch review returned three findings, all fixed in this branch: + +| Finding | Severity | Disposition | +|---|---|---| +| Retitle accepted on a revived change that still has plan/results backlinks | important | fixed in b0571693b | +| `spec-backlink-missing` message suggested the wrong remedy | minor | fixed in a9408a6c1 | +| Invisible U+2028/U+2029 characters in test literals | minor | fixed in a9408a6c1 | + +## Known issues and follow-ups + +- The `docket-groom-next` skill text lists some groom refusal codes but not the new `not-retitleable`. A groomer who hits it gets the typed message, but the skill doesn't mention it. Impact is minor and confirmed. Suggested next action: add it the next time that skill is edited. +- Task 5 raised the `docket-groom-next` SKILL.md size budget in `internal/repoguard/budgets_test.go` to the new size (78 lines / 2081 words), following the precedent of 0382 and 0445. Please glance at it when reviewing. +- The suite printed `BUDGET WATCH` / `PARALLEL-SENSITIVE` lines for tests this change doesn't touch (race, toolchain, several integration shards). They were not serially confirmed and look like existing parallel-load noise. From b0ee6bf2385d7b0bb26459be066b142863891e44 Mon Sep 17 00:00:00 2001 From: Daniel Hanold Date: Mon, 28 Sep 2026 17:14:55 -0400 Subject: [PATCH 10/10] docs(groom-next): list the not-retitleable refusal code (change 0461, post-review) --- internal/assets/embedded/manifest.json | 6 +++--- .../assets/embedded/tree/skills/docket-groom-next/SKILL.md | 2 +- internal/repoguard/budgets_test.go | 2 +- internal/repoguard/prose_contracts_test.go | 2 +- skills/docket-groom-next/SKILL.md | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/internal/assets/embedded/manifest.json b/internal/assets/embedded/manifest.json index 5b2ab5fdb..1941e3cbd 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:3a325b5f786a92434c2e61b4869196374caf79aa6a2bc0abd7306eceab4002ea", + "asset_set_id": "sha256:cae80b0daeff8b3a310465fc28da7de6edd5b8c09df9925f829a425801145278", "entries": [ { "path": ".docket.example.yml", @@ -399,8 +399,8 @@ "path": "skills/docket-groom-next/SKILL.md", "role": "skill", "mode": 420, - "size": 14240, - "sha256": "a64e3de60d119d0e8003e8e038f86a2928e60a4f2f4720f6af57d858b1725e37" + "size": 14259, + "sha256": "8cdb849cec789031bf67cc674529e066c570495b2afc2d195398f59fdfee5b67" }, { "path": "skills/docket-implement-next/SKILL.md", diff --git a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md index baff25057..34ac25893 100644 --- a/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md +++ b/internal/assets/embedded/tree/skills/docket-groom-next/SKILL.md @@ -67,7 +67,7 @@ All six exits reuse existing transitions — this skill introduces no new lifecy 5. **Revise** (explicit-id route only): the change is already groomed and the human wants it adjusted — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `version`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_version`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:` or `trivial:`, so a change cannot flip between spec'd and trivial here. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked`, `spec-file-missing`, `empty-revise`, `empty-spec_version`, version mismatch) writes nothing — surface it. The change stays build-ready. 6. **Re-arm** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: rearm`, the pinned `path` + `version`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-rearm` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-arm. -**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. +**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `not-retitleable`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. ### Step 5 — The transaction lands (no separate board pass) diff --git a/internal/repoguard/budgets_test.go b/internal/repoguard/budgets_test.go index 9d0dadb7d..d7465a1d5 100644 --- a/internal/repoguard/budgets_test.go +++ b/internal/repoguard/budgets_test.go @@ -196,7 +196,7 @@ var skillBudgets = []skillBudget{ {"docket-convention/references/terminal-close-out.md", 240, 2150}, {"docket-finalize-change/SKILL.md", 239, 5647}, // 0455: +record-invalid refusal (structural scope, findings remedy, merged-outside-docket precedence) in step 8 (word ceiling 5520 -> 5647); 0442: +post-publication base-advance guidance (word ceiling 5421 -> 5520); de-duplicated the shared forward-rebase mechanic against the 0438 unpublished-case paragraph (reclaimed 57 words), but the distinct published-refresh facts plus the retained 0438 guidance cannot fit the old ceiling without deleting required guidance; 0411: +reconciliation-write recovery exception paragraph in the resolver loop (ceilings 238/5232 -> 239/5421); 0413: +generated-bundle mixed-conflict handoff sentence in the resolver-loop block (word ceiling 5200 -> 5232); 0419: +repair-attempt budget payload line and rewired repair contract (line ceiling 236 -> 238); 0393: +exact payload, marker, and direct-dispatch lines atop 0349/0410 (see note above) {"docket-finalize-change/references/gate-failure.md", 147, 1901}, // 0411: +reconciliation-write exception section and abort-set carve-out (ceilings 135/1472 -> 147/1901); 0413: +conflicted_paths-lists-authored-only rule in the resolver-report section (line ceiling 133 -> 135, word ceiling 1465 -> 1472); 0419: +repair-attempt budget payload and rewired repair contract prose (word ceiling 1450 -> 1465); 0349: +reserve-before-dispatch resolver protocol prose; 0375: +worktree-slot note for the scopeless finalize gate (120/1300 -> 133/1450) - {"docket-groom-next/SKILL.md", 78, 2081}, // 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) + {"docket-groom-next/SKILL.md", 78, 2082}, // 0461: +Step-4 retitle paragraph (title on change.groom; lines 77 -> 78, words 1996 -> 2081); +not-retitleable refusal code (2081 -> 2082); 0382: +typed rearm exit (word ceiling 1889 -> 1996); 0445: +revise route for already-groomed explicit ids (Step 1) and the fifth Step-4 exit (word ceiling 1650 -> 1813); +spec_version pin for a spec-body revise (1813 -> 1849); +revise spec_markdown excludes the backlink block (1849 -> 1850); +revise in the description and the revise contended/board clauses (1850 -> 1889) {"docket-implement-next/SKILL.md", 214, 8080}, // 0455: +pr.publish record-invalid refusal clause (word ceiling 8025 -> 8080); 0448: +named-invocation branch; bounded own-dependency closeout moved to edge-paths.md (ceilings 210/7716 -> 214/8270 -> 214/8025); 0393: +exact payload, marker, and direct-dispatch lines atop 0410/0354/0376; 0375: +gate-epoch resume pointer (word ceiling 7530 -> 7547); 0440: reader-first results prose {"docket-implement-next/references/edge-paths.md", 118, 1554}, // 0410: +resume/recovery + required-results reconciliation; 0375: +gate-epoch resume refusals (78/1091 -> 93/1261); 0448: +named own-dependency closeout moved from SKILL.md (93/1261 -> 118/1554) {"docket-implement-next/references/fix-loop.md", 190, 1958}, // 0410: +findings-to-results checkpoint linkage (see note above) diff --git a/internal/repoguard/prose_contracts_test.go b/internal/repoguard/prose_contracts_test.go index 5fd99c00d..3d986141a 100644 --- a/internal/repoguard/prose_contracts_test.go +++ b/internal/repoguard/prose_contracts_test.go @@ -233,7 +233,7 @@ var proseContracts = []proseContract{ // change 0461 — change.groom carries an optional title; a retitle renames // nothing (slug, record path, spec path, and branch stay put). {sentinel: "change_0461_retitle", file: "skills/docket-groom-next/SKILL.md", - present: []string{"also carry `title`", "a `title` alone is a valid revise", "a retitle renames nothing"}}, + present: []string{"also carry `title`", "a `title` alone is a valid revise", "a retitle renames nothing", "`not-retitleable`"}}, // change 0389 — implementation-scope sweep + the two completion barriers. // docket-status owns the COMMAND barrier: a backgrounded sweep is observed // to its terminal envelope, never declared done by proxy signals; and an diff --git a/skills/docket-groom-next/SKILL.md b/skills/docket-groom-next/SKILL.md index baff25057..34ac25893 100644 --- a/skills/docket-groom-next/SKILL.md +++ b/skills/docket-groom-next/SKILL.md @@ -67,7 +67,7 @@ All six exits reuse existing transitions — this skill introduces no new lifecy 5. **Revise** (explicit-id route only): the change is already groomed and the human wants it adjusted — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: revise`, the pinned `path` + `version`, and whichever of `spec_markdown` (a whole-body replace of the *existing* linked spec, minus its `docket:backlink` block, which the transaction re-stamps; it never mints a new path) and owned-section `sections` edits the adjustment touched, plus any relationship-field updates. With `spec_markdown`, also send `spec_version`, the linked spec's blob object id read by `git -C .docket rev-parse HEAD:` (`` is the record's `spec:` value) — a concurrent spec edit then contends instead of being overwritten. It never sets `spec:` or `trivial:`, so a change cannot flip between spec'd and trivial here. Repeatable while the change stays `proposed`. A typed refusal (`not-revisable`, `spec-not-linked`, `spec-file-missing`, `empty-revise`, `empty-spec_version`, version mismatch) writes nothing — surface it. The change stays build-ready. 6. **Re-arm** (abstained or opted-out stubs): the human supplied the context an abstain asked for and wants `docket-auto-groom` to take the stub — apply the `change.groom` operation with `--repo-dir .docket --request ` with `outcome: rearm`, the pinned `path` + `version`, and any owned-section `sections` edits carrying the new context. The transaction sets `auto_groomable: true`, removes the `## Auto-groom blocked` section, and re-renders the inline board atomically, returning the stub to the autonomous queue. A `nothing-to-rearm` refusal (no blocked section, already `true`) writes nothing. A spec or trivial groom of an abstained stub needs no re-arm. -**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. +**Retitle.** When the settled design renamed the change, exits 1 (spec), 2 (trivial), 5 (revise), and 6 (re-arm) also carry `title` — the new title — in the same `change.groom` request. The transaction rewrites `title:` through the writer, re-renders the board and the `## Artifacts` block, and re-stamps the linked spec's `docket:backlink` line; a `title` alone is a valid revise. The slug, record filename, spec path, and branch never change — a retitle renames nothing. A typed refusal (`invalid-title`, `empty-title`, `not-retitleable`, `spec-backlink-missing`, `spec-backlink-malformed`) writes nothing — surface it. ### Step 5 — The transaction lands (no separate board pass)