Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
---
id: itd-80
slug: intent-lifecycle-automation
kind: null
suggested_kind: standalone
bundle: null
spec_id: null
reclassification_history: []
builds_on: [itd-34, itd-1]
related_adrs: [adr-25, adr-26, adr-27]
severity: major
---

# An Intent Ships Itself: `planned → shipped` Follows Its Spec Closing, With A Fidelity Audit Attached

## Press Release

> **abcd ships intent-lifecycle automation: an intent moves from `planned/` to `shipped/` as a side-effect of its linked spec closing, and arrives carrying a fidelity audit.** The developer runs `abcd intent plan itd-N` to link an intent to a freshly-minted native spec (`spc-M`) and commit it to `planned/`. When the work is done and they run `abcd spec close spc-M`, a deterministic reconcile detects the change through the intent↔spec link and moves the intent to `shipped/` — no `move` verb, no hand-editing, because the directory *is* the lifecycle state. The move emits a host-delegated fidelity-review request over the intent's Acceptance Criteria and the delivered code; the host's verdict is ingested back into the intent's `## Audit Notes` as per-criterion verdicts (MET / MET_WITH_CONCERNS / NOT_MET / INCONCLUSIVE) plus a three-bucket prose audit (honoured / diverged / missing), every claim carrying a cited `file:line` evidence pointer.
>
> "I stopped babysitting the roadmap," said Maya, an autonomous-development practitioner. "I plan an intent, I close its spec, and the intent ships itself — and the audit that lands with it tells me, criterion by criterion with evidence, whether what I built actually matches what I promised. The lifecycle stopped being a thing I maintained by hand and became a thing that maintains itself."

## Why This Matters

Until now, an intent's journey through `drafts → planned → shipped` was a set of manual file moves and hand-written audits — the very kind of undisciplined, drift-prone bookkeeping abcd exists to eliminate. itd-3 was driven through the lifecycle entirely by hand (its `## Audit Notes` were authored manually) precisely because no machinery existed. That manual precedent is the reference the automation must reproduce.

This intent builds the automation. Directory location stays the single source of truth (adr-26 directory-as-truth): there is no `status:` field to drift and no `abcd intent move` to invite manual state-setting. The lifecycle advances only as a *consequence* of a real event — a spec closing — detected through an ID-anchored link that survives file moves. Because abcd cannot run an LLM itself (adr-25 host-delegated default), the fidelity review is asynchronous: abcd emits a request and parks a receipt; the host runs the reviewer; the verdict is ingested through a validated transport. The result is a lifecycle that advances itself and a shipped record that carries its own evidence.

## What's In Scope

- **A minimal native spec store** (`internal/core/spec`): directory-as-truth spec records, `spc-N` id minting that does not collide with reserved ids, an ID-anchored bidirectional link to an intent (`intent: itd-N`), and status-by-directory (open vs closed).
- **`abcd intent` verb family**: bare `abcd intent` renders lifecycle status (never mutates); `abcd intent plan <itd-N>` mints a spec, writes the bidirectional link, sets `kind` (default `standalone`), and moves the intent `drafts → planned`; `abcd intent link <itd-N> <spc-N>` retroactively links a pre-existing spec; `abcd intent review ingest` ingests a verdict.
- **`abcd spec` verb family**: bare render; `abcd spec close <spc-N>` marks a spec done and runs the reconcile.
- **A deterministic reconcile**: on spec close, detect the linked intent via the immutable link, move it `planned → shipped` with an atomic same-filesystem rename, and repair any drifted derived `spec_id`. Fail closed on a missing or ambiguous link — never a silent or partial move. Keeps the `intent_lifecycle` record-lint contract green (the `shipped/` `spec_id ^spc-` requirement satisfied, not relaxed).
- **A host-delegated `intent-fidelity-reviewer` markdown agent** (new `agents/` directory): a single-document (Role 1) fidelity reviewer that reads an intent's Acceptance Criteria + the delivered diff and returns a VSA-shaped verdict JSON with per-criterion verdicts and a honoured/diverged/missing audit, each entry citing evidence.
- **An async verdict-ingest transport**: emit a review-request + park an `OWED` receipt at the ship-move; ingest schema-gates the verdict, semantically checks it (every `criterion_id` exists in the intent, every verdict in-enum), and appends idempotently (keyed on the receipt id) to `## Audit Notes`. A malformed/absent verdict after retry → receipt `DEAD_LETTER`, affected criteria recorded `INCONCLUSIVE`, raw payload retained.
- **Dogfooding**: this intent (itd-80) is the pipeline's first real payload — it is driven `drafts → planned → shipped` through the machinery it specifies, and its `## Audit Notes` are produced by the automation, not by hand.

