diff --git a/.changeset/tidy-tigers-forage.md b/.changeset/tidy-tigers-forage.md
new file mode 100644
index 000000000..a520e6c4f
--- /dev/null
+++ b/.changeset/tidy-tigers-forage.md
@@ -0,0 +1,5 @@
+---
+type: Fixed
+pr: 4754
+---
+**Seeds minted by parallel workstreams no longer share an id by construction** — `/gsd:capture --seed` derives `SEED-YYMMDD-xxx` from the local date plus a random suffix instead of counting files in `.planning/seeds/`, which each worktree could only do from what had merged, so two workstreams planting before either merged both picked the same id; the residual same-day collision bound (~1 in 46,656 per pair) is the one the `.planning/quick/` scheme already accepts. Existing `SEED-NNN` seeds keep resolving in list, enrich, the new-milestone scan, and audit — whose scan now publishes the same canonical id as `list-seeds` and whose acknowledge resolves either id to the same file. (#4378)
diff --git a/commands/gsd/capture.md b/commands/gsd/capture.md
index 64a25f937..d5daa3bcc 100644
--- a/commands/gsd/capture.md
+++ b/commands/gsd/capture.md
@@ -31,7 +31,7 @@ Mode routing:
| (none) | Structured todo in .planning/todos/ | add-todo |
| --note | Timestamped note file, list, or promote | note |
| --backlog | ROADMAP.md backlog section (999.x) | add-backlog |
-| --seed | .planning/seeds/SEED-NNN-slug.md | plant-seed |
+| --seed | .planning/seeds/SEED-YYMMDD-xxx-slug.md | plant-seed |
| --list | Interactive todo browser + action router | check-todos |
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
index 66582efd8..5d65d662a 100644
--- a/docs/COMMANDS.md
+++ b/docs/COMMANDS.md
@@ -1939,7 +1939,7 @@ Capture ideas, tasks, notes, and seeds to their appropriate destination. Default
**Backlog:** 999.x numbering keeps items outside the active phase sequence; phase directories are created immediately so `/gsd-discuss-phase` and `/gsd-plan-phase` work on them.
**Seeds:** Preserve full WHY, WHEN to surface, and breadcrumbs — consumed by `/gsd-new-milestone`. Audit parked seeds anytime with `--list-seeds` (optionally `--list-seeds dormant`).
-**Produces:** `.planning/todos/` (default), note files (--note), ROADMAP.md backlog section (--backlog), `.planning/seeds/SEED-NNN-slug.md` (--seed)
+**Produces:** `.planning/todos/` (default), note files (--note), ROADMAP.md backlog section (--backlog), `.planning/seeds/SEED-YYMMDD-xxx-slug.md` (--seed)
**STATE.md rendering:** each capture (or `--list` action that changes the pending count) refreshes STATE.md's "### Pending Todos" section to one bullet per pending todo, each capped at 240 characters — `- [date] [area] title — [todo file](path) — Needs ...`. The todo-file link is repo-relative (`.planning/todos/pending/...`), so the cap is independent of where the repo is checked out — a long absolute path never consumes the budget or drops the "Needs ..." clause. A todo with no clear next step omits the "Needs ..." clause rather than the bullet. Refresh is fail-safe: a failed or malformed lookup leaves the existing section untouched rather than clearing it.
diff --git a/docs/FEATURES.md b/docs/FEATURES.md
index 3153757c6..6a0570d95 100644
--- a/docs/FEATURES.md
+++ b/docs/FEATURES.md
@@ -1464,7 +1464,7 @@ That third-party dependence is a real trade-off, held honestly rather than paper
| Artifact | Description |
|----------|-------------|
| `.planning/phases/999.x-slug/` | Backlog item directory |
-| `.planning/seeds/SEED-NNN-slug.md` | Seed with trigger conditions |
+| `.planning/seeds/SEED-YYMMDD-xxx-slug.md` | Seed with trigger conditions |
---
diff --git a/docs/USER-GUIDE.md b/docs/USER-GUIDE.md
index cf2e8166e..847d9b7f6 100644
--- a/docs/USER-GUIDE.md
+++ b/docs/USER-GUIDE.md
@@ -392,7 +392,7 @@ Seeds are forward-looking ideas with trigger conditions. Unlike backlog items, s
/gsd-capture --seed "Add real-time collab when WebSocket infra is in place"
```
-`/gsd-new-milestone` scans all seeds and presents matches. **Storage:** `.planning/seeds/SEED-NNN-slug.md`
+`/gsd-new-milestone` scans all seeds and presents matches. **Storage:** `.planning/seeds/SEED-YYMMDD-xxx-slug.md`
Once you've parked a few, audit them on demand instead of waiting for the next milestone to surface them:
diff --git a/docs/features/backlog-parking-lot.md b/docs/features/backlog-parking-lot.md
index 1848d7aaf..950984b51 100644
--- a/docs/features/backlog-parking-lot.md
+++ b/docs/features/backlog-parking-lot.md
@@ -21,4 +21,4 @@ group: v1.27 Features
| Artifact | Description |
|----------|-------------|
| `.planning/phases/999.x-slug/` | Backlog item directory |
-| `.planning/seeds/SEED-NNN-slug.md` | Seed with trigger conditions |
+| `.planning/seeds/SEED-YYMMDD-xxx-slug.md` | Seed with trigger conditions |
diff --git a/gsd-core/workflows/help/modes/full.compact.md b/gsd-core/workflows/help/modes/full.compact.md
index 18330c45f..0ed1de47d 100644
--- a/gsd-core/workflows/help/modes/full.compact.md
+++ b/gsd-core/workflows/help/modes/full.compact.md
@@ -184,7 +184,7 @@ Usage: `/gsd:capture --note promote 3`
Usage: `/gsd:capture --list api`
-**`/gsd:capture --list-seeds [status]`** — Read-only listing of captured seeds (ID, status, scope, trigger, title); optional status filter. Enrich via `/gsd:capture --seed --enrich SEED-NNN`.
+**`/gsd:capture --list-seeds [status]`** — Read-only listing of captured seeds (ID, status, scope, trigger, title); optional status filter. Enrich via `/gsd:capture --seed --enrich SEED-YYMMDD-XXX`.
Usage: `/gsd:capture --list-seeds dormant`
diff --git a/gsd-core/workflows/help/modes/full.md b/gsd-core/workflows/help/modes/full.md
index a9976e4e3..8a3643ffc 100644
--- a/gsd-core/workflows/help/modes/full.md
+++ b/gsd-core/workflows/help/modes/full.md
@@ -444,7 +444,7 @@ List and audit captured seeds (read-only).
- Lists all seeds with ID, status, scope, trigger, and title
- Optional status filter (e.g., `/gsd:capture --list-seeds dormant`)
-- Does not modify any seed — enrich with `/gsd:capture --seed --enrich SEED-NNN`
+- Does not modify any seed — enrich with `/gsd:capture --seed --enrich SEED-YYMMDD-XXX`
Usage: `/gsd:capture --list-seeds`
Usage: `/gsd:capture --list-seeds dormant`
diff --git a/gsd-core/workflows/plant-seed.md b/gsd-core/workflows/plant-seed.md
index 1a8dea7df..4dddd8ce8 100644
--- a/gsd-core/workflows/plant-seed.md
+++ b/gsd-core/workflows/plant-seed.md
@@ -22,9 +22,23 @@ Parse `$ARGUMENTS` for the idea summary.
First, check for an enrich flag:
```bash
-if echo "$ARGUMENTS" | grep -qE '\-\-enrich[[:space:]]+SEED-[0-9]+'; then
- ENRICH_TARGET=$(echo "$ARGUMENTS" | grep -oE 'SEED-[0-9]+')
- SEED_FILE=$(ls .planning/seeds/${ENRICH_TARGET}-*.md 2>/dev/null | head -1)
+if echo "$ARGUMENTS" | grep -qE '\-\-enrich[[:space:]]+SEED-[0-9]+(-[a-zA-Z0-9]{3})?'; then
+ # Anchor on the flag and capture the COMPLETE id — uppercase-tolerant, since
+ # the docs display SEED-YYMMDD-XXX. A leftmost `SEED-[0-9]+` would truncate
+ # an uppercase or malformed suffix to its date and enrich an arbitrary
+ # same-day seed (#4378 review).
+ ENRICH_MATCH=$(echo "$ARGUMENTS" | grep -oE '\-\-enrich[[:space:]]+SEED-[0-9]+(-[a-zA-Z0-9]{3})?' | head -1)
+ ENRICH_TARGET=${ENRICH_MATCH##*[[:space:]]}
+ SEED_FILE=$(ls .planning/seeds/${ENRICH_TARGET}-*.md 2>/dev/null) || true
+ if [ -z "$SEED_FILE" ]; then
+ echo "ERROR: no seed file matches '$ENRICH_TARGET' in .planning/seeds/." >&2
+ exit 1
+ fi
+ if [ "$(printf '%s\n' "$SEED_FILE" | grep -c .)" -gt 1 ]; then
+ echo "ERROR: '$ENRICH_TARGET' matches multiple seed files — duplicate legacy ids cannot be disambiguated by a longer id, so rename the duplicates; for new-format ids re-run with the complete id:" >&2
+ printf '%s\n' "$SEED_FILE" >&2
+ exit 1
+ fi
# Skip to enrich-seed step — do not prompt for $IDEA
else
if [ -n "$ARGUMENTS" ]; then
@@ -52,17 +66,39 @@ mkdir -p .planning/seeds
```bash
-# Find next seed number
-EXISTING=$( (ls .planning/seeds/SEED-*.md 2>/dev/null || true) | wc -l )
-NEXT=$((EXISTING + 1))
-PADDED=$(printf "%03d" $NEXT)
+# Seed id: date + 3 random base36 chars (the `.planning/quick/` shape).
+# NO shared counter: `.planning/seeds/` is shared, but each worktree only sees
+# what has merged — counting files collides across parallel workstreams (#4378).
+# Residual bound: two workstreams planting the same day before either merges
+# can still draw the same suffix (~1 in 46,656 per pair) — the same bound the
+# `.planning/quick/` scheme accepts.
+SEED_DATE=$(date +%y%m%d)
+SEED_ID=""
+for _SEED_ATTEMPT in 1 2; do
+ # `|| true`: `tr` reads an infinite stream, so `head -c` closing the pipe
+ # takes SIGPIPE — harmless (head already has its 3 bytes) but fatal under
+ # pipefail.
+ SEED_SUFFIX=$(LC_ALL=C tr -dc 'a-z0-9' &2
+ exit 1
+ fi
+ SEED_ID="SEED-${SEED_DATE}-${SEED_SUFFIX}"
+ # Same-day regen guard, written as a find existence test (never `ls `:
+ # under a stray nullglob that shape silently degenerates — #3409 drift guard).
+ [ -z "$(find .planning/seeds -maxdepth 1 -name "${SEED_ID}-*.md" -print 2>/dev/null)" ] && break
+done
+if [ -n "$(find .planning/seeds -maxdepth 1 -name "${SEED_ID}-*.md" -print 2>/dev/null)" ]; then
+ echo "ERROR: could not draw an unused seed id after 2 attempts" >&2
+ exit 1
+fi
```
Generate slug from idea summary.
-Write `.planning/seeds/SEED-{PADDED}-{slug}.md` immediately with sensible defaults:
+Write `.planning/seeds/{SEED_ID}-{slug}.md` immediately with sensible defaults:
- `trigger_when`: default is `"when relevant"` — the seed will surface during any
new-milestone scan; the user can narrow it later via `--enrich`
@@ -70,7 +106,7 @@ Write `.planning/seeds/SEED-{PADDED}-{slug}.md` immediately with sensible defaul
```markdown
---
-id: SEED-{PADDED}
+id: {SEED_ID}
status: dormant
planted: {ISO date}
planted_during: {current milestone/phase from STATE.md, or "unknown" if not in a GSD project}
@@ -78,11 +114,11 @@ trigger_when: when relevant
scope: unknown
---
-# SEED-{PADDED}: {$IDEA}
+# {SEED_ID}: {$IDEA}
## Why This Matters
-_To be filled in. Run `/gsd:capture --seed --enrich SEED-{PADDED}` to add context._
+_To be filled in. Run `/gsd:capture --seed --enrich {SEED_ID}` to add context._
## When to Surface
@@ -92,7 +128,7 @@ This seed will surface during `/gsd:new-milestone` when the milestone scope matc
## Scope Estimate
-**Unknown** — run `/gsd:capture --seed --enrich SEED-{PADDED}` to estimate effort.
+**Unknown** — run `/gsd:capture --seed --enrich {SEED_ID}` to estimate effort.
## Breadcrumbs
@@ -137,7 +173,7 @@ Store relevant file paths as `$BREADCRUMBS`.
```bash
_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; _gsd_at() { for _p; do if [ -f "$_p" ]; then GSD_TOOLS="$_p"; return 0; fi; done; return 1; }; if _gsd_at "${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif unset -f gsd_run; _G="$(command -v gsd_run)"; then GSD_TOOLS="$_G"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif _gsd_at "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; then gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd_run is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; GSD_IDENTITY_STATUS=unverified; case "$(gsd_run runtime-identity --raw 2>/dev/null || true)" in '{"packageName":"@opengsd/gsd-core"'*'}') GSD_IDENTITY_STATUS=ok;; esac; export GSD_IDENTITY_STATUS; [ "$GSD_IDENTITY_STATUS" = ok ] || echo "WARNING: \"$GSD_TOOLS\" did not prove it is @opengsd/gsd-core - it is either a different package or an @opengsd/gsd-core older than the runtime-identity verb. See docs/how-to/diagnose-a-foreign-gsd-tools.md" >&2; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --raw --default "" 2>/dev/null || echo "")
-gsd_run query commit "docs: plant seed — {$IDEA}" --files .planning/seeds/SEED-{PADDED}-{slug}.md
+gsd_run query commit "docs: plant seed — {$IDEA}" --files .planning/seeds/{SEED_ID}-{slug}.md
```
**If `response_language` is set:** All user-facing output of this workflow — narration between tool calls, status updates, progress notes, findings, questions, prompts, and explanations — MUST be presented in `{response_language}`. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.
@@ -145,12 +181,12 @@ gsd_run query commit "docs: plant seed — {$IDEA}" --files .planning/seeds/SEED
```text
-✅ Seed planted: SEED-{PADDED}
+✅ Seed planted: {SEED_ID}
"{$IDEA}"
-File: .planning/seeds/SEED-{PADDED}-{slug}.md
+File: .planning/seeds/{SEED_ID}-{slug}.md
-Trigger and scope are set to defaults. Run `/gsd:capture --seed --enrich SEED-{PADDED}`
+Trigger and scope are set to defaults. Run `/gsd:capture --seed --enrich {SEED_ID}`
to add trigger conditions, rationale, and scope estimate at your convenience.
This seed will surface automatically when you run /gsd:new-milestone.
@@ -161,7 +197,7 @@ This seed will surface automatically when you run /gsd:new-milestone.
**Optional enrichment — only run this step when `--enrich` flag is present.**
If `--enrich` flag is in `$ARGUMENTS`:
-- `$ENRICH_TARGET` and `$SEED_FILE` are already set by `parse-idea`. Derive `$SEED_ID` from `$ENRICH_TARGET` (e.g. `SEED_ID="$ENRICH_TARGET"`). If `$SEED_FILE` is empty, fall back to the most-recently modified file in `.planning/seeds/` and set `$SEED_ID` from its filename.
+- `$ENRICH_TARGET` and `$SEED_FILE` are already set by `parse-idea`. Derive `$SEED_ID` from `$ENRICH_TARGET` (e.g. `SEED_ID="$ENRICH_TARGET"`). If `$SEED_FILE` is empty, `parse-idea` has already failed closed (no seed matches the id), so this step is never reached with an unresolved target.
- Ask focused questions to build a complete seed:
diff --git a/skills/gsd-capture/SKILL.md b/skills/gsd-capture/SKILL.md
index faa88ed23..13126822e 100644
--- a/skills/gsd-capture/SKILL.md
+++ b/skills/gsd-capture/SKILL.md
@@ -32,7 +32,7 @@ Mode routing:
| (none) | Structured todo in .planning/todos/ | add-todo |
| --note | Timestamped note file, list, or promote | note |
| --backlog | ROADMAP.md backlog section (999.x) | add-backlog |
-| --seed | .planning/seeds/SEED-NNN-slug.md | plant-seed |
+| --seed | .planning/seeds/SEED-YYMMDD-xxx-slug.md | plant-seed |
| --list | Interactive todo browser + action router | check-todos |
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
diff --git a/src/audit.cts b/src/audit.cts
index f983b4c4b..58497534b 100644
--- a/src/audit.cts
+++ b/src/audit.cts
@@ -25,6 +25,11 @@ import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir, quickDirFrom, todosDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
+// #4378 (roll-in): scanSeeds publishes the SAME canonical seed identity the
+// list-seeds gate derives — one grammar, two surfaces, no drift. commands.cjs
+// does not require this module, so the edge is acyclic.
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+import commandsModule = require('./commands.cjs');
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
@@ -800,6 +805,15 @@ function scanTodos(todosBase: string): ScanOutcome {
return { items: results, acknowledged };
}
+// #4378 (roll-in): true when the filename is a well-formed seed name —
+// `SEED-` prefix, `.md` suffix, and no control bytes anywhere in between (the
+// scanSeeds admission filter passes such names through to the identity
+// derivation; a name with an embedded control byte falls back to its raw
+// stem exactly as the pre-canonical code did).
+function seedIdMatchRawName(name: string): boolean {
+ return !/[\u0000-\u001f\u007f]/.test(name);
+}
+
// ─── scanSeeds ────────────────────────────────────────────────────────────────
/**
@@ -855,16 +869,18 @@ function scanSeeds(planDir: string): ScanOutcome {
continue;
}
- // Extract seed_id from filename or frontmatter. The regex match is
- // `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
- // but the fallback taken when a filename doesn't fully match — e.g. a
- // `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
- // the middle, which still passes the `startsWith`/`endsWith` filter above
- // — is the raw, unconstrained basename. Both branches are routed through
- // sanitizeLabel below.
- const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
- const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
- const slug = sanitizeLabel(seed_id.replace(/^SEED-/, ''));
+ // #4378 (roll-in): the canonical identity comes from the SAME derivation
+ // the list-seeds surface uses — frontmatter `id:` when it matches a seed
+ // grammar (legacy `SEED-NNN` or date-suffixed `SEED-YYMMDD-xxx`), else the
+ // filename's id prefix, else the whole stem. The old fused
+ // filename-stem id (e.g. `SEED-081-region` for `SEED-081-region.md`)
+ // disagreed with list-seeds and misfiled deferrals; publishing the
+ // canonical id keeps the two surfaces answering identically. The raw-
+ // basename fallback for control-byte-bearing names is preserved.
+ const stem = path.basename(entry.name, '.md');
+ const derived = commandsModule.deriveSeedIdentity(stem, fm.id);
+ const seed_id = seedIdMatchRawName(entry.name) ? derived.seed_id : stem;
+ const slug = sanitizeLabel(derived.slug);
let title = sanitizeForDisplay(fm.title || '');
if (!title) {
@@ -1757,8 +1773,44 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
} else if (category === 'seeds') {
if (!seedId) ioError('--seed-id is required for --category seeds');
- safeFilePath = requireSafePath(path.join(planDir, 'seeds', `${seedId as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
- if (!fs.existsSync(safeFilePath)) ioError(`file not found: seeds/${seedId as string}.md`);
+ // #4378 (roll-in): `--seed-id` arrives as whichever id an audit/list
+ // surface published — the canonical identity (frontmatter `id:` or the
+ // derived prefix, e.g. `SEED-081`, `SEED-260914-k3x`) or, for callers
+ // scripted against pre-canonical output, the full filename stem. Resolve
+ // by scanning the seeds directory and matching each candidate's derived
+ // identity (falling back to the literal stem), then prove the winner with
+ // requireSafePath exactly like every other acknowledge target. The direct
+ // `seeds/.md` build stays as the final fallback so a genuine
+ // miss still reports the same "file not found" error as before.
+ const seedsAckDir = path.join(planDir, 'seeds');
+ let ackCandidate: string | null = null;
+ let ackEntries: string[] = [];
+ try {
+ ackEntries = fs.readdirSync(seedsAckDir).filter((n) => n.startsWith('SEED-') && n.endsWith('.md'));
+ } catch {
+ ackEntries = [];
+ }
+ for (const name of ackEntries) {
+ const stem = path.basename(name, '.md');
+ let fmId: unknown;
+ try {
+ const rawAck = platformReadSync(path.join(seedsAckDir, name));
+ if (rawAck !== null) fmId = extractFrontmatter(normalizeLineEndings(rawAck), path.join(seedsAckDir, name)).id;
+ } catch {
+ fmId = undefined;
+ }
+ const { seed_id: derivedAckId } = commandsModule.deriveSeedIdentity(stem, fmId);
+ if (derivedAckId === seedId || stem === seedId) {
+ ackCandidate = name;
+ break;
+ }
+ }
+ if (ackCandidate !== null) {
+ safeFilePath = requireSafePath(path.join(seedsAckDir, ackCandidate), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
+ } else {
+ safeFilePath = requireSafePath(path.join(seedsAckDir, `${seedId as string}.md`), planDir, 'audit acknowledge target', PathAcceptance.AbsoluteInsideRoot);
+ ioError(`file not found: seeds/${seedId as string}.md`);
+ }
const content = fs.readFileSync(safeFilePath, 'utf-8');
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
} else if (category === 'todos') {
diff --git a/src/commands.cts b/src/commands.cts
index 756e095ae..e00cf5b4b 100644
--- a/src/commands.cts
+++ b/src/commands.cts
@@ -343,25 +343,43 @@ function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void
* displayed field is passed through sanitizeForDisplay and each file path is
* validated with requireSafePath before reading. Read-only — never mutates.
*/
+/**
+ * Seed id grammars. `SEED-YYMMDD-xxx` (date + 3 base36 chars, the shape
+ * `.planning/quick/` uses) is what plant-seed has minted since #4378 removed
+ * the shared `wc -l` counter; `SEED-NNN` is the legacy counter form, which
+ * keeps parsing forever — existing seeds must never lose their identity.
+ *
+ * Known (theoretical, documented-not-fixed per #4378 review): a frontmatter-less
+ * legacy file whose counter is exactly 6 digits and whose slug opens with
+ * exactly 3 base36 chars parses as new-format. Requires a counter >= 100000 AND
+ * a missing frontmatter id; with frontmatter the legacy id always wins.
+ */
+const CANONICAL_SEED_ID_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)$/i;
+const SEED_ID_PREFIX_RE = /^(SEED-(?:\d{6}-[a-z0-9]{3}|\d+))/i;
+const SEED_SLUG_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)-(.+)$/i;
+
/**
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
*
- * seed_id: frontmatter `id:` when it matches `SEED-NNN`, else the numeric prefix
- * of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
- * remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
- * `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
+ * seed_id: frontmatter `id:` when it matches a seed id grammar (`SEED-YYMMDD-xxx`
+ * or legacy `SEED-NNN`), else the id prefix of the filename (`SEED-…-`),
+ * else the whole stem. The prefix fallback must keep the FULL new-format id —
+ * truncating at the date gives every same-day seed the same id (#4378).
+ * slug: the descriptive remainder after the id, else the stem with a leading
+ * `SEED-` stripped. `rawFmId` is `unknown` because frontmatter values are not
+ * guaranteed strings.
*/
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
let seedId: string;
- if (/^SEED-\d+$/i.test(fmId)) {
+ if (CANONICAL_SEED_ID_RE.test(fmId)) {
seedId = fmId;
} else {
- const numMatch = stem.match(/^(SEED-\d+)/i);
- seedId = numMatch ? numMatch[1] : stem;
+ const prefixMatch = stem.match(SEED_ID_PREFIX_RE);
+ seedId = prefixMatch ? prefixMatch[1] : stem;
}
- const slugMatch = stem.match(/^SEED-\d+-(.+)$/i);
+ const slugMatch = stem.match(SEED_SLUG_RE);
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
return { seed_id: seedId, slug };
}
@@ -414,9 +432,11 @@ function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolea
// sanitizeForDisplay is for output, not comparison.
if (wantStatus && status !== wantStatus) continue;
- // Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
- // back to the numeric prefix of the filename, then to the whole stem. The
- // descriptive remainder of the filename (`SEED-NNN-.md`) is the slug.
+ // Canonical seed ids are `SEED-YYMMDD-xxx` (frontmatter `id:`, what
+ // plant-seed has minted since #4378) or legacy `SEED-NNN`; deriveSeedIdentity
+ // owns that grammar. Fall back to the id prefix of the filename, then to the
+ // whole stem. The descriptive remainder of the filename (`SEED-…-.md`)
+ // is the slug.
const stem = path.basename(entry.name, '.md');
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
diff --git a/tests/audit-command-cutover.test.cjs b/tests/audit-command-cutover.test.cjs
index 0d78fe7f5..f4622eb9c 100644
--- a/tests/audit-command-cutover.test.cjs
+++ b/tests/audit-command-cutover.test.cjs
@@ -2567,3 +2567,107 @@ describe('bug #950: quick-task SUMMARY must carry status: complete', () => {
});
});
}
+
+
+// ────────────────────────────────────────────────────────────────────────
+// #4378 — audit seed identity agrees with list-seeds; acknowledge resolves
+// by canonical id (legacy stems still resolve for back-compat).
+// ────────────────────────────────────────────────────────────────────────
+{
+ const fs = require('node:fs');
+ const path = require('node:path');
+ const { cleanup } = require('./helpers.cjs');
+
+ function readJson4546(result) {
+ assert.ok(result.success, `command must succeed. stdout: ${result.output}\nstderr: ${result.error}`);
+ return JSON.parse(result.output);
+ }
+
+ function auditJson(tmpDir) {
+ return readJson4546(runGsdTools(['audit-open', '--json'], tmpDir));
+ }
+
+ function ack4546(tmpDir, args) {
+ return runGsdTools(['audit-open', 'acknowledge', ...args, '--json'], tmpDir);
+ }
+
+ describe('audit seed identity agrees with list-seeds (#4378)', () => {
+ let tmpDir;
+
+ beforeEach(() => { tmpDir = createTempProject('gsd-4378-seedident-'); });
+ afterEach(() => { cleanup(tmpDir); });
+
+ function planningPath(...segs) {
+ return path.join(tmpDir, '.planning', ...segs);
+ }
+
+ function seedItem(output, seedId) {
+ const item = output.items.seeds.find((s) => s.seed_id === seedId);
+ assert.ok(item, `expected a seed item with seed_id ${seedId}; got: ${JSON.stringify(output.items.seeds.map((s) => s.seed_id))}`);
+ return item;
+ }
+
+ test('legacy seed publishes the canonical frontmatter id, not the fused filename stem', () => {
+ const seedsDir = planningPath('seeds');
+ fs.mkdirSync(seedsDir, { recursive: true });
+ fs.writeFileSync(path.join(seedsDir, 'SEED-081-region-becomes.md'),
+ '---\nid: SEED-081\nstatus: dormant\n---\n# SEED-081: region idea\n', 'utf8');
+
+ const output = auditJson(tmpDir);
+ const item = seedItem(output, 'SEED-081');
+ assert.strictEqual(item.slug, 'region-becomes',
+ 'slug is the remainder after the canonical id, matching list-seeds');
+ assert.ok(!output.items.seeds.some((s) => s.seed_id === 'SEED-081-region-becomes'),
+ 'the fused filename stem must not resurface as a second id');
+ });
+
+ test('date-suffixed seed publishes its full id (never truncated at the date prefix)', () => {
+ const seedsDir = planningPath('seeds');
+ fs.mkdirSync(seedsDir, { recursive: true });
+ fs.writeFileSync(path.join(seedsDir, 'SEED-260914-k3x-my-slug.md'),
+ '---\nid: SEED-260914-k3x\nstatus: dormant\n---\n# SEED-260914-k3x: idea\n', 'utf8');
+
+ const output = auditJson(tmpDir);
+ const item = seedItem(output, 'SEED-260914-k3x');
+ assert.strictEqual(item.slug, 'my-slug');
+ });
+
+ test('acknowledge resolves the canonical id to the real file and writes the marker', () => {
+ const seedsDir = planningPath('seeds');
+ fs.mkdirSync(seedsDir, { recursive: true });
+ const legacyFile = path.join(seedsDir, 'SEED-081-region-becomes.md');
+ fs.writeFileSync(legacyFile,
+ '---\nid: SEED-081\nstatus: dormant\n---\n# SEED-081: region idea\n', 'utf8');
+
+ const result = ack4546(tmpDir, ['--category', 'seeds', '--seed-id', 'SEED-081', '--milestone', 'v1.0', '--at', '2026-09-15']);
+ assert.ok(result.success, `acknowledge by canonical id must succeed. stderr: ${result.error}`);
+ assert.match(fs.readFileSync(legacyFile, 'utf-8'), /^status: dormant$/m,
+ 'verdict-preserving: the seed status line must be unchanged');
+
+ const after = auditJson(tmpDir);
+ assert.equal(after.counts.seeds, 0, 'acknowledged seed drops out of counts');
+ });
+
+ test('full filename stem still resolves (back-compat with pre-canonical callers)', () => {
+ const seedsDir = planningPath('seeds');
+ fs.mkdirSync(seedsDir, { recursive: true });
+ fs.writeFileSync(path.join(seedsDir, 'SEED-081-region-becomes.md'),
+ '---\nid: SEED-081\nstatus: dormant\n---\nbody\n', 'utf8');
+
+ const result = ack4546(tmpDir, ['--category', 'seeds', '--seed-id', 'SEED-081-region-becomes', '--milestone', 'v1.0', '--at', '2026-09-15']);
+ assert.ok(result.success, `acknowledge by legacy stem must succeed. stderr: ${result.error}`);
+ });
+
+ test('an unknown seed id still fails with the file-not-found error', () => {
+ const seedsDir = planningPath('seeds');
+ fs.mkdirSync(seedsDir, { recursive: true });
+ fs.writeFileSync(path.join(seedsDir, 'SEED-081-region-becomes.md'),
+ '---\nid: SEED-081\nstatus: dormant\n---\nbody\n', 'utf8');
+
+ const result = ack4546(tmpDir, ['--category', 'seeds', '--seed-id', 'SEED-999', '--milestone', 'v1.0', '--at', '2026-09-15']);
+ assert.equal(result.success, false, 'an unresolvable id must fail');
+ assert.match(String(result.error), /file not found: seeds\/SEED-999\.md/,
+ 'the error names the unresolvable id exactly as before');
+ });
+ });
+}
diff --git a/tests/check-predicate.test.cjs b/tests/check-predicate.test.cjs
index d72d30828..8f8b9af42 100644
--- a/tests/check-predicate.test.cjs
+++ b/tests/check-predicate.test.cjs
@@ -22,13 +22,18 @@ const os = require('os');
const { evaluatePredicate } = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
const { buildPredicateDeps, parsePredicateFlags } = require('../gsd-core/bin/lib/check-command-router.cjs');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
+const { PROBE_TIMEOUT_MS } = require('./helpers/timeouts.cjs');
/**
* A real, bounded `sh -c` subprocess spawned via the production
* runBoundedShell dependency -- the describe block's own name is "real
* bounded sh -c subprocess."
*/
-const BOUNDED_SHELL_PROBE_TIMEOUT_MS = 5000;
+// #4378 (windows conformance lane): the local 5000ms bound timed out on a
+// cold sh.exe spawn under windows-latest shard load while the identical code
+// passed twice earlier the same day -- the probe now uses the class norm
+// (tests/helpers/timeouts.cjs PROBE_TIMEOUT_MS) instead of a local override.
+const BOUNDED_SHELL_PROBE_TIMEOUT_MS = PROBE_TIMEOUT_MS;
/**
* The same runBoundedShell call as BOUNDED_SHELL_PROBE_TIMEOUT_MS, but
diff --git a/tests/list-seeds.property.test.cjs b/tests/list-seeds.property.test.cjs
index bbfb4b141..ed8e5bf30 100644
--- a/tests/list-seeds.property.test.cjs
+++ b/tests/list-seeds.property.test.cjs
@@ -87,4 +87,117 @@ describe('list-seeds: deriveSeedIdentity properties', () => {
)
);
});
+
+ // ── #4378: the new-format grammar `SEED-YYMMDD-xxx` (date + 3 base36 chars) ──
+
+ // New-format short suffix: exactly 6 digits, hyphen, exactly 3 lowercase base36.
+ const seedDate = fc.integer({ min: 0, max: 99 })
+ .map((n) => String(n).padStart(2, '0'))
+ .chain((yy) =>
+ fc.integer({ min: 1, max: 12 }).map((m) => yy + String(m).padStart(2, '0'))
+ .chain((ym) =>
+ fc.integer({ min: 1, max: 31 }).map((d) => ym + String(d).padStart(2, '0'))
+ )
+ );
+ const seedSuffix = fc.tuple(
+ fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
+ fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
+ fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')),
+ ).map(([a, b, c]) => a + b + c);
+
+ // (e) Canonical new format: frontmatter id wins; slug is the filename remainder.
+ test('property: new-format id `SEED-YYMMDD-xxx` round-trips (#4378)', () => {
+ fc.assert(
+ fc.property(seedDate, seedSuffix, slug, (date, suf, s) => {
+ const id = `SEED-${date}-${suf}`;
+ const stem = `${id}-${s}`;
+ const result = deriveSeedIdentity(stem, id);
+ assert.strictEqual(result.seed_id, id);
+ assert.strictEqual(result.slug, s);
+ })
+ );
+ });
+
+ // (f) The filename-prefix fallback must keep the FULL new-format id. Truncating
+ // at `SEED-` (the date) gives every same-day seed the same id — the
+ // exact ambiguity #4378 files.
+ test('property: missing id falls back to the full new-format prefix (#4378)', () => {
+ fc.assert(
+ fc.property(
+ seedDate,
+ seedSuffix,
+ slug,
+ fc.oneof(fc.constant(undefined), fc.constant(''), fc.constant(42)),
+ (date, suf, s, badId) => {
+ const stem = `SEED-${date}-${suf}-${s}`;
+ const result = deriveSeedIdentity(stem, badId);
+ assert.strictEqual(result.seed_id, `SEED-${date}-${suf}`);
+ assert.strictEqual(result.slug, s);
+ }
+ )
+ );
+ });
+
+ // (g) Width boundaries — the new grammar is exactly 6 digits + exactly 3
+ // base36 chars; off-by-one widths must resolve through the documented
+ // grammar branches, never by mis-parsing as a different seed's id
+ // (CLAUDE.md: boundary coverage at limit-1 / limit / limit+1). Verified
+ // against the real module: the SLUG regex's alternation backtracks to the
+ // legacy branch whenever the canonical branch cannot complete, so the slug
+ // is always the remainder after the legacy numeric prefix for these
+ // off-grammar stems.
+ describe('width boundaries (limit-1 / limit+1 vs the new grammar)', () => {
+ test('5-digit date (limit-1) parses as legacy', () => {
+ const r = deriveSeedIdentity('SEED-26091-k3x-slug', 'SEED-26091');
+ assert.strictEqual(r.seed_id, 'SEED-26091');
+ assert.strictEqual(r.slug, 'k3x-slug');
+ });
+
+ test('7-digit date (limit+1) parses as legacy (the 7th digit breaks the {6}-dash anchor)', () => {
+ const r = deriveSeedIdentity('SEED-2609147-k3x-slug', 'SEED-2609147');
+ assert.strictEqual(r.seed_id, 'SEED-2609147');
+ assert.strictEqual(r.slug, 'k3x-slug');
+ });
+
+ test('4-char suffix (limit+1): canonical frontmatter wins; without frontmatter the id prefix absorbs exactly 3 suffix chars', () => {
+ // Off-grammar input is never minted by the writer (it length-checks the
+ // draw), so this pins the parser's documented greedy-then-legacy
+ // behavior rather than a contract the writer can produce.
+ assert.deepStrictEqual(
+ deriveSeedIdentity('SEED-260914-k3xy-slug', 'SEED-260914'),
+ { seed_id: 'SEED-260914', slug: 'k3xy-slug' }
+ );
+ assert.deepStrictEqual(
+ deriveSeedIdentity('SEED-260914-k3xy-slug', ''),
+ { seed_id: 'SEED-260914-k3x', slug: 'k3xy-slug' },
+ 'the prefix fallback has no trailing anchor, so the new-format branch absorbs exactly 3 suffix chars; the slug regex backtracks to legacy and keeps the whole remainder'
+ );
+ });
+
+ test('2-char suffix (limit-1) never parses as new-format', () => {
+ const withFm = deriveSeedIdentity('SEED-260914-k3', 'SEED-260914-k3');
+ assert.strictEqual(withFm.seed_id, 'SEED-260914',
+ 'a frontmatter id matching NO grammar is ignored; the filename fallback applies');
+ assert.strictEqual(withFm.slug, 'k3');
+ const noFm = deriveSeedIdentity('SEED-260914-k3', '');
+ assert.strictEqual(noFm.seed_id, 'SEED-260914');
+ assert.strictEqual(noFm.slug, 'k3');
+ });
+
+ test('property: a 5-digit date (below the {6} width) always resolves to the legacy numeric prefix', () => {
+ fc.assert(
+ fc.property(
+ fc.integer({ min: 10000, max: 99999 }), // 5-digit date, below {6}
+ fc.stringMatching(/^[a-z0-9]{2}([a-z0-9])?$/), // 2 or 4 suffix chars
+ slug,
+ (date, suf, s) => {
+ const stem = `SEED-${date}-${suf}-${s}`;
+ const r = deriveSeedIdentity(stem, '');
+ assert.strictEqual(r.seed_id, `SEED-${date}`,
+ 'a short date can never start a new-format id');
+ }
+ )
+ );
+ });
+ });
});
diff --git a/tests/list-seeds.test.cjs b/tests/list-seeds.test.cjs
index d7b7677cb..38e318970 100644
--- a/tests/list-seeds.test.cjs
+++ b/tests/list-seeds.test.cjs
@@ -213,4 +213,70 @@ describe('list-seeds command', () => {
assert.ok(result.success, `Command failed: ${result.error}`);
assert.strictEqual(result.output.trim(), '1');
});
+
+ // ── #4378: seed ids are `SEED-YYMMDD-xxx` (date + random base36), not a count ──
+
+ test('new-format id (SEED-YYMMDD-xxx) is canonical, not truncated to its date prefix (#4378)', () => {
+ writeSeed(tmpDir, 'SEED-260914-k3x-my-slug.md',
+ { id: 'SEED-260914-k3x', status: 'dormant', planted: '2026-09-14' },
+ 'SEED-260914-k3x: my idea');
+ const output = JSON.parse(runGsdTools('list-seeds', tmpDir).output);
+ assert.strictEqual(output.count, 1);
+ const s = output.seeds[0];
+ // The filename-prefix fallback matches `SEED-` and would truncate a
+ // new-format id to its date (`SEED-260914`), which is exactly the ambiguity
+ // #4378 files: two same-day seeds then share one id.
+ assert.strictEqual(s.seed_id, 'SEED-260914-k3x');
+ assert.strictEqual(s.slug, 'my-slug');
+ });
+
+ test('same-day seeds with distinct suffixes list as distinct ids (#4378)', () => {
+ // The reported incident: two workstreams plant before either merges and the
+ // counting scheme gives both the same number. With collision-free ids the
+ // reader must surface two DISTINCT ids — one id must never have two answers.
+ writeSeed(tmpDir, 'SEED-260914-k3x-my-slug.md',
+ { id: 'SEED-260914-k3x', status: 'dormant' }, 'SEED-260914-k3x: my idea');
+ writeSeed(tmpDir, 'SEED-260914-b2c-other-slug.md',
+ { id: 'SEED-260914-b2c', status: 'dormant' }, 'SEED-260914-b2c: other idea');
+ const output = JSON.parse(runGsdTools('list-seeds', tmpDir).output);
+ assert.strictEqual(output.count, 2);
+ const ids = output.seeds.map(s => s.seed_id).sort();
+ assert.deepStrictEqual(ids, ['SEED-260914-b2c', 'SEED-260914-k3x']);
+ const slugs = output.seeds.map(s => s.slug).sort();
+ assert.deepStrictEqual(slugs, ['my-slug', 'other-slug']);
+ });
+
+ test('legacy counter id and new-format id coexist (#4378)', () => {
+ writeSeed(tmpDir, 'SEED-081-region.md',
+ { id: 'SEED-081', status: 'dormant' }, 'SEED-081: region idea');
+ writeSeed(tmpDir, 'SEED-260914-k3x-fresh.md',
+ { id: 'SEED-260914-k3x', status: 'dormant' }, 'SEED-260914-k3x: fresh idea');
+ const output = JSON.parse(runGsdTools('list-seeds', tmpDir).output);
+ assert.strictEqual(output.count, 2);
+ const byId = Object.fromEntries(output.seeds.map(s => [s.seed_id, s]));
+ assert.strictEqual(byId['SEED-081'].slug, 'region');
+ assert.strictEqual(byId['SEED-260914-k3x'].slug, 'fresh');
+ });
+
+ test('filename fallback keeps the full new-format id (not just the date prefix) (#4378)', () => {
+ fs.writeFileSync(path.join(seedsDir(tmpDir), 'SEED-260914-k3x-bare.md'),
+ 'no frontmatter, no heading\n');
+ const output = JSON.parse(runGsdTools('list-seeds', tmpDir).output);
+ assert.strictEqual(output.count, 1);
+ const s = output.seeds[0];
+ assert.strictEqual(s.seed_id, 'SEED-260914-k3x');
+ assert.strictEqual(s.slug, 'bare');
+ });
+
+ test('uppercase new-format id is canonical end-to-end (#4378)', () => {
+ // The docs display SEED-YYMMDD-XXX and the writer's enrich path is
+ // uppercase-tolerant, so the reader must be too — an uppercase id must
+ // survive verbatim, never be truncated to its date prefix.
+ writeSeed(tmpDir, 'SEED-260914-K3X-Upper.md',
+ { id: 'SEED-260914-K3X', status: 'dormant' }, 'SEED-260914-K3X: upper');
+ const output = JSON.parse(runGsdTools('list-seeds', tmpDir).output);
+ assert.strictEqual(output.count, 1);
+ assert.strictEqual(output.seeds[0].seed_id, 'SEED-260914-K3X');
+ assert.strictEqual(output.seeds[0].slug, 'Upper');
+ });
});
diff --git a/tests/plant-seed-id.test.cjs b/tests/plant-seed-id.test.cjs
new file mode 100644
index 000000000..094c54098
--- /dev/null
+++ b/tests/plant-seed-id.test.cjs
@@ -0,0 +1,249 @@
+'use strict';
+
+/**
+ * Writer-contract tests for seed id generation (#4378).
+ *
+ * Defect: plant-seed.md derived the next seed id from `ls | wc -l` — a count of
+ * files the local worktree happens to see. Two workstreams planting before
+ * either merges computed the same id and git merged both files silently.
+ *
+ * Contract shipped by the fix:
+ * 1. `generate-seed-id` derives `SEED-YYMMDD-xxx` from the local date plus a
+ * 3-char random base36 suffix — computable in one worktree alone — with a
+ * loud failure when the suffix cannot be drawn, a same-day regen guard,
+ * and NO shared counter.
+ * 2. The `parse-idea` enrich pattern is anchored to the `--enrich` flag and
+ * captures the COMPLETE id, uppercase-tolerant (legacy `SEED-NNN` still
+ * resolves) — writer and reader grammars must not diverge (the reader
+ * grammar is pinned behaviorally in tests/list-seeds.test.cjs /
+ * .property.test.cjs on the SAME sample ids, and the parity property at
+ * the bottom of this file proves every id the writer grammar can mint
+ * round-trips through the reader). A truncated or ambiguous target fails
+ * closed instead of enriching an arbitrary same-day seed.
+ * 3. No counting-era placeholder (`SEED-{PADDED}`) survives anywhere in the
+ * file — a stale placeholder would write malformed ids at runtime.
+ */
+
+const { describe, test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const fc = require('./helpers/fast-check-setup.cjs');
+
+const ROOT = path.join(__dirname, '..');
+const PLANT_SEED_PATH = path.join(ROOT, 'gsd-core', 'workflows', 'plant-seed.md');
+
+// allow-test-rule: source-text-is-the-product (#4378)
+// The readFileSync below is the marker's suppression site: plant-seed.md is
+// runtime-loaded text — the workflow IS its markdown — so asserting on the
+// shipped `generate-seed-id` and `parse-idea` text tests the deployed contract
+// (the alternative — executing cross-worktree collisions end-to-end — is not
+// reproducible in a single checkout).
+function readPlantSeedNormalized() {
+ const src = fs.readFileSync(PLANT_SEED_PATH, 'utf8');
+ return src.replace(/\r\n/g, '\n');
+}
+
+/** Extract the body of a named block, or throw naming the missing step. */
+function stepBlock(src, stepName) {
+ const start = src.indexOf(``);
+ assert.ok(start !== -1, `plant-seed.md must contain `);
+ const end = src.indexOf('', start);
+ assert.ok(end !== -1, ` must be closed`);
+ return src.slice(start, end);
+}
+
+describe('plant-seed id contract (#4378)', () => {
+ const src = readPlantSeedNormalized();
+
+ test('generate-seed-id derives the id from local date + random, never a shared count (#4378)', () => {
+ const block = stepBlock(src, 'generate-seed-id');
+
+ // Date component: local YYMMDD.
+ assert.match(
+ block,
+ /date \+%y%m%d/,
+ 'generate-seed-id must derive the date part via `date +%y%m%d`'
+ );
+
+ // Random base36 suffix: exactly 3 chars of [a-z0-9]. urandom is present on
+ // every supported platform (macOS, Linux, Git Bash). `tr` reads an infinite
+ // stream, so the pipeline takes a harmless SIGPIPE once `head -c` has its
+ // bytes — `|| true` keeps that from aborting the step under pipefail.
+ assert.match(
+ block,
+ /tr -dc 'a-z0-9'/,
+ 'generate-seed-id must draw the suffix from [a-z0-9] (base36)'
+ );
+ assert.match(
+ block,
+ /head -c 3/,
+ 'generate-seed-id must take exactly 3 random characters'
+ );
+ assert.match(
+ block,
+ /\|\| true/,
+ 'the suffix draw must tolerate the expected SIGPIPE under pipefail'
+ );
+ // A missing /dev/urandom must fail LOUDLY, not mint `SEED--` (whose
+ // ids would all collapse to the bare date — the #4378 collision reborn).
+ assert.match(
+ block,
+ /\[ \$\{#SEED_SUFFIX\} -ne 3 \]/,
+ 'the drawn suffix must be length-checked; an empty suffix must abort the step'
+ );
+ assert.match(
+ block,
+ /could not draw a random id suffix/,
+ 'the empty-suffix abort must say so on stderr'
+ );
+ assert.match(
+ block,
+ /SEED-\$\{SEED_DATE\}-\$\{SEED_SUFFIX\}/,
+ 'generate-seed-id must assemble SEED--'
+ );
+
+ // Same-day regen guard — as a find existence test, never `ls `
+ // (under a stray nullglob that shape silently degenerates: #3409 drift
+ // guard, Detector B) — with a loud terminal failure if the retry also
+ // collides.
+ assert.match(
+ block,
+ /find \.planning\/seeds -maxdepth 1 -name "\$\{SEED_ID\}-\*\.md"/,
+ 'generate-seed-id must check the freshly drawn id against existing same-day seeds via find'
+ );
+ assert.match(
+ block,
+ /could not draw an unused seed id/,
+ 'a regen retry that also collides must abort loudly, not ship a duplicate'
+ );
+ assert.doesNotMatch(
+ block,
+ /ls .*SEED_ID.*\*\.md/,
+ 'the regen guard must not use the `ls ` shape (#3409 Detector B)'
+ );
+
+ // The shared counter must be GONE — every counting idiom of the old step.
+ assert.doesNotMatch(block, /wc -l/, 'the `wc -l` counter must not remain');
+ assert.doesNotMatch(
+ block,
+ /NEXT=\$\(\(EXISTING/,
+ 'the `NEXT=$((EXISTING + 1))` derivation must not remain'
+ );
+ assert.doesNotMatch(
+ src,
+ /printf "%03d" \$NEXT/,
+ 'the `%03d` padding of the counted id must not remain anywhere in the file'
+ );
+ });
+
+ test('enrich flag parsing is flag-anchored, complete, and uppercase-tolerant (#4378)', () => {
+ const block = stepBlock(src, 'parse-idea');
+ const m = block.match(/grep -oE '([^']+)'/);
+ assert.ok(m, 'parse-idea must extract the enrich target via `grep -oE`');
+ const pattern = m[1];
+
+ // The extraction must be ANCHORED to the --enrich flag: a leftmost
+ // `SEED-[0-9]+` would grab a seed id mentioned anywhere in $ARGUMENTS
+ // instead of the one the flag names (#4378 review).
+ assert.match(
+ pattern,
+ /\\-\\-enrich/,
+ 'the extractor pattern must anchor on the --enrich flag'
+ );
+ // The pattern is executed by grep -E at runtime; exercise the same grammar
+ // through the JS regex engine, translating the one POSIX class grep
+ // understands and JS does not (`[[:space:]]` -> `[ \t]`).
+ const jsPattern = pattern.replace(/\[\[:space:\]\]/g, '[ \\t]');
+ const re = new RegExp(jsPattern);
+ const targetOf = (args) => {
+ const match = args.match(re);
+ assert.ok(match, `pattern must match: ${args}`);
+ return match[0].replace(/^.*[ \t]/, '');
+ };
+
+ // New-format id: the FULL id must be captured, never truncated at the date
+ // — and uppercase-tolerant, since the docs display SEED-YYMMDD-XXX.
+ assert.strictEqual(
+ targetOf('--seed --enrich SEED-260914-K3X'),
+ 'SEED-260914-K3X',
+ 'an uppercase new-format id must be captured in full'
+ );
+ assert.strictEqual(
+ targetOf('--seed --enrich SEED-260914-k3x'),
+ 'SEED-260914-k3x',
+ 'a lowercase new-format id must be captured in full'
+ );
+ // Legacy id: still resolves, still captured whole.
+ assert.strictEqual(targetOf('--seed --enrich SEED-081'), 'SEED-081');
+
+ // A seed id mentioned in the idea text must NOT be picked up when the flag
+ // names a different seed.
+ assert.strictEqual(
+ targetOf('"see SEED-5 first" --enrich SEED-7'),
+ 'SEED-7',
+ 'the extractor must follow the --enrich flag, not the leftmost id'
+ );
+
+ // Truncated/ambiguous targets fail closed instead of enriching an
+ // arbitrary same-day seed (`head -1` over a date glob).
+ assert.match(
+ block,
+ /matches multiple seed files/,
+ 'a target matching several seed files must fail closed, naming the files'
+ );
+ assert.match(
+ block,
+ /no seed file matches/,
+ 'a target matching no seed file must fail closed instead of falling back'
+ );
+ assert.match(
+ block,
+ /-gt 1/,
+ 'the ambiguity check must compare the match count, not take head -1'
+ );
+ });
+
+ test('no counting-era placeholder remains (#4378)', () => {
+ assert.doesNotMatch(
+ src,
+ /SEED-\{PADDED\}/,
+ 'the SEED-{PADDED} placeholder would write malformed ids at runtime; write-seed/confirm must use {SEED_ID}'
+ );
+ assert.match(src, /\{SEED_ID\}/, 'write-seed/confirm must reference {SEED_ID}');
+ });
+
+ test('parity: every id the writer grammar can mint round-trips through the reader (#4378)', () => {
+ // The writer (plant-seed.md generate-seed-id) and the reader
+ // (deriveSeedIdentity) are parallel surfaces owning one grammar — per
+ // CLAUDE.md's generative-fix rule their agreement must be ASSERTED, not
+ // assumed. The widths are parsed out of the shipped MINT sites (the
+ // `date +%y%m%d` directive and the `head -c N` draw) so a width drift on
+ // either side fails here. (The enrich matcher is deliberately wider — it
+ // must also accept legacy `SEED-NNN` — so it is not the parity source.)
+ const { deriveSeedIdentity } = require('../gsd-core/bin/lib/commands.cjs');
+ const genBlock = stepBlock(src, 'generate-seed-id');
+ const suffixWidth = Number(genBlock.match(/head -c (\d+)/)?.[1]);
+ const dateFormat = genBlock.match(/date \+(\S+)/)?.[1] ?? '';
+ const dateWidth = (dateFormat.match(/%[ymd]/g) ?? []).length * 2;
+ assert.ok(dateWidth > 0, 'writer mint block must declare the date format via %y%m%d');
+ assert.ok(suffixWidth > 0, 'writer mint block must declare the suffix width via head -c N');
+
+ const digits = fc.integer({ min: 0, max: 10 ** dateWidth - 1 })
+ .map((n) => String(n).padStart(dateWidth, '0'));
+ const base36 = fc.tuple(
+ ...Array.from({ length: suffixWidth }, () =>
+ fc.constantFrom(...'abcdefghijklmnopqrstuvwxyz0123456789'.split('')))
+ ).map((parts) => parts.join(''));
+
+ fc.assert(
+ fc.property(digits, base36, fc.stringMatching(/^[a-z0-9][a-z0-9-]{0,20}$/), (date, suf, slug) => {
+ const id = `SEED-${date}-${suf}`;
+ const result = deriveSeedIdentity(`${id}-${slug}`, id);
+ assert.strictEqual(result.seed_id, id, `reader must accept the writer's id ${id}`);
+ assert.strictEqual(result.slug, slug);
+ }),
+ { numRuns: 200 }
+ );
+ });
+});