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
1 change: 1 addition & 0 deletions .abcd/development/brief/04-surfaces/05-intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -454,6 +454,7 @@ The invariants below are the contract the tree is held to, and each names what h
- Every intent in `drafts/` has `spec_id: null` (drafts have no plan yet).
- Every intent in `planned/` has `spec_id: null` (unscheduled) or a `spc-N` id; a non-null `spec_id` points to an existing native-spec-store `<spec_id>-*.md` whose frontmatter `intent` field matches the intent's `id` (or contains the intent's `id` as one of a list, for bundle-member intents).
- **An intent owns one or more specs, and it ships when its last spec closes.** The intent↔spec relation is 1:n (invariant 17 in [`02-constraints/03-invariants.md`](../02-constraints/03-invariants.md), per [adr-2609151513118583](../../decisions/adrs/2609151513118583-an-intent-owns-one-or-more-specs-and-it-ships-when-its-last.md)). The spec's own `intent:` field is the source of truth for the link: the intent's scalar `spec_id` names the spec it was planned with, and the set of specs realising an intent is derived from the back-links (`spec.Store.SpecsForIntent`, `lint.SpecLinkIndex.SpecsForIntent`) — no field carries a list. The bidirectional check is therefore membership, not equality: a spec naming an intent is clean when that intent's `spec_id` names *some* spec realising it (`spec_lifecycle`), so a remainder spec is not drift. Closing a spec ships the intent only when no open spec is left naming it; a remainder slug given on the close mints the follow-on spec in the same operation, carrying the closing spec's steps not marked landed, and the impact is demanded at the close that ships and refused at any earlier one. The release cut's stale-intent refusal asks whether a planned intent has any OPEN spec, never whether its spec has closed — a planned intent with one closed and one open spec is the correct steady state of a partial delivery.
- **A move repoints the links that named the moved record.** An intent's and a spec's folder is its status, so planning (`drafts/ → planned/`) and closing (`open/ → closed/`, and on the close that ships `planned/ → shipped/`) are renames, and the verb that renames is the one place that knows both paths. It rewrites every relative markdown link in the tree that named an old path, from any folder — a spec already closed pointing at `../open/<the closing spec>`, an ADR or a plan naming the intent's `planned/` path, a draft naming both, and the moved record's own links, written from the folder it left — through the one link-repoint primitive (`core/relink`) the ledger's resolve and wontfix share. A link that never resolved is left as written. The result lists each rewrite (`relinked`), so the close leaves a tree record-lint's `links_resolve` accepts, with no hand survey. The close derives its moves from where the records are now, so a re-run repoints every link other files still hold to an old path; it re-reads a record's own links from the folder it left only when that same run moved the record, because a record an earlier run moved may have been edited where it is (a bare `README.md` link names the `closed/` index, not the `open/` one). A repoint failure is a warning, never a failed close; the moved records' own links an attempt that failed before or during the repoint left unrewritten are ones `links_resolve` names, to repair by hand.
- **A bundle is the opposite relation and is untouched.** `kind: bundle-member` with a `bundle:` link is N:1 — several intents sharing one spec — and the bundle invariant above (all members in one phase) still holds. 1:n and N:1 are different relations, not two names for one thing; composing them into N:M is not authorised by anything in the record. An intent's own specs may sit in different phases, because the reason a second spec exists is that the work did not fit the cycle that carried the first.
- Every intent in `shipped/` has `kind` set (`standalone` or `bundle-member`) and a non-null `spec_id`. (The stronger invariant — the linked spec exists and is closed, or `spec_id: null` + a `manual_ship_reason` for the no-spec case — is a later-phase gate; the shipped rule checks only that `spec_id` is non-null.)
- Discipline-kind intents have `spec_id: null` always (disciplines never get a spec; this is structurally enforced).
Expand Down
10 changes: 10 additions & 0 deletions .abcd/development/brief/04-surfaces/06-capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,16 @@ stays out of the current cut.
the issue to `wontfix/`. Grounds are optional here and override the recorded
text only: the token stays `declined`, because a wontfix **is** that non-action.

**Both moves repoint the links that named the issue.** Resolving and marking
wontfix each rename the record out of `open/`, and in the same operation every
relative markdown link in the tree that named its old path is rewritten to the
new one — from a decision, a draft intent or a sibling issue, and the moved
issue's own links, written from `open/` — through the one link-repoint
primitive every record-moving verb shares (`core/relink`). A link that never
resolved is left as written. Each rewrite is reported (file, line, the
destination before and after), and a repoint that fails part-way is a warning,
not a failure: the transition stands.

