Development record
an-issue-graduates-into-an-intent-without-retyping-abcd-capt
Summary
Delivers abcd capture promote <iss-N> [--intent <itd-N>] --json: the native
verb for step 2 of the record walk. Default mode mints an intent draft from the
issue and stamps the issue's promoted_to (historical) in the same invocation; --intent
is the stamp-only mode that links an existing draft instead of minting. Closes
the promoted_to (historical) half of iss-245.
Scope
internal/core/capture/promote.go(new) —Promote(req PromoteRequest) (PromoteResult, error), transport-agnostic, plus its typed request/result.internal/core/intent/create.go— factor the mint path so promote can reuse it with an explicit slug and seed body (one-canonical-primitive; see Approach).internal/surface/cli/cli.go—promotesub-command insidenewCaptureCommand(cli.go:1841).- Docs sweep:
commands/capture.md(replace the "skill-orchestrated, not a binary sub-verb" promote paragraph and extend theargument-hint),.abcd/development/brief/02-constraints/04-naming.md(drop the design-target marker oncapture promote),.abcd/development/brief/04-surfaces/capture entry (promote is shipped),CHANGELOG.md(additive entry).
Out of scope: any change to resolve/resolved_by (sibling intent), any
sub-verb-table machinery, any change to the planning interview.
Approach
Core (capture.Promote). PromoteRequest{RepoRoot, IssuesRoot, ID, LinkIntent string}. Flow:
findIssue(alloc.go:348) locates the issue in any status folder — promotion is orthogonal to fix-status; the file never moves.- Read via
readWithChecksum; if the parsed issue already carriespromoted_to(historical), refuse with an error naming the existingitd-N(the CLI surfaces it as a normal verb error, mirroringresolveconflicts). - Mint mode (
LinkIntent == ""): call the factored intent-create core with slug = the issue's slug, title = the issue's first body line, impact unset (a promoted draft is "not judged yet"), and a seed body that carries the standard placeholder Press Release section plus a Why This Matters of the form "Graduated fromiss-N: <issue first line>. Read that issue record for the source observation." — a by-id pointer, never a copy of the issue body (SSOT). The minted draft's frontmatter gainspromoted_from: iss-N(historical) (string, optional field; the intent reader'sIntentstruct gainsPromotedFromparsed leniently — absent on every existing record). - Stamp (both modes): under
withLedgerLock(alloc.go:86), re-find and checksum-re-read the issue,setScalarField(content, "promoted_to", itdID)(historical) (serialize.go:114), and write back in place to the same path (atomic-rename write likecommitTransition, minus the move). In link mode theitd-Nis first verified to exist in the intent store (any bucket); unknown → structural fault, nothing written. - Ordering + residue contract: mint first, stamp second. A failure after
the mint returns an error carrying the orphan draft's path and the remedy
string naming
capture promote <iss-N> --intent <itd-N>. No cross-store lock is attempted; the ledger lock alone guards the stamp, exactly astransitiondoes.
PromoteResult{IssueID, IssueStatus, IssuePath, IntentID, IntentPath, Linked bool} — repo-relative paths, JSON-rendered by the CLI with the shared
marshaller.
Intent-create factoring. Extract the body of CreateFromText
(create.go:41) into an unexported createDraft(repoRoot string, opts draftOptions) (Intent, string, error) where draftOptions{Slug, Title, SeedBody, Impact, PromotedFrom string}; CreateFromText becomes a thin
wrapper deriving slug/title from text (deriveIntentSlug, titleLine) with
an empty PromotedFrom. Both paths share nextIntentID and
withIntentMintLock, so promote can never mint outside the id lock.
capture imports intent (cycle-free; intent imports only changelog,
recordid, spec, frontmatter, fsutil).
CLI. capture promote <iss-N> with --intent and the standard --json;
human rendering states what was minted or linked and both paths. Faults follow
the existing capture verb conventions (invalid/unknown id → non-zero exit with
diagnostic, nothing written).
Acceptance-criteria mapping
- Mint + stamp in one invocation — steps 3–4; test: promote an open issue,
assert draft exists under
drafts/with reused slug, placeholder press release, by-id pointer body,promoted_from(historical); issue frontmatter gainspromoted_to(historical) with the minted id. - Any status, folder kept — step 1; tests over
resolved/andwontfix/issues assert stamp-in-place, no move. - Already-promoted refuses — step 2; test asserts the error names the existing itd-N and no second draft appears.
- Malformed/unknown id structural fault —
findIssue+ link-mode existence check; tests assert non-zero exit, ledger and intent store byte-identical. - Mint-first residue +
--intentrepair — step 5; test simulates stamp failure (unwritable ledger) and asserts the error carries the draft path and remedy; a follow-up--intentrun completes the link. Test that link mode never mints. - Two-sided edge —
promoted_from(historical) in the draft; parse-back test via the intent reader. --jsoncontract — surface test asserting the result fields.- Docs sweep — covered by the file list in Scope;
docs-lintandrecord-lintstay clean.
Every new behaviour lands with a test watched fail before the change.
Frontmatter
| id | spc-24 |
|---|---|
| type | spec |
| lifecycle | closed |
.abcd/development/specs/closed/spc-24-an-issue-graduates-into-an-intent.md
open on GitHub ↗ · commit history ↗
References1
- itd-119An Issue Graduates Into An Intent Without Retypingimplements
mentions8
- iss-245The capture store's schema models resolution provenance it has no verb to write. ResolvedBy{Intent,Spec,Commit} (capture.go:72) is parsed on read (validate.go:275) but Resolve (workflow.go:162) writes only resolution and impact and exposes no flag to supply it, so a resolved issue can assert it was fixed in prose but cannot point at the intent, spec or commit that fixed it. PromotedTo (capture.go:92) is validated against ^itd-[0-9]+$ (validate.go:121) and documented in the issues README as 'the itd-N this issue graduated into', yet no verb writes it and no issue in the ledger carries it — so the capture-to-intent promotion the schema anticipates is unreachable, which is why the 'Which ledger?' note in commands/intent.md forces the intent-vs-issue call at the moment of lowest information with no way to revise it. Contrast the intent side, where the fidelity review binds its verdict to a sha256 receipt over the acceptance criteria: one record store, two very different evidence standards.
- iss-258capture promote's post-mint stamp-failure remedy is attached unconditionally, so a promote that loses a race against a concurrent promote of the same issue emits advice that will refuse: B mints itd-Y, fails the under-lock re-check because A stamped itd-X, and B's error still says 'complete the link with capture promote iss-N --intent itd-Y' — running that hits the already-promoted refusal, and the duplicate draft itd-Y must be deleted by hand. The wrapped error does carry the true itd-X fact. Accepted residue class in spc-24 (no cross-store lock); a smarter remedy would special-case the already-promoted stamp error and say 'delete the duplicate draft' instead.
- iss-259intent-store paths render to the terminal unsanitised in several intent verbs: intent plan prints res.Intent.Path and intent create prints the minted path without termsafe.Sanitize, and a hostile clone can commit a drafts/itd-N-<slug>.md whose filename tail carries control bytes (intentFileRe admits anything but a newline after the id), reaching the owner's terminal as escape sequences. The promote render sanitises its two paths as of spc-24; close the class once by sweeping the sibling intent renders through termsafe.Sanitize.
- iss-2609290448510918The predecessor-store qualifier sweep reaches past the six intents iss-2609261536147903 named: outside drafts/ and those six, 192 citation sites across about forty intents name a spc-N at or below spc-70 that is not the citing intent's own spec, with no '(predecessor store)' on the line. Some are live cross-references (the cold-reading family's intents cite each other's live specs), and some are the predecessor store's (itd-4, itd-6, itd-29, itd-47 and itd-49 describe pre-rebuild work in its terms), so each site needs the same reading against the live spec it collides with; the specs charter's Two spc-N Namespaces section is the rule. Found while sweeping the pattern of iss-2609261536147903 in lane drainDrift3; a census script over intents/{planned,shipped,disciplines} reproduces the count.
- itd-4Nothing You Notice Gets Lost
- itd-82`abcd drain` fixes the issues that need no decision, and hands the rest back by kind
- itd-121Type The Id, Get Your Next Move
- spc-25a-resolved-issue-points-at-what-fixed-it-abcd-capture-resolv