From 4925e2120b24ffc46ed934e1d6f1caa1662781c5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:16 +0100 Subject: [PATCH 1/5] chore: capture the record slug cut mid-token at the cap Refs: iss-2609211735419853 Assisted-by: Claude:claude-opus-5 --- ...-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md diff --git a/.abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md b/.abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md new file mode 100644 index 000000000..5f19f67d9 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609211735419853" +slug: "a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1" +severity: "minor" +category: "bug" +source: "agent-finding" +found_during: "Dessau pilot 3, relayed by session gropiusllm-34 on 2026-09-21" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/core/capture/roots.go deriveSlug; internal/core/intent/create.go deriveIntentSlug; internal/core/decide/decide.go deriveSlug" +--- + +A record slug is cut mid-token at the sixty-character cap, so a slug that ends in a numbered token reads as a different token: a capture whose text named acceptance criterion ac-10 landed as ...-ac-1, and one naming ac-13 the same, which reads as a capture about ac-1 and makes two distinct records look like one in a directory listing. The cut is collapsed[:60] followed by a hyphen trim, in three places that each derive a slug the same way and cap it the same way: the capture ledger, the intent store and the decision store. Wanted: one slug cap in the record-id package, the one home for record filenames, that cuts at the last token boundary at or before the cap (and only falls back to a hard cut when the first token alone exceeds it), with the three sites calling it; a slug never ends in a token the text did not contain. From 94141ed4c1ce236a920639e4c2436b2853cf02ae Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:17 +0100 Subject: [PATCH 2/5] fix(recordid): a record slug is capped between words, never inside one Three stores derived a slug the same way and capped it the same way, collapsed[:60] then a hyphen trim, so a slug that ended in a numbered token read as a different token: a capture naming criterion ac-10 landed as ...-ac-1, and one naming ac-13 the same. The cap now lives once, in the record-id package that owns record filenames: words are split on every non-alphanumeric run with a hyphen inside a word kept as part of it, and the cap takes as many whole words as fit, cutting inside a word only when the first word alone exceeds it. The capture ledger, the intent store and the decision store call it; below the cap the result is byte-identical to before, so no existing filename moves. Refs: iss-2609211735419853 Assisted-by: Claude:claude-opus-5 --- internal/core/capture/roots.go | 9 ++---- internal/core/decide/decide.go | 8 +---- internal/core/intent/create.go | 8 +---- internal/core/recordid/slug.go | 50 +++++++++++++++++++++++++++++ internal/core/recordid/slug_test.go | 33 +++++++++++++++++++ 5 files changed, 87 insertions(+), 21 deletions(-) create mode 100644 internal/core/recordid/slug.go create mode 100644 internal/core/recordid/slug_test.go diff --git a/internal/core/capture/roots.go b/internal/core/capture/roots.go index 2351920d9..22702e68e 100644 --- a/internal/core/capture/roots.go +++ b/internal/core/capture/roots.go @@ -5,6 +5,7 @@ import ( "encoding/hex" "errors" "fmt" + "github.com/intentdriven/abcd/internal/core/recordid" "io/fs" "os" "os/exec" @@ -150,13 +151,7 @@ var reNonSlug = regexp.MustCompile(`[^a-z0-9]+`) // is redacted (gh-485). Deriving here, after redaction, closes that seam and // mirrors the intent engine's deriveIntentSlug, which likewise keeps derivation // in core rather than trusting a pre-kebab'd slug. -func deriveSlug(text string) string { - collapsed := strings.Trim(reNonSlug.ReplaceAllString(strings.ToLower(text), "-"), "-") - if len(collapsed) > 60 { - collapsed = strings.Trim(collapsed[:60], "-") - } - return collapsed -} +func deriveSlug(text string) string { return recordid.Slug(text, 60) } // normaliseSlug lowercases, collapses non-alphanumeric runs to a single hyphen, // and trims hyphens, mirroring _normalise_slug. Empty result is an error. diff --git a/internal/core/decide/decide.go b/internal/core/decide/decide.go index 85b64d4a6..b6c881062 100644 --- a/internal/core/decide/decide.go +++ b/internal/core/decide/decide.go @@ -61,9 +61,6 @@ const maxSlugLen = 60 // (not const) so a test can shorten it to exercise contention. var mintLockTimeout = 5 * time.Second -// slugNonAlnumRe collapses every run of non-slug characters to one hyphen. -var slugNonAlnumRe = regexp.MustCompile(`[^a-z0-9]+`) - // slugRe is the kebab-case shape a derived slug must land in before it may // become a filename. var slugRe = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`) @@ -145,10 +142,7 @@ func Create(repoRoot, title string) (Decision, error) { // and non-empty — the slug becomes a filename, so it is validated before any // path is built. func deriveSlug(title string) (string, error) { - collapsed := strings.Trim(slugNonAlnumRe.ReplaceAllString(strings.ToLower(title), "-"), "-") - if len(collapsed) > maxSlugLen { - collapsed = strings.Trim(collapsed[:maxSlugLen], "-") - } + collapsed := recordid.Slug(title, maxSlugLen) if collapsed == "" { return "", fmt.Errorf("decide: title %q has no slug-able characters", title) } diff --git a/internal/core/intent/create.go b/internal/core/intent/create.go index 741e51bd1..7a25b1c25 100644 --- a/internal/core/intent/create.go +++ b/internal/core/intent/create.go @@ -338,11 +338,7 @@ func draftStamp(opts DraftOptions) (provenance.Stamp, error) { // is kebab-case and non-empty — the slug becomes a filename, so it is validated // before any path is built (path-traversal / filename-safety defence). func deriveIntentSlug(text string) (string, error) { - lowered := strings.ToLower(text) - collapsed := strings.Trim(slugNonAlnumRe.ReplaceAllString(lowered, "-"), "-") - if len(collapsed) > maxSlugLen { - collapsed = strings.Trim(collapsed[:maxSlugLen], "-") - } + collapsed := recordid.Slug(text, maxSlugLen) if collapsed == "" { return "", fmt.Errorf("intent: text %q has no slug-able characters", text) } @@ -352,8 +348,6 @@ func deriveIntentSlug(text string) (string, error) { return collapsed, nil } -var slugNonAlnumRe = regexp.MustCompile(`[^a-z0-9]+`) - // mintIntentID draws a native itd id that names no record in any bucket of this // checkout. It reads no maximum (adr-45 ruling 2): the clock orders the ids and // the entropy separates two minters in the same second, so a sibling checkout — diff --git a/internal/core/recordid/slug.go b/internal/core/recordid/slug.go new file mode 100644 index 000000000..d1ad87732 --- /dev/null +++ b/internal/core/recordid/slug.go @@ -0,0 +1,50 @@ +package recordid + +import ( + "regexp" + "strings" +) + +// slugWordSepRe matches every run that separates words in a slug: anything +// but a lowercase letter, a digit or a hyphen. A hyphen inside a word (ac-10, +// spec-kit) is part of the word, so the cap below never splits it. +var slugWordSepRe = regexp.MustCompile(`[^a-z0-9-]+`) + +// slugHyphenRunRe collapses a run of hyphens inside a word to one. +var slugHyphenRunRe = regexp.MustCompile(`-{2,}`) + +// Slug derives a record filename's slug from free text: lowercased, split into +// words on every non-alphanumeric run, each word's hyphen runs collapsed and +// its ends trimmed, the words joined by hyphens, and the length capped at max. +// The cap lands between words — as many whole words as fit — so a slug never +// ends in a token the text did not contain: a hard cut turned "…criterion +// ac-10" into "…-ac-1", which reads as a record about a different criterion. +// Only a first word that alone exceeds the cap is cut inside, since there is +// no boundary to land on. The result may be empty when the text has no +// slug-able characters; callers refuse that case in their own words. +func Slug(text string, max int) string { + var words []string + for _, w := range slugWordSepRe.Split(strings.ToLower(text), -1) { + w = strings.Trim(slugHyphenRunRe.ReplaceAllString(w, "-"), "-") + if w != "" { + words = append(words, w) + } + } + if len(words) == 0 { + return "" + } + if max <= 0 { + return strings.Join(words, "-") + } + if len(words[0]) > max { + return strings.Trim(words[0][:max], "-") + } + out := words[0] + for _, w := range words[1:] { + if len(out)+1+len(w) > max { + break + } + out += "-" + w + } + return out +} diff --git a/internal/core/recordid/slug_test.go b/internal/core/recordid/slug_test.go new file mode 100644 index 000000000..faa509253 --- /dev/null +++ b/internal/core/recordid/slug_test.go @@ -0,0 +1,33 @@ +package recordid + +import "testing" + +// TestSlugCutsAtATokenBoundary holds the cap's rule: a slug longer than the +// cap ends at the last whole token that fits, never inside one, so a slug +// never ends in a token the text did not contain (…-ac-10 cannot become +// …-ac-1). +func TestSlugCutsAtATokenBoundary(t *testing.T) { + cases := []struct { + name, text string + max int + want string + }{ + {"short text is untouched", "Hello, World", 60, "hello-world"}, + {"exact fit is untouched", "abc-def", 7, "abc-def"}, + {"the cut lands on the boundary before the token", "one two three ac-10", 17, "one-two-three"}, + {"a numbered token is dropped whole, not shortened", "the eval names criterion ac-10", 29, "the-eval-names-criterion"}, + {"the first token alone over the cap is hard cut", "abcdefghijklmnop", 8, "abcdefgh"}, + {"punctuation runs collapse before the cut", "A: b -- c!! d", 5, "a-b-c"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := Slug(c.text, c.max) + if got != c.want { + t.Errorf("Slug(%q, %d) = %q, want %q", c.text, c.max, got, c.want) + } + if len(got) > c.max { + t.Errorf("Slug(%q, %d) = %q exceeds the cap", c.text, c.max, got) + } + }) + } +} From 526a6e1bb7f55a31e268259c351598eef7ce7349 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:18 +0100 Subject: [PATCH 3/5] =?UTF-8?q?chore:=20resolve=20iss-2609211735419853=20?= =?UTF-8?q?=E2=80=94=20the=20slug=20is=20capped=20between=20words?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves: iss-2609211735419853 Assisted-by: Claude:claude-opus-5 --- ...-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md | 8 ++++++++ 1 file changed, 8 insertions(+) rename .abcd/work/issues/{open => resolved}/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md (74%) diff --git a/.abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md b/.abcd/work/issues/resolved/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md similarity index 74% rename from .abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md rename to .abcd/work/issues/resolved/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md index 5f19f67d9..8ef76d339 100644 --- a/.abcd/work/issues/open/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md +++ b/.abcd/work/issues/resolved/iss-2609211735419853-a-record-slug-is-cut-mid-token-at-the-cap-so-ac-10-reads-as-ac-1.md @@ -9,6 +9,14 @@ found_during: "Dessau pilot 3, relayed by session gropiusllm-34 on 2026-09-21" origin: researcher-authored production_mode: hand-written found_at: "internal/core/capture/roots.go deriveSlug; internal/core/intent/create.go deriveIntentSlug; internal/core/decide/decide.go deriveSlug" +resolution: "one slug cap in the record-id package, between words, with the three stores calling it" +impact: fix +resolved_by: + commit: "94141ed4c1ce236a920639e4c2436b2853cf02ae" --- A record slug is cut mid-token at the sixty-character cap, so a slug that ends in a numbered token reads as a different token: a capture whose text named acceptance criterion ac-10 landed as ...-ac-1, and one naming ac-13 the same, which reads as a capture about ac-1 and makes two distinct records look like one in a directory listing. The cut is collapsed[:60] followed by a hyphen trim, in three places that each derive a slug the same way and cap it the same way: the capture ledger, the intent store and the decision store. Wanted: one slug cap in the record-id package, the one home for record filenames, that cuts at the last token boundary at or before the cap (and only falls back to a hard cut when the first token alone exceeds it), with the three sites calling it; a slug never ends in a token the text did not contain. + +## Grounds + +- pursued: we expect a slug capped between words to stop reading as a token the text did not contain, because the misleading token was only ever the tail of a cut word; shown wrong by a new record whose slug ends in a token absent from its text From 4e2cd5a55c7cc72eb2f44d6a6ad5ff2d664894d5 Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:38:48 +0100 Subject: [PATCH 4/5] fix(scripts): keep-current re-arms auto-merge after the forge updates a branch The forge's update-branch disarms auto-merge on the pull request it brings current, so the remedy for a BEHIND pull request left it CLEAN, green and unqueued with nothing saying so. The script re-arms after a successful update and prints what it did; the record carries the evidence and keeps its wider want. Refs: iss-2609211105011322 Assisted-by: Claude:claude-opus-5 --- ...ruleset-never-enqueues-and-nothing-in-abcd-says-so.md | 4 ++++ scripts/pr-keep-current.sh | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/.abcd/work/issues/open/iss-2609211105011322-a-pull-request-that-goes-behind-under-the-strict-ruleset-never-enqueues-and-nothing-in-abcd-says-so.md b/.abcd/work/issues/open/iss-2609211105011322-a-pull-request-that-goes-behind-under-the-strict-ruleset-never-enqueues-and-nothing-in-abcd-says-so.md index c65231232..6d18958fc 100644 --- a/.abcd/work/issues/open/iss-2609211105011322-a-pull-request-that-goes-behind-under-the-strict-ruleset-never-enqueues-and-nothing-in-abcd-says-so.md +++ b/.abcd/work/issues/open/iss-2609211105011322-a-pull-request-that-goes-behind-under-the-strict-ruleset-never-enqueues-and-nothing-in-abcd-says-so.md @@ -12,3 +12,7 @@ found_at: "the push and pull-request step; scripts/pr-keep-current.sh" --- A pull request that goes BEHIND main under the strict ruleset never enqueues: the merge queue requires an up-to-date branch, and auto-merge armed on a PR that then falls behind waits forever without a word. The only recovery is scripts/pr-keep-current.sh, a forge update-branch that lives outside abcd; the pilot run hit it on its first lane (PR 647, update-branch at 23:37Z after a sibling merged) and every later lane was sequenced around it by hand. Nothing abcd prints at push or PR time names the condition, the remedy or the sequencing rule (one lane in the queue at a time when they share a file). Wanted: the launch or capture surface that opens a PR says the rule, and a verb or the keep-current script is reachable from the plugin surface, so an orchestrating agent does not learn it from a stalled queue. + +## Evidence + +- 2026-09-21, PR 652: the keep-current remedy has a second half. The forge's update-branch that brings a BEHIND pull request current DISARMS its auto-merge, so after the update the pull request sat CLEAN with every check green and no queue entry for fifteen minutes; nothing said so. `scripts/pr-keep-current.sh` now re-arms after a successful update and prints what it did; the want above stands, since the sequencing rule and the condition are still learned from a stalled queue. diff --git a/scripts/pr-keep-current.sh b/scripts/pr-keep-current.sh index 5fada7b35..112a9eaca 100755 --- a/scripts/pr-keep-current.sh +++ b/scripts/pr-keep-current.sh @@ -43,6 +43,15 @@ pass() { if [ "$state" = "BEHIND" ] && [ "$queue" = "none" ]; then msg=$(gh api -X PUT "repos/$repo/pulls/$n/update-branch" -f update_method=merge -q .message 2>&1 || true) printf '%s PR #%s was BEHIND with auto-merge armed: %s\n' "$(date +%H:%M)" "$n" "$msg" + # The forge's update-branch disarms auto-merge on the pull request it + # updates (observed 2026-09-21 on PR 652: CLEAN, every check green, no + # queue entry for fifteen minutes), so the remedy re-arms once the + # update has landed. A re-arm on a pull request already armed is a no-op. + if gh pr merge "$n" --auto --merge >/dev/null 2>&1; then + printf '%s PR #%s auto-merge re-armed after the update\n' "$(date +%H:%M)" "$n" + else + printf '%s PR #%s auto-merge could NOT be re-armed; arm it by hand\n' "$(date +%H:%M)" "$n" + fi fi done < <(gh pr list --state open --json number,autoMergeRequest \ -q '.[] | select(.autoMergeRequest != null) | .number') From 1a93347a141bdc7a18c41aba5cdcf9276edec85e Mon Sep 17 00:00:00 2001 From: REPPL <77722411+REPPL@users.noreply.github.com> Date: Mon, 21 Sep 2026 18:39:15 +0100 Subject: [PATCH 5/5] chore: carry the three pilots' evidence onto the implement seed and the pacing spec, and capture the specless planned intent The Dessau pilots' accounts (parts the script did that a verb should own; a pause that never fired) and this run's own recoveries go onto the implement verb's future-work seed and the pacing spec; the fourteen planned intents no verb can give a spec to are captured. Refs: iss-2609211105021406, iss-2609211738504433 Assisted-by: Claude:claude-opus-5 --- ...plementation-run-paces-itself-by-default-and.md | 4 ++++ ...y-to-launch-the-pilot-run-without-its-prompt.md | 6 ++++++ ...with-no-spec-cannot-be-given-one-by-any-verb.md | 14 ++++++++++++++ 3 files changed, 24 insertions(+) create mode 100644 .abcd/work/issues/open/iss-2609211738504433-a-planned-intent-with-no-spec-cannot-be-given-one-by-any-verb.md diff --git a/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md b/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md index bbeeef0a2..1ad6c52f2 100644 --- a/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md +++ b/.abcd/development/specs/open/spc-2609202134341288-an-autonomous-implementation-run-paces-itself-by-default-and.md @@ -48,3 +48,7 @@ default lives in one constant the run record names. 1 to 3 and 9 by piece 1; 4 and 5 by piece 2; 6 by piece 3; 7 by piece 4; 8 by piece 5. +## Evidence the build must answer + +- The pause has never fired. Three Dessau pilots on 2026-09-21 ran the scripted outer loop under a pacing window, and each finished inside its first window, so the gate that refuses an early start and the resume on `next_eligible_at` are untested by a real pause; the abcd pilot of 2026-09-20/21 paced by hand (idle wake-ups from the orchestrator's own scheduler) and broke its second pause on the facilitator's word. The build proves the pause with a test that sets the clock past the window and watches the refusal, and the first real run records whether the pause fired. +- The ceiling turned reviews into a queue: 40 minutes of a two-hour window with a lane waiting on the two-agent ceiling, 27 minutes for one review (iss-2609211105014235). Reviewers counted separately from implementers, or a ceiling set from the lane shape, is a criterion this spec takes from that record. diff --git a/.abcd/work/issues/open/iss-2609211105021406-what-abcd-implement-would-have-to-carry-to-launch-the-pilot-run-without-its-prompt.md b/.abcd/work/issues/open/iss-2609211105021406-what-abcd-implement-would-have-to-carry-to-launch-the-pilot-run-without-its-prompt.md index 32f51d50f..b2ace56e1 100644 --- a/.abcd/work/issues/open/iss-2609211105021406-what-abcd-implement-would-have-to-carry-to-launch-the-pilot-run-without-its-prompt.md +++ b/.abcd/work/issues/open/iss-2609211105021406-what-abcd-implement-would-have-to-carry-to-launch-the-pilot-run-without-its-prompt.md @@ -12,3 +12,9 @@ found_at: "itd-2609201916151817 (abcd implement); the pilot's run file and orche --- What abcd implement would have to carry to launch the pilot run without its prompt, from having run it: (1) the run file's part 1 as data: the design decisions, one per lane, that the run is not allowed to reopen, and a stop that names the decision it lacks; (2) the lane table: record ids, the files each touches, the sibling lanes touching the same files, the base sha, and the worktree path under the machine-scoped store; (3) the roles and models per role, with a fresh agent per fix round and a reviewer that never implemented; (4) the pace: window length, the pause as idle wake-ups, the agent ceiling with reviewers counted, and the tally fields written per session (lanes opened, finished, PRs, ceiling-wait minutes, agent-minutes, tokens); (5) the gate ladder per lane: preflight and fmt-check under the HOME alias where the username collides, the issue-resolution gate on the PR form, the push, the PR body re-read and lint outbound, auto-merge with the queue's method, and the rule that an armed PR is never pushed to; (6) the pre-authorised remedies, today the window recalibration, as named recipes the lane may run once; (7) the stop conditions as a list the orchestrator checks after every report; (8) the handover shape, NEXT.md's pilot section, so a crashed session is resumed from it (this run's orchestrator died once and was resumed from NEXT.md alone, the scratchpad and the run file both lost). Evidence from the sibling pilot on the same day, Dessau pilot 2 (its account is at that repository's for_abcd/pilot-2026-09-21-2-experiment.md): the outer loop ran as a script end to end in 82 minutes with zero stops for the work; every step that needed a hand was record-writing (Departures at close, captures for audit concerns, reviewers missing from the session ledger), and the pause never fired because the run fit one window, so itd-2609201925079472's pacing is untested past the first window. Implementer context peaked at 170k there against 349k in the first pilot. + +## Evidence + +- Dessau pilot 2 (2026-09-21, one intent through a scripted outer loop in 82 minutes): zero stops for the work; three script fixes; every step that needed a hand was record-writing; the pause never fired because the run fit one window. Implementer context peaked at 170k against 349k in the first pilot. +- Dessau pilot 3 (2026-09-21, 29 of 29 stop paths on fakes, then one intent through the loop with a planted merge-conflict fault): merged without a release because the lane was built before its parent, which the fix session captured as a major gating the cut. Part (g) of that pilot's account lists what the script did that the verb should own: run state plus a handover and a resume that re-enters on the same reason; the brief split and the JSON report schemas, held from the first session; Departures rendered at spec close from the lane reports; captures at audit ingest, checking the ledger for an existing record first; the auto-merge arm check after a push. The pacing gate never fired in any of the three pilots. +- This run (2026-09-20/21): the orchestrator session died once mid-gate and was resumed from the handover alone; a fresh worktree had no local-tier logs directory and a gate's redirect failed silently before make started; the forge's update-branch disarmed auto-merge; a merge-group check failed on a 504 fetching a tool; every one was a re-arm or a retry, none a new pull request. diff --git a/.abcd/work/issues/open/iss-2609211738504433-a-planned-intent-with-no-spec-cannot-be-given-one-by-any-verb.md b/.abcd/work/issues/open/iss-2609211738504433-a-planned-intent-with-no-spec-cannot-be-given-one-by-any-verb.md new file mode 100644 index 000000000..66da9d377 --- /dev/null +++ b/.abcd/work/issues/open/iss-2609211738504433-a-planned-intent-with-no-spec-cannot-be-given-one-by-any-verb.md @@ -0,0 +1,14 @@ +--- +schema_version: 1 +id: "iss-2609211738504433" +slug: "a-planned-intent-with-no-spec-cannot-be-given-one-by-any-verb" +severity: "minor" +category: "ux" +source: "agent-finding" +found_during: "the autonomous run's coverage check, 2026-09-21" +origin: researcher-authored +production_mode: hand-written +found_at: "internal/surface/cli (intent plan, intent link); the spec store" +--- + +A planned intent with no spec cannot be given one by any verb. Fourteen intents sit in planned/ with spec_id null (itd-6, 7, 24, 27, 28, 34, 36, 42, 48, 50, 53, 58, 63, 69), planned before the spec seam existed; the readiness gate reports each as not ready, and the autonomous run treats them as skips with a planning brief each. The way to a spec is closed on every side: intent plan on an already-planned record does the identity step alone and touches no spec; intent link writes a spec_id only for a spec that exists; the spec store has no mint of its own, only close. So the only path is a hand move of the record from planned/ back to drafts/ and a fresh plan, which no verb performs and the lifecycle does not name. Wanted: intent plan on a planned record whose spec_id is null mints and links the spec the way it does for a draft, without moving a bucket, on the same human sign-off; the readiness gate's remedy names that command instead of one that cannot run.