## 2. Which ledger a verb addresses

Every verb addresses the checkout's ledger, whichever directory of the working
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ being a fact about delivery and becomes a claim the tool asserted on the
operator's behalf.

The adjacent finding is the same seam from the other side.
[iss-2609091732329046](../../../work/issues/open/iss-2609091732329046-closing-a-spec-moves-its-intent-but-leaves-every-link-that-n.md)
[iss-2609091732329046](../../../work/issues/resolved/iss-2609091732329046-closing-a-spec-moves-its-intent-but-leaves-every-link-that-n.md)
reports that the close moves the intent and leaves every link written against
the intent's old folder pointing at nothing — three closes in one sitting
produced eight dead links and a red gate immediately afterwards. Both records
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ found_during: "itd-184 ship ceremony, cold-reading cycle 1"
origin: researcher-authored
production_mode: hand-written
found_at: ".abcd/work/CONTEXT.md"
resolution: "The record-moving verbs (spec close, intent plan, capture resolve, capture wontfix) repoint every relative link that named the moved record's old path through one primitive, core/relink, and report each rewrite."
impact: fix
resolved_by:
commit: "2a6d5b63"
---

The ship ceremony's repoint step names two link classes -- every intents/planned link to the shipping intent, and the closing spec's bare sibling links to still-open specs plus bare links to it from open specs. A third class exists and is not named: a link from an ALREADY-CLOSED spec pointing at ../open/<the spec being closed>. Closing spc-62 left spc-61, itself already closed, holding ../open/spc-62 and record-lint refused with a links_resolve BLOCKER. The survey greps the checklist implies (specs/open/ and bare siblings) do not match that shape, so it is invisible until the gate runs. Class (b) is already recorded as having broken two earlier ships; this is a third sibling of the same shape and the checklist should name all three, or the repoint should be mechanical rather than a hand survey.

## Grounds

- pursued: we expect a spec close, plan, resolve or wontfix to leave a tree record-lint's links_resolve accepts with no hand repair; a links_resolve blocker naming a moved record's old path after one of those verbs would show it wrong
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ found_during: "closing three specs after the sub-agent capture work"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/spec"
resolution: "The record-moving verbs (spec close, intent plan, capture resolve, capture wontfix) repoint every relative link that named the moved record's old path through one primitive, core/relink, and report each rewrite."
impact: fix
resolved_by:
commit: "2a6d5b63"
---

Closing a spec moves its intent but leaves every link that named the intent's old folder pointing at nothing. The close verb reconciles the intent from planned to shipped, which is its job, and the spec body that was written while the intent was planned keeps its relative links to the planned folder. Those links resolve to nothing the moment the move completes, and the record gate refuses on them, so a close that reports success hands the next command a tree that will not lint. Three closes in one sitting produced eight dead links here and a red preflight immediately afterwards, with nothing in the close output hinting at it. The verb already knows both the old and the new path, so it is the one thing in the system positioned to fix or at least name them. Either rewrite links to the moved record in the same operation, or refuse the close while a link in the spec names the folder the intent is about to leave, or say at minimum which links the move has just invalidated. Silence is the worst of the three, because the failure surfaces later, in a different command, as a lint error that looks unrelated to the close that caused it.

## Grounds

- pursued: we expect a spec close, plan, resolve or wontfix to leave a tree record-lint's links_resolve accepts with no hand repair; a links_resolve blocker naming a moved record's old path after one of those verbs would show it wrong
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
---
schema_version: 1
id: "iss-2609250846525896"
slug: "record-moving-verbs-other-than-spec-close-leave-links-to-the-moved-record-dead"
severity: "minor"
category: "bug"
source: "agent-finding"
found_during: "autonomous run A resumed 2026-09-25"
origin: researcher-authored
production_mode: hand-written
found_at: "internal/core/capture/workflow.go"
resolution: "The record-moving verbs (spec close, intent plan, capture resolve, capture wontfix) repoint every relative link that named the moved record's old path through one primitive, core/relink, and report each rewrite."
impact: fix
resolved_by:
commit: "2a6d5b63"
---

capture resolve, capture wontfix and intent plan move a record between status folders and leave every relative markdown link that named its old path pointing at nothing, the same gap iss-2609091732329046 records for spec close. Lane records1 of run A closed two issues that three ADRs and two draft intents linked by path, and record-lint refused seven links_resolve blockers until the links were repointed by hand. Every verb that moves a record is the one place that knows the old and the new path, so each should repoint the links through one shared primitive and report what it rewrote.