## What's Out of Scope

- **The inter-spec dependency graph** and richer spec-store features (adr-26's full vision) — the store here is the minimal floor: link, status-by-directory, reconcile.
- **Bundle-member and discipline lifecycles** (itd-34) — slice 1 handles `standalone` only.
- **Reviewer Roles 2 and 3** (cross-document consistency, shape-classification; itd-48) and **loop-to-acceptance** (itd-50) — the reviewer is Role 1, one-shot, report-only.
- **Multi-judge juries / calibration harness** — a single host verdict; a calibration corpus is added only once real verdicts exist.
- **Auto-generation or auto-planning of the spec body** — `plan` mints the spec record and link; authoring the spec's content is not automated here.
- **A vendor/event "hook"** — the reconcile is a deterministic Go step inside `abcd spec close`, never a harness event.

## Acceptance Criteria

> _BDD format, per the [itd-1 discipline](../disciplines/itd-1-acceptance-gates.md). These gates are checked by `intent-fidelity-reviewer`'s single-document role when this intent moves to `shipped/` — this intent dogfoods that path._

- **Given** a `standalone` intent in `drafts/` with a valid `## Acceptance Criteria` section and `spec_id: null`, **when** the developer runs `abcd intent plan itd-N`, **then** a native spec `spc-M` is minted (with `intent: itd-N`), the intent gains `spec_id: spc-M` and a binding `kind: standalone`, the file moves `drafts → planned`, and `make record-lint` stays green.
- **Given** a planned intent linked to an open spec `spc-M`, **when** the developer runs `abcd spec close spc-M`, **then** a deterministic reconcile detects the linked intent through the immutable link and moves it `planned → shipped` with an atomic rename, satisfying the `intent_lifecycle` `shipped/` contract, and no unrelated file is modified.
- **Given** the ship-move, **when** reconcile runs, **then** exactly one VSA-shaped fidelity-review request (over the intent's Acceptance Criteria + the delivered diff) is emitted and exactly one `OWED` receipt carrying an idempotency key is parked.
- **Given** a host-produced verdict JSON that matches a parked receipt, **when** the developer runs `abcd intent review ingest`, **then** the payload is schema-gated and semantically checked (every `criterion_id` exists in the intent; every verdict is one of MET / MET_WITH_CONCERNS / NOT_MET / INCONCLUSIVE), and on success the per-criterion verdicts plus a honoured/diverged/missing audit — each entry carrying a cited `file:line` evidence pointer — are appended to the intent's `## Audit Notes`, and the receipt flips `OWED → INGESTED`.
- **Given** a verdict already ingested for a receipt, **when** ingest runs again with the same `receipt_id`, **then** it is a no-op: `## Audit Notes` is not duplicated (idempotent append keyed on the receipt id).
- **Given** a malformed/missing intent↔spec link at reconcile time, or a verdict referencing an unknown receipt at ingest time, **when** the operation runs, **then** it fails closed — no partial move, no partial append — with a clear error; and a verdict still invalid after retry marks the receipt `DEAD_LETTER`, records the affected criteria `INCONCLUSIVE`, and retains the raw payload.
- **Given** a crafted `itd-`/`spc-` id containing path-traversal or unexpected characters, **when** any verb resolves it to a file, **then** the id is validated against `^itd-[0-9]+$` / `^spc-[0-9]+$` and rejected otherwise — no file outside the intent/spec directories is read, written, or moved.

## Prior Art

- **[itd-34](../planned/itd-34-three-intent-kinds.md)** (three intent kinds) — defines the `kind` field and the standalone/bundle/discipline lifecycle paths; slice 1 implements the `standalone` path only. This intent is the lifecycle *automation* itd-34's ACs assume exists.
- **[itd-46](../planned/itd-46-abcd-intent-quoted-text-create-symmetric.md)** — the `abcd intent` create ergonomics (markdown surface); complementary, not overlapping (that is the create path; this is plan→ship→audit).
- **[itd-48](../planned/itd-48-intent-fidelity-reviewer-roles-2-3.md)** — the reviewer's Roles 2/3; this intent delivers Role 1 that they extend.
- **itd-3** (shipped, manual precedent) — its hand-authored `## Audit Notes` are the golden reference the automated audit must reproduce in shape.
- **adr-26** (native spec store — directory-as-truth), **adr-25** (host-delegated LLM default), **adr-27** (autonomous-run receipt gating) — the load-bearing decisions this intent instantiates.
- **`.abcd/development/plans/2026-07-11-intent-lifecycle.md`** — the SOTA-researched design plan this intent builds to.

## Open Questions

- **`spc-N` minting under reserved ids.** The corpus pre-references `spc-` ids in planning docs and itd-3 reserved `spc-1`. Slice 1 mints `max(spec-store files ∪ intent `spec_id`) + 1` to avoid live collisions; reconciling the store's sequential minting with the brief's aspirational spec numbering is deferred to the richer spec-store slice.
- **Where the review-request + receipt are parked.** A local ephemeral queue vs a committed slot — leaning local-ephemeral under `.abcd/.work.local/` for the request, with the receipt as the correlation record; settle at plan.

## Audit Notes

<Empty until intent moves to shipped/. intent-fidelity-reviewer populates this with per-criterion verdicts plus a three-bucket prose audit comparing delivered reality to the press release above. This intent dogfoods that automation.>
17 changes: 17 additions & 0 deletions .abcd/work/DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -192,3 +192,20 @@ parallel-agent merge contention bites.
NOT_MET; every divergence is a signed-off D1–D4 delta, the one gap is the AC6
legacy-harvest completeness. Inbound links repointed planned→shipped by hand —
the link-drift-on-move the future reconcile pass automates.
- 2026-07-11 — Intent-lifecycle slice 1 (build sign-off given): the pipeline is
**dogfooded** — itd-3 stays shipped as the reference fixture (option b), and a
new tightly-scoped intent **itd-80-intent-lifecycle-automation** (ACs = the
steel thread) is the pipeline's first real payload, driven drafts→planned→
shipped through the machinery it specifies. Slice scope: minimal native spec
store (`internal/core/spec`, directory-as-truth open/closed, `intent:` link),
`abcd intent` (plan/link/review-ingest + bare render) and `abcd spec`
(close + bare render) verbs, deterministic reconcile inside `spec close`
(no vendor event), host-delegated `intent-fidelity-reviewer` markdown agent
(Role 1 only) + async outbox/inbox verdict ingest to `## Audit Notes`.
- 2026-07-11 — `spc-N` minting rule for slice 1: `max(N over spec-store files ∪
N over every intent's spec_id) + 1`, so the first mint is spc-2 (itd-3's
reserved spc-1 is respected without a backing spec file). Reconciling the
store's sequential minting with the brief's aspirational spc-numbering is
deferred to the richer spec-store slice. Reviewer roles 2/3 (itd-48),
loop-to-acceptance (itd-50), bundle/discipline lifecycles, and the spec
dependency graph are all explicitly deferred.
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,22 @@ called out in a **Breaking** section.

### Added

- The **intent lifecycle** verbs `abcd intent` and `abcd spec` (itd-80), the
front doors onto the native intent store (`internal/core/intent`). Bare
`abcd intent` renders a read-only lifecycle summary (intent counts by bucket,
spec counts by status, and the linked intent↔spec pairs); `abcd intent plan
<itd-N>` mints a native spec for a draft intent that carries a non-empty
`## Acceptance Criteria` section (the itd-1 gate), writes both sides of the
bidirectional link (the spec's `intent: itd-N` and the intent's
`spec_id: spc-N` plus a default `kind: standalone`), and moves the intent
`drafts/ → planned/` — fail-closed, so every intermediate on-disk state stays
valid under the `intent_lifecycle` record-lint rule. `abcd intent link <itd-N>
<spc-N>` retroactively links a planned intent to an existing spec, refusing a
spec that realises a different intent. Bare `abcd spec` renders the spec-store
status; `abcd spec close <spc-N>` moves a spec `open/ → closed/` (the
lifecycle reconcile that trails a close lands in a later phase). The
frontmatter line-scanner shared by these stores now lives in
`internal/core/frontmatter`.
- The **modular rules loader** core and its `abcd rules [domain]` verb (itd-3,
phases 1 + 3). `internal/core/rules` holds binary-bundled default rule domains
(COMMITTING, DOCUMENTATION, ROADMAP, ISSUES, INTENTS, LIFEBOAT, PII, and
Expand Down
55 changes: 55 additions & 0 deletions internal/core/frontmatter/frontmatter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
// Package frontmatter is abcd's shared markdown-frontmatter line scanner. It is
// deliberately a line scanner, not a YAML parser: it reads only the top-level
// keys of the leading `---`…`---` block, first key wins, and pulls in zero
// dependencies. It exists so the native record stores (internal/core/spec,
// internal/core/intent) share ONE copy of this primitive rather than each
// keeping a private replica.
//
// It is transport-agnostic: no stdout, no os.Exit, no filesystem access — the
// caller supplies the file's lines and decides what the fields mean.
package frontmatter

import (
"regexp"
"strings"
)

// keyRe matches a top-level frontmatter key (column 0, no indentation).
var keyRe = regexp.MustCompile(`^([A-Za-z0-9_]+):(.*)$`)

// Field is a frontmatter key's value and its 1-based source line.
type Field struct {
Value string
Line int
}

// Fields returns the top-level keys of the leading frontmatter block (the block
// between the first two `---` lines). Nested keys and list items are ignored,
// and the first occurrence of a key wins. An input whose first line is not `---`
// (or is empty) yields no fields.
func Fields(lines []string) map[string]Field {
fields := map[string]Field{}
if len(lines) == 0 || strings.TrimRight(lines[0], "\r") != "---" {
return fields
}
for i := 1; i < len(lines); i++ {
line := strings.TrimRight(lines[i], "\r")
if line == "---" {
break
}
m := keyRe.FindStringSubmatch(line)
if m == nil {
continue
}
key := m[1]
if _, exists := fields[key]; !exists {
fields[key] = Field{Value: strings.TrimSpace(m[2]), Line: i + 1}
}
}
return fields
}

// IsNull treats an empty value and the YAML nulls ""/"null"/"~" as null.
func IsNull(v string) bool {
return v == "" || v == "null" || v == "~"
}
72 changes: 72 additions & 0 deletions internal/core/frontmatter/frontmatter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package frontmatter

import "testing"

func TestFieldsReadsLeadingBlock(t *testing.T) {
lines := []string{
"---",
"id: itd-9",
"slug: my-thing",
"spec_id: null",
"---",
"# Title",
"key: not-frontmatter",
}
fields := Fields(lines)
if got := fields["id"]; got.Value != "itd-9" || got.Line != 2 {
t.Fatalf("id = %+v, want {itd-9 2}", got)
}
if got := fields["slug"]; got.Value != "my-thing" || got.Line != 3 {
t.Fatalf("slug = %+v, want {my-thing 3}", got)
}
if _, ok := fields["key"]; ok {
t.Fatal("a key past the closing --- must not be read")
}
}

func TestFieldsFirstKeyWins(t *testing.T) {
lines := []string{"---", "kind: standalone", "kind: discipline", "---"}
if got := Fields(lines)["kind"]; got.Value != "standalone" || got.Line != 2 {
t.Fatalf("kind = %+v, want first-key-wins {standalone 2}", got)
}
}

func TestFieldsNoFrontmatter(t *testing.T) {
if got := Fields([]string{"# Title", "id: itd-9"}); len(got) != 0 {
t.Fatalf("no leading --- must yield no fields, got %+v", got)
}
if got := Fields(nil); len(got) != 0 {
t.Fatalf("empty input must yield no fields, got %+v", got)
}
}

func TestFieldsIgnoresNested(t *testing.T) {
lines := []string{"---", "top: v", " nested: v", "- item", "---"}
fields := Fields(lines)
if _, ok := fields["nested"]; ok {
t.Fatal("indented key must be ignored (top-level only)")
}
if got := fields["top"]; got.Value != "v" {
t.Fatalf("top = %+v, want v", got)
}
}

func TestFieldsTrimsCarriageReturn(t *testing.T) {
lines := []string{"---\r", "id: itd-9\r", "---\r"}
if got := Fields(lines)["id"]; got.Value != "itd-9" {
t.Fatalf("CRLF id = %+v, want itd-9", got)
}
}

func TestIsNull(t *testing.T) {
for _, v := range []string{"", "null", "~"} {
if !IsNull(v) {
t.Errorf("IsNull(%q) = false, want true", v)
}
}
for _, v := range []string{"itd-9", "spc-1", "standalone"} {
if IsNull(v) {
t.Errorf("IsNull(%q) = true, want false", v)
}
}
}
Loading
Loading