Skip to content
Open
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
20 changes: 18 additions & 2 deletions .abcd/development/release/surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,15 @@
{
"path": "abcd capture resolve",
"hidden": false,
"flags": []
"flags": [
{
"name": "impact",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
}
]
},
{
"path": "abcd capture wontfix",
Expand Down Expand Up @@ -391,7 +399,15 @@
{
"path": "abcd intent",
"hidden": false,
"flags": []
"flags": [
{
"name": "impact",
"shorthand": "",
"type": "string",
"required": false,
"hidden": false
}
]
},
{
"path": "abcd intent link",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
schema_version: 1
id: "iss-126"
slug: "intent-reconcile-cannot-stamp-impact"
severity: "major"
category: "inconsistency"
source: "agent-finding"
found_during: "iss-117 review (2026-07-24 run queue, burst 1)"
found_at: "internal/core/intent/lifecycle.go"
---

intent Reconcile ships planned intents into shipped/ without stamping impact, so a no-impact seed passes plan and reconcile then trips the intent_impact_valid blocker on a record produced entirely by the tool's own verbs; a plan-or-ship verb must be able to stamp impact (sibling of the iss-117 class, found by its review)
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ category: "inconsistency"
source: "agent-finding"
found_during: "itd-73 phase 1 derived versioning"
found_at: "internal/core/capture/workflow.go"
resolution: "capture resolve and intent seed now stamp a valid impact; the write paths reach the shared changelog enum so the records they mint satisfy issue_impact_valid and intent_impact_valid"
impact: fix
---

abcd capture resolve and intent seedDraft cannot set impact, so the tool's own path produces a record the new issue_impact_valid and intent_impact_valid blockers reject
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,21 @@ called out in a **Breaking** section.

## [Unreleased]

### Added

- **`abcd capture resolve` and `abcd intent "<text>"` can now stamp a product
`impact`** (iss-117). A resolved issue and a shipped intent are in the release
set, so the `issue_impact_valid` and `intent_impact_valid` record-lint blockers
require a valid `impact` on those records — but the verbs that mint them had no
way to set one, so the tool's own path produced records its own gates rejected.
`capture resolve` now takes a mandatory `--impact <additive|breaking|fix|internal>`
(there is no default: an absent or misspelled value is refused, not guessed),
and `abcd intent "<text>"` takes an optional `--impact <additive|breaking|fix>`
that is stamped onto the seeded draft and travels unchanged through planning to
`shipped/`. `internal` is rejected on an intent (a press-release-first intent is
user-facing by definition). `capture wontfix` is unchanged — a non-action ships
nothing, so `wontfix/` carries no impact.

## [0.4.0] - 2026-07-22

### Breaking
Expand Down
11 changes: 9 additions & 2 deletions commands/abcd/capture.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
---
name: capture
description: Capture issues to the structured per-repo ledger and query them, by invoking the abcd binary. Bare invocation is a read-only status render; list/resolve/wontfix act on the ledger.
argument-hint: "[text] | list --open|--resolved|--wontfix|--all | resolve <iss-N> <note> | wontfix <iss-N> <reason>"
argument-hint: "[text] | list --open|--resolved|--wontfix|--all | resolve <iss-N> <note> --impact <additive|breaking|fix|internal> | wontfix <iss-N> <reason>"
---

# `/abcd:capture` — issue ledger
Expand Down Expand Up @@ -64,13 +64,20 @@ blocked by an open dependency are demoted and annotated `[blocked-by iss-N,…]`
## Resolve / wontfix

```bash
abcd capture resolve <iss-N> "<resolution-note>" --json
abcd capture resolve <iss-N> "<resolution-note>" --impact <additive|breaking|fix|internal> --json
abcd capture wontfix <iss-N> "<reason>" --json
```

Each moves the issue out of `open/` and records the note; report the `id` and
the `from_status -> to_status` transition from the JSON.

`resolve` requires `--impact`: a resolved issue is in the release set, so it
carries the product judgement the version derivation reads (`additive`,
`breaking`, `fix`, or `internal` — plumbing invisible to users). There is no
default; an absent or misspelled impact is refused rather than guessed, so the
record always satisfies the `issue_impact_valid` gate. `wontfix` takes no impact
(a non-action ships nothing).

Promoting an issue to an intent (`/abcd:capture promote <iss-N>`) is
skill-orchestrated, not a binary sub-verb. It hands the issue body to the intent
create path — `abcd intent "<issue text>"` — which files a new draft under
Expand Down
8 changes: 7 additions & 1 deletion commands/abcd/intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,14 +27,20 @@ and the intent↔spec links. Nothing is created or moved by this invocation.
## Create a draft

```bash
abcd intent "<text>" --json
abcd intent "<text>" [--impact <additive|breaking|fix>] --json
```

Files `drafts/itd-N-<slug>.md` seeded from the text. Report the new `id` and
`path`, and tell the user the seeded Acceptance Criteria section is a
placeholder that must be replaced with real Given-When-Then bullets — via the
planning interview below — before the draft can be planned.

`--impact` is optional: a draft is "not judged yet", so an unset impact writes
no field. When you do set it, the value is validated (one of `additive`,
`breaking`, `fix` — never `internal`, since an intent is user-facing by
definition) and stamped onto the draft, where it travels unchanged through
planning to `shipped/`, which the `intent_impact_valid` gate requires.

## THE RULE: no implementation without a planned, specced intent

Before implementing ANY `itd-N` — or whenever the user asks you to "build",
Expand Down
16 changes: 14 additions & 2 deletions docs/reference/cli/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,13 @@ List issues by state (one of --open/--resolved/--wontfix/--all required)

Mark an open issue resolved (open/ -> resolved/)

**Usage:** `abcd capture resolve <iss-N> <note>`
**Usage:** `abcd capture resolve <iss-N> <note> --impact <additive|breaking|fix|internal> [flags]`

**Flags:**

```
--impact string product impact: additive|breaking|fix|internal (required)
```

#### `abcd capture wontfix`

Expand Down Expand Up @@ -282,7 +288,13 @@ Show one stored transcript's metadata and redacted body

Intent lifecycle; bare invocation is read-only status, quoted text files a draft

**Usage:** `abcd intent [text]`
**Usage:** `abcd intent [text] [flags]`

**Flags:**

```
--impact string stamp the draft's product impact: additive|breaking|fix (optional)
```

#### `abcd intent link`

Expand Down
5 changes: 5 additions & 0 deletions internal/core/capture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,11 @@ type ResolveRequest struct {
IssuesRoot string
ID string
Resolution string
// Impact is the product judgement resolved/ requires (issue_impact_valid):
// one of the shared changelog enum's values (additive|breaking|fix|internal).
// There is no default — an empty or invalid value is refused, never invented,
// so a resolved record the tool mints always satisfies its own blocker.
Impact string
}

// WontfixRequest moves an open issue to wontfix/.
Expand Down
110 changes: 110 additions & 0 deletions internal/core/capture/impact_resolve_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package capture

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/REPPL/abcd-cli/internal/core/lint"
)

// lintIssueImpact runs only the issue_impact_valid record-lint blocker over the
// ledger under repoRoot and returns its findings. It is the real gate an issue
// the tool resolves must satisfy, so a resolve path that cannot stamp a valid
// impact is caught here rather than at a later hand-lint.
func lintIssueImpact(t *testing.T, repoRoot string) []lint.Finding {
t.Helper()
cfg := lint.Config{
Rules: map[string]lint.RuleConfig{
"issue_impact_valid": {Enabled: true, Severity: "blocker", IssuesDir: LedgerRelPath},
},
}
fs, err := lint.Lint(cfg, repoRoot)
if err != nil {
t.Fatalf("lint: %v", err)
}
return fs
}

// TestResolveProducesImpactValidRecord is the iss-117 reproduction: a resolved
// issue lands in resolved/, where issue_impact_valid REQUIRES a valid, non-null
// impact. The tool's own resolve path must therefore be able to stamp one, or the
// record it produces is rejected by the very blocker the tool ships.
func TestResolveProducesImpactValidRecord(t *testing.T) {
repo, ir := ledger(t)
res, err := Capture(CaptureRequest{
RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor,
Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "note",
})
if err != nil {
t.Fatal(err)
}

if _, err := Resolve(ResolveRequest{
RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "fixed the thing", Impact: "fix",
}); err != nil {
t.Fatalf("Resolve: %v", err)
}

if fs := lintIssueImpact(t, repo); len(fs) != 0 {
t.Fatalf("resolved record must satisfy issue_impact_valid, got %d finding(s): %+v", len(fs), fs)
}
}

// TestResolveRefusesInvalidImpact proves the resolve boundary fails closed on a
// missing or misspelled impact rather than minting a record the blocker rejects:
// there is no default, so an absent judgement is refused, not invented.
func TestResolveRefusesInvalidImpact(t *testing.T) {
repo, ir := ledger(t)
res, err := Capture(CaptureRequest{
RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor,
Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "note",
})
if err != nil {
t.Fatal(err)
}

for _, bad := range []string{"", "additiv", "Additive", `"fix"`} {
if _, err := Resolve(ResolveRequest{
RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "note", Impact: bad,
}); err == nil {
t.Fatalf("Resolve with impact %q must be refused", bad)
}
}
// The refused transitions must not have moved the issue out of open/.
if _, status, ferr := findIssue(ir, res.ID); ferr != nil || status != StateOpen {
t.Fatalf("a refused resolve must leave the issue in open/: status=%s err=%v", status, ferr)
}
}

// TestResolveImpactUnquoted guards the wire format: impact is a machine-read enum
// the frontmatter line-scanner compares byte-for-byte, so it must be written bare
// (impact: fix), never YAML-quoted (impact: "fix") the way a prose note is.
func TestResolveImpactUnquoted(t *testing.T) {
repo, ir := ledger(t)
res, err := Capture(CaptureRequest{
RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor,
Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: "note",
})
if err != nil {
t.Fatal(err)
}
tr, err := Resolve(ResolveRequest{
RepoRoot: repo, IssuesRoot: ir, ID: res.ID, Resolution: "done", Impact: "internal",
})
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(filepath.Join(repo, tr.Path))
if err != nil {
t.Fatal(err)
}
data := string(raw)
if !strings.Contains(data, "\nimpact: internal\n") {
t.Fatalf("impact must be written bare (impact: internal), got:\n%s", data)
}
if strings.Contains(data, `impact: "internal"`) {
t.Fatalf("impact must not be YAML-quoted:\n%s", data)
}
}
20 changes: 20 additions & 0 deletions internal/core/capture/serialize.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ type kv struct {
val any
}

// rawScalar is a frontmatter value written verbatim — no surrounding quotes —
// for a constrained, machine-read enum like `impact`. The frontmatter scanner is
// a line reader that compares the value byte-for-byte, so a quoted `"fix"` reads
// as a different string than the enum member `fix`; the enum fields must be bare.
// Only values already validated against their enum reach here, and yamlScalar
// still rejects any control char, so the verbatim path cannot inject a newline or
// a second frontmatter key.
type rawScalar string

// yamlScalar encodes a scalar value as a safe YAML literal, mirroring
// _issue_lib._yaml_scalar. Strings are double-quoted with backslash/dquote
// escaping and reject any ASCII control char (< 0x20); ints render bare.
Expand All @@ -34,6 +43,17 @@ func yamlScalar(value any) (string, error) {
esc := strings.ReplaceAll(v, `\`, `\\`)
esc = strings.ReplaceAll(esc, `"`, `\"`)
return `"` + esc + `"`, nil
case rawScalar:
// Verbatim, but never a value that could break the frontmatter shape: reject
// any control char (a newline would inject a second key) and any character
// outside a bare enum token. The caller has already validated the value
// against its enum; this is defence in depth at the serialise boundary.
for _, r := range v {
if r < 0x20 || !(r == '-' || r == '_' || (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9')) {
return "", fmt.Errorf("%w: unsafe raw scalar %q", ErrMalformedFrontmatter, string(v))
}
}
return string(v), nil
default:
return "", fmt.Errorf("%w: unsupported scalar type %T", ErrMalformedFrontmatter, value)
}
Expand Down
32 changes: 26 additions & 6 deletions internal/core/capture/workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,12 @@ import (
"fmt"
"os"
"path/filepath"

"github.com/REPPL/abcd-cli/internal/fsutil"
"sort"
"strconv"
"strings"

"github.com/REPPL/abcd-cli/internal/core/changelog"
"github.com/REPPL/abcd-cli/internal/fsutil"
)

// mutationPreamble runs the idempotent pre-mutation steps: sweep orphan
Expand Down Expand Up @@ -120,17 +121,30 @@ func commitCapture(req CaptureRequest, issID, slug, placeholder string) (Capture
return CaptureResult{ID: issID, Slug: slug, Path: placeholder, Status: StateOpen}, nil
}

// Resolve moves an open issue to resolved/, writing the resolution note.
// Resolve moves an open issue to resolved/, writing the resolution note and the
// product impact. resolved/ is gated by issue_impact_valid, so the impact is
// validated against the shared changelog enum up front (empty or invalid is
// refused, never defaulted) and stamped bare alongside the note — the tool's own
// resolve path can never mint a record its own blocker rejects.
func Resolve(req ResolveRequest) (TransitionResult, error) {
return transition(req.RepoRoot, req.IssuesRoot, req.ID, "resolution", req.Resolution, StateResolved)
impact, err := changelog.ParseImpact(req.Impact)
if err != nil {
return TransitionResult{}, fmt.Errorf("resolve: %w", err)
}
return transition(req.RepoRoot, req.IssuesRoot, req.ID, "resolution", req.Resolution,
[]kv{{"impact", rawScalar(string(impact))}}, StateResolved)
}

// Wontfix moves an open issue to wontfix/, writing the wontfix_reason note.
// wontfix/ carries no impact (issue_impact_valid gates resolved/ only), so no
// judgement is stamped.
func Wontfix(req WontfixRequest) (TransitionResult, error) {
return transition(req.RepoRoot, req.IssuesRoot, req.ID, "wontfix_reason", req.Reason, StateWontfix)
return transition(req.RepoRoot, req.IssuesRoot, req.ID, "wontfix_reason", req.Reason, nil, StateWontfix)
}

func transition(repoRoot, issuesRoot, issID, field, note string, target State) (TransitionResult, error) {
// transition moves an open issue to target, setting the defining note field and
// any extra frontmatter fields (e.g. resolved/'s impact) in one atomic write.
func transition(repoRoot, issuesRoot, issID, field, note string, extra []kv, target State) (TransitionResult, error) {
rr, ir, err := resolveRoots(repoRoot, issuesRoot)
if err != nil {
return TransitionResult{}, err
Expand Down Expand Up @@ -168,6 +182,12 @@ func transition(repoRoot, issuesRoot, issID, field, note string, target State) (
if err != nil {
return err
}
for _, f := range extra {
newContent, err = setScalarField(newContent, f.key, f.val)
if err != nil {
return err
}
}

dst := filepath.Join(ir, statusDirName[target], filepath.Base(src))
fm, _, err := parseFrontmatterAndBody(newContent)
Expand Down
Loading