## Grounds

- pursued: we expect a spec close, plan, resolve or wontfix to leave a tree record-lint's links_resolve accepts with no hand repair; a links_resolve blocker naming a moved record's old path after one of those verbs would show it wrong
9 changes: 9 additions & 0 deletions commands/capture.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,15 @@ whenever it is non-zero: these paths redact the note exactly as `capture` does,
but their human render stays silent, so the caller learns their wording was
rewritten only if you relay it.

Moving the issue repoints every relative markdown link in the tree that named
it in `open/` — an ADR, a draft intent, a sibling issue — and the moved issue's
own links, which were written from `open/`. The JSON lists each rewrite under
`relinked` (`file`, `line`, `from`, `to`) and the text render prints them;
report them, because they are files the verb changed beyond the issue. A link
that never resolved is left as written. A repoint that fails part-way leaves
the transition standing and warns on stderr; record-lint's `links_resolve`
then names each link left behind.

An id this checkout's ledger does not hold is refused. When a peer holds it —
a sibling worktree or a local branch (see `/abcd:peers`) — the refusal names
the peer's branch, path and folder instead of answering not found: the record
Expand Down
23 changes: 22 additions & 1 deletion commands/intent.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,7 +343,10 @@ gate that will refuse the move mechanically is a recorded seed until built.
This invocation IS the maintainer's sign-off act — never run it unattended
or infer consent. It mints the spec stub, links both sides, stamps an
identity onto every unmarked scope condition, and moves the intent
`drafts/ → planned/`.
`drafts/ → planned/`. Every relative markdown link that named the draft's
path, from any file in the tree, is repointed at `planned/` in the same
operation; the JSON lists each rewrite under `relinked` (`file`, `line`,
`from`, `to`) — report them.

**`--impact` is the judgement the interview settled**, stamped here because
this is the moment it is made: a draft filed without one gets it now, in the
Expand Down Expand Up @@ -426,6 +429,24 @@ nothing refuses `--impact`, because that judgement is written only at the close
that ships (adr-2609151513118583, invariant 17). Report the specs the close
names as still open — they are the reason the intent did not move.

**The close repoints every link that named a record it moved.** A record's
folder is its status, so the close renames two files — the spec out of
`open/`, and on the close that ships, the intent out of `planned/` — and in the
same operation it rewrites every relative markdown link in the tree that named
either old path: a spec already closed that pointed at `../open/<the spec>`, an
ADR or a plan naming the intent's `planned/` path, a draft naming both, and the
closed spec's own links, which were written from `open/`. A link that never
resolved is left as written. The JSON lists each rewrite under `relinked`
(`file`, `line`, `from`, `to`), and the text render prints them; report them,
because they are files the close changed beyond the two records. The tree the
close leaves passes record-lint's `links_resolve` with no hand repair. If the
repoint fails part-way, the close still stands and a warning on stderr says so.
After an attempt that failed before or during the repoint, re-running the same
`spec close` repoints every link other files still hold to either old path. The re-run reads the moved records' own links from the folders
they are in, because they may have been edited there since the move, so it
never rewrites them; any of those the failed attempt left unrewritten is one
`links_resolve` names, to repair by hand.

Run it in the **same change** that lands the intent's work — the commit or
pull request that makes the acceptance criteria true — the way a captured
issue is resolved in the change that fixes it. The reason is the release cut:
Expand Down
9 changes: 9 additions & 0 deletions internal/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ plugin surface, and a future MCP server share one engine.
reader spelled twice is one the two can disagree about, which is how a bullet
one writer appends becomes a bullet the other cannot find. It owns no heading's
meaning: a caller supplies the pattern it is looking for.
- **`core/relink/`** — the one link-repoint primitive. A record's folder is its
status, so every lifecycle transition is a rename, and a rename strands every
relative link that named the file where it was. The verbs that move a record
— `spec close` and `intent plan` (`core/intent`), `capture resolve` and
`capture wontfix` (`core/capture`) — hand it the moves they made, and it
rewrites every markdown link in the working tree that named an old path and
reports each rewrite. A leaf on the `core/mdrecord` precedent: three record
families move, and a repoint spelled per family is one that misses a link
class the others catch.
- **`core/provenance/`** — the record's disclosure vocabulary: where an item came
from (`origin`) and how its text was produced (`production_mode`), plus the one
parser that reads and renders them. It is a leaf for the same reason
Expand Down
8 changes: 8 additions & 0 deletions internal/core/capture/capture.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (

"github.com/intentdriven/abcd/internal/core/issueschema"
"github.com/intentdriven/abcd/internal/core/recordid"
"github.com/intentdriven/abcd/internal/core/relink"
)

