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 } + ); + }); +});