// LedgerRelPath is the ledger root relative to the repo worktree.
Expand Down Expand Up @@ -247,6 +248,13 @@ type TransitionResult struct {
// same redactor and reports the same way.
Redacted int `json:"redacted,omitempty"`
Degraded string `json:"redaction_degraded,omitempty"`
// Relinked lists every relative markdown link the transition repointed
// because it named the issue's old path in open/ (iss-2609250846525896).
Relinked []relink.Rewrite `json:"relinked,omitempty"`
// RelinkError is a NON-FATAL report of a repoint that failed part-way: the
// issue has moved and the transition stands, so the surface prints it
// loudly, and record-lint's links_resolve names any link left behind.
RelinkError string `json:"relink_error,omitempty"`
}

// ListRequest queries one state (or "all").
Expand Down
88 changes: 88 additions & 0 deletions internal/core/capture/relink_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package capture

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

"github.com/intentdriven/abcd/internal/core/lint"
)

// Resolving or declining an issue moves it out of open/, and every link that
// named it there follows it — from an ADR, from a sibling issue still in open/,
// and the moved issue's own bare link to that sibling (iss-2609250846525896).
func TestTransitionRepointsLinksToTheMovedIssue(t *testing.T) {
for _, tc := range []struct {
name string
folder string
move func(repo, ir, id string) (TransitionResult, error)
}{
{"resolve", "resolved", func(repo, ir, id string) (TransitionResult, error) {
return Resolve(ResolveRequest{Grounds: testGrounds, RepoRoot: repo, IssuesRoot: ir, ID: id, Resolution: "fixed", Impact: "fix"})
}},
{"wontfix", "wontfix", func(repo, ir, id string) (TransitionResult, error) {
return Wontfix(WontfixRequest{RepoRoot: repo, IssuesRoot: ir, ID: id, Reason: "declined because the cost exceeds the benefit here"})
}},
} {
t.Run(tc.name, func(t *testing.T) {
repo, ir := ledger(t)
mk := func(slug string) string {
res, err := Capture(CaptureRequest{RepoRoot: repo, IssuesRoot: ir, Text: "b", Severity: SeverityMinor,
Category: "bug", Source: "user-observation", FoundDuring: "t", Slug: slug})
if err != nil {
t.Fatal(err)
}
return filepath.Base(res.Path)
}
a, b := mk("alpha"), mk("beta")
appendTo := func(rel, line string) {
f, err := os.OpenFile(filepath.Join(repo, rel), os.O_APPEND|os.O_WRONLY|os.O_CREATE, 0o644)
if err != nil {
t.Fatal(err)
}
defer f.Close()
if _, err := f.WriteString(line); err != nil {
t.Fatal(err)
}
}
if err := os.MkdirAll(filepath.Join(repo, ".abcd/development/decisions/adrs"), 0o755); err != nil {
t.Fatal(err)
}
adr := ".abcd/development/decisions/adrs/0001-x.md"
appendTo(adr, "# x\n\nFound in [alpha](../../../work/issues/open/"+a+").\n")
appendTo(LedgerRelPath+"/open/"+a, "\nSee [beta]("+b+").\n")
appendTo(LedgerRelPath+"/open/"+b, "\nSee [alpha]("+a+").\n")

id := strings.SplitN(a, "-", 3)[0] + "-" + strings.SplitN(a, "-", 3)[1]
res, err := tc.move(repo, ir, id)
if err != nil {
t.Fatal(err)
}

cfg := lint.Config{
Roots: []string{".abcd/development", ".abcd/work"},
Rules: map[string]lint.RuleConfig{"links_resolve": {Enabled: true, Severity: "blocker"}},
}
findings, err := lint.Lint(cfg, repo)
if err != nil {
t.Fatal(err)
}
for _, f := range findings {
if f.RuleID == "links_resolve" {
t.Errorf("links_resolve after %s: %s:%d %s", tc.name, f.File, f.Line, f.Message)
}
}
if len(res.Relinked) != 3 || res.RelinkError != "" {
t.Errorf("the transition must report its three rewrites: %+v %q", res.Relinked, res.RelinkError)
}
body, err := os.ReadFile(filepath.Join(repo, adr))
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(body), "(../../../work/issues/"+tc.folder+"/"+a+")") {
t.Errorf("ADR link not repointed:\n%s", body)
}
})
}
}
Loading
Loading