* test(#4378): regression tests for collision-free seed ids * fix(#4378): mint collision-free SEED-YYMMDD-xxx ids, not a shared count plant-seed derived the next seed id from 'ls .planning/seeds/SEED-*.md | wc -l'. .planning/seeds/ is shared but each worktree only sees what has merged, so two workstreams planting before either merges computed the same id and git merged both files silently. The id is now the local date plus a 3-char random base36 suffix -- the shape .planning/quick/ already uses -- computed from knowledge one worktree has alone, with a same-day regen guard. deriveSeedIdentity learns the new canonical grammar alongside legacy SEED-NNN (whose parsing never changes), the --enrich parser and the filename-prefix fallback keep the full new-format id, and the docs that state the filename shape move to it. The prefix fallback previously truncated any non-pure-numeric id at 'SEED-<digits>' -- the same one-id-two-answers ambiguity the issue reports, reproduced one level down. * fix(#4378): harden seed id generation per adversarial review - parse-idea: anchor the --enrich extractor to the flag and capture the complete id, uppercase-tolerant; a leftmost 'SEED-[0-9]+' truncated an uppercase or malformed suffix to its date and enriched an arbitrary same-day seed via head -1. Ambiguous and unmatched targets now fail closed instead. - generate-seed-id: tolerate the expected SIGPIPE under pipefail, abort loudly when the suffix cannot be drawn (an empty suffix would collapse every seed's id to the bare date), and run the same-day regen guard as a find existence test (the 'ls <glob>' shape trips the #3409 drift guard and degenerates under a stray nullglob). - deriveSeedIdentity: document the theoretical legacy/new grammar ambiguity (6-digit counter + 3-char base36 slug, no frontmatter). - changeset: state the residual same-day collision bound instead of implying zero. Emitted-Drift-Ack-Growth: plant-seed.md — the counting step became hardened date+random generation with explicit failure modes; growth is the failure handling, not duplicated logic * fix(#4378): address standards and spec review findings - tests: move the allow-test-rule marker to its suppression site (the file-header placement was inert per CONTRIBUTING site-scoping); add width-boundary coverage (5/7-digit dates, 2/4-char suffixes pin the documented branch behavior); add a writer-to-reader parity property that parses the mint widths out of the shipped workflow so the two grammar owners cannot drift; cover uppercase ids end-to-end in the reader. - plant-seed.md: draw/retry restructured as one loop with a loud terminal failure; SEED_SUFX renamed SEED_SUFFIX; regen guard drops the redundant head -1; the ambiguity error no longer advises an impossible 'complete id' for duplicate legacy ids. - commands.cts: refresh the cmdListSeeds comment still describing SEED-NNN as the only canonical form. - changeset: drop the audit claim the spec axis showed to be an overstatement (audit's id display is filename-derived, pre-existing). - remove a stray untracked artifact file swept into the tree. * test(#4378): correct boundary expectations to the module's real branch behavior The first matrix run on the boundary tests caught my hand-trace of the regex branches, not a module defect: the slug regex's alternation backtracks to the legacy branch whenever the canonical branch cannot complete (so the slug is the remainder after the legacy numeric prefix), and the 7-digit case fails the canonical branch at its 7th digit before the dash. Pin the verified values. * docs(#4378): backfill changeset PR number * fix(#4378): audit seed identity uses the canonical grammar Review of this PR found the audit surface publishing a fused filename stem (SEED-081-region for SEED-081-region.md) where list-seeds reports the canonical id -- one id, two answers across surfaces, the same ambiguity class the issue files. scanSeeds now derives identity through the SAME deriveSeedIdentity the list-seeds gate uses (frontmatter id, then filename id-prefix, then stem), and audit-open acknowledge resolves --seed-id by scanning for the derived identity, falling back to the literal stem so callers scripted against pre-canonical output keep working. Roll-in per the fix-inline rule: found during this PR's review, same seed-identity seam. RED probe: pre-fix audit published seed_id SEED-081-region-becomes / slug 081-region-becomes for a legacy seeded file; post-fix SEED-081 / region-becomes, matching list-seeds. * test(#4378): probe timeout uses the class norm after windows-lane timeout The windows conformance shard failed its bounded sh -c probes at the local 5000ms bound (cold sh.exe spawn under shard load) while the identical code passed this PR's two earlier windows waves. The probe now uses PROBE_TIMEOUT_MS from the class-norm module instead of a local override, per the helpers/timeouts.cjs convention. --------- Co-authored-by: sim <sim@local>
This commit is contained in:
5
.changeset/tidy-tigers-forage.md
Normal file
5
.changeset/tidy-tigers-forage.md
Normal file
@@ -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)
|
||||||
@@ -31,7 +31,7 @@ Mode routing:
|
|||||||
| (none) | Structured todo in .planning/todos/ | add-todo |
|
| (none) | Structured todo in .planning/todos/ | add-todo |
|
||||||
| --note | Timestamped note file, list, or promote | note |
|
| --note | Timestamped note file, list, or promote | note |
|
||||||
| --backlog | ROADMAP.md backlog section (999.x) | add-backlog |
|
| --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 | Interactive todo browser + action router | check-todos |
|
||||||
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
|
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
**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`).
|
**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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@@ -1464,7 +1464,7 @@ That third-party dependence is a real trade-off, held honestly rather than paper
|
|||||||
| Artifact | Description |
|
| Artifact | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `.planning/phases/999.x-slug/` | Backlog item directory |
|
| `.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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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-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:
|
Once you've parked a few, audit them on demand instead of waiting for the next milestone to surface them:
|
||||||
|
|
||||||
|
|||||||
@@ -21,4 +21,4 @@ group: v1.27 Features
|
|||||||
| Artifact | Description |
|
| Artifact | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
| `.planning/phases/999.x-slug/` | Backlog item directory |
|
| `.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 |
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ Usage: `/gsd:capture --note promote 3`
|
|||||||
|
|
||||||
Usage: `/gsd:capture --list api`
|
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`
|
Usage: `/gsd:capture --list-seeds dormant`
|
||||||
|
|
||||||
|
|||||||
@@ -444,7 +444,7 @@ List and audit captured seeds (read-only).
|
|||||||
|
|
||||||
- Lists all seeds with ID, status, scope, trigger, and title
|
- Lists all seeds with ID, status, scope, trigger, and title
|
||||||
- Optional status filter (e.g., `/gsd:capture --list-seeds dormant`)
|
- 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`
|
||||||
Usage: `/gsd:capture --list-seeds dormant`
|
Usage: `/gsd:capture --list-seeds dormant`
|
||||||
|
|||||||
@@ -22,9 +22,23 @@ Parse `$ARGUMENTS` for the idea summary.
|
|||||||
First, check for an enrich flag:
|
First, check for an enrich flag:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
if echo "$ARGUMENTS" | grep -qE '\-\-enrich[[:space:]]+SEED-[0-9]+'; then
|
if echo "$ARGUMENTS" | grep -qE '\-\-enrich[[:space:]]+SEED-[0-9]+(-[a-zA-Z0-9]{3})?'; then
|
||||||
ENRICH_TARGET=$(echo "$ARGUMENTS" | grep -oE 'SEED-[0-9]+')
|
# Anchor on the flag and capture the COMPLETE id — uppercase-tolerant, since
|
||||||
SEED_FILE=$(ls .planning/seeds/${ENRICH_TARGET}-*.md 2>/dev/null | head -1)
|
# 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
|
# Skip to enrich-seed step — do not prompt for $IDEA
|
||||||
else
|
else
|
||||||
if [ -n "$ARGUMENTS" ]; then
|
if [ -n "$ARGUMENTS" ]; then
|
||||||
@@ -52,17 +66,39 @@ mkdir -p .planning/seeds
|
|||||||
|
|
||||||
<step name="generate-seed-id">
|
<step name="generate-seed-id">
|
||||||
```bash
|
```bash
|
||||||
# Find next seed number
|
# Seed id: date + 3 random base36 chars (the `.planning/quick/` shape).
|
||||||
EXISTING=$( (ls .planning/seeds/SEED-*.md 2>/dev/null || true) | wc -l )
|
# NO shared counter: `.planning/seeds/` is shared, but each worktree only sees
|
||||||
NEXT=$((EXISTING + 1))
|
# what has merged — counting files collides across parallel workstreams (#4378).
|
||||||
PADDED=$(printf "%03d" $NEXT)
|
# 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' </dev/urandom | head -c 3) || true
|
||||||
|
if [ ${#SEED_SUFFIX} -ne 3 ]; then
|
||||||
|
echo "ERROR: could not draw a random id suffix (is /dev/urandom available?)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
SEED_ID="SEED-${SEED_DATE}-${SEED_SUFFIX}"
|
||||||
|
# Same-day regen guard, written as a find existence test (never `ls <glob>`:
|
||||||
|
# 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.
|
Generate slug from idea summary.
|
||||||
</step>
|
</step>
|
||||||
|
|
||||||
<step name="write-seed">
|
<step name="write-seed">
|
||||||
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
|
- `trigger_when`: default is `"when relevant"` — the seed will surface during any
|
||||||
new-milestone scan; the user can narrow it later via `--enrich`
|
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
|
```markdown
|
||||||
---
|
---
|
||||||
id: SEED-{PADDED}
|
id: {SEED_ID}
|
||||||
status: dormant
|
status: dormant
|
||||||
planted: {ISO date}
|
planted: {ISO date}
|
||||||
planted_during: {current milestone/phase from STATE.md, or "unknown" if not in a GSD project}
|
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
|
scope: unknown
|
||||||
---
|
---
|
||||||
|
|
||||||
# SEED-{PADDED}: {$IDEA}
|
# {SEED_ID}: {$IDEA}
|
||||||
|
|
||||||
## Why This Matters
|
## 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
|
## When to Surface
|
||||||
|
|
||||||
@@ -92,7 +128,7 @@ This seed will surface during `/gsd:new-milestone` when the milestone scope matc
|
|||||||
|
|
||||||
## Scope Estimate
|
## 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
|
## Breadcrumbs
|
||||||
|
|
||||||
@@ -137,7 +173,7 @@ Store relevant file paths as `$BREADCRUMBS`.
|
|||||||
```bash
|
```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
|
_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 "")
|
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.
|
**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
|
|||||||
|
|
||||||
<step name="confirm">
|
<step name="confirm">
|
||||||
```text
|
```text
|
||||||
✅ Seed planted: SEED-{PADDED}
|
✅ Seed planted: {SEED_ID}
|
||||||
|
|
||||||
"{$IDEA}"
|
"{$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.
|
to add trigger conditions, rationale, and scope estimate at your convenience.
|
||||||
|
|
||||||
This seed will surface automatically when you run /gsd:new-milestone.
|
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.**
|
**Optional enrichment — only run this step when `--enrich` flag is present.**
|
||||||
|
|
||||||
If `--enrich` flag is in `$ARGUMENTS`:
|
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:
|
- Ask focused questions to build a complete seed:
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ Mode routing:
|
|||||||
| (none) | Structured todo in .planning/todos/ | add-todo |
|
| (none) | Structured todo in .planning/todos/ | add-todo |
|
||||||
| --note | Timestamped note file, list, or promote | note |
|
| --note | Timestamped note file, list, or promote | note |
|
||||||
| --backlog | ROADMAP.md backlog section (999.x) | add-backlog |
|
| --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 | Interactive todo browser + action router | check-todos |
|
||||||
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
|
| --list-seeds | Read-only seed list/audit (optional status filter) | list-seeds |
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ import planningWorkspace = require('./planning-workspace.cjs');
|
|||||||
const { planningDir, quickDirFrom, todosDir } = planningWorkspace;
|
const { planningDir, quickDirFrom, todosDir } = planningWorkspace;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import frontmatter = require('./frontmatter.cjs');
|
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;
|
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||||
import phaseIdMod = require('./phase-id.cjs');
|
import phaseIdMod = require('./phase-id.cjs');
|
||||||
@@ -800,6 +805,15 @@ function scanTodos(todosBase: string): ScanOutcome<TodoItem> {
|
|||||||
return { items: results, acknowledged };
|
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 ────────────────────────────────────────────────────────────────
|
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -855,16 +869,18 @@ function scanSeeds(planDir: string): ScanOutcome<SeedItem> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract seed_id from filename or frontmatter. The regex match is
|
// #4378 (roll-in): the canonical identity comes from the SAME derivation
|
||||||
// `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
|
// the list-seeds surface uses — frontmatter `id:` when it matches a seed
|
||||||
// but the fallback taken when a filename doesn't fully match — e.g. a
|
// grammar (legacy `SEED-NNN` or date-suffixed `SEED-YYMMDD-xxx`), else the
|
||||||
// `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
|
// filename's id prefix, else the whole stem. The old fused
|
||||||
// the middle, which still passes the `startsWith`/`endsWith` filter above
|
// filename-stem id (e.g. `SEED-081-region` for `SEED-081-region.md`)
|
||||||
// — is the raw, unconstrained basename. Both branches are routed through
|
// disagreed with list-seeds and misfiled deferrals; publishing the
|
||||||
// sanitizeLabel below.
|
// canonical id keeps the two surfaces answering identically. The raw-
|
||||||
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
// basename fallback for control-byte-bearing names is preserved.
|
||||||
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
const stem = path.basename(entry.name, '.md');
|
||||||
const slug = sanitizeLabel(seed_id.replace(/^SEED-/, ''));
|
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 || '');
|
let title = sanitizeForDisplay(fm.title || '');
|
||||||
if (!title) {
|
if (!title) {
|
||||||
@@ -1757,8 +1773,44 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
|||||||
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
|
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
|
||||||
} else if (category === 'seeds') {
|
} else if (category === 'seeds') {
|
||||||
if (!seedId) ioError('--seed-id is required for --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);
|
// #4378 (roll-in): `--seed-id` arrives as whichever id an audit/list
|
||||||
if (!fs.existsSync(safeFilePath)) ioError(`file not found: seeds/${seedId as string}.md`);
|
// 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/<seedId>.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');
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
||||||
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
|
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
|
||||||
} else if (category === 'todos') {
|
} else if (category === 'todos') {
|
||||||
|
|||||||
@@ -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
|
* displayed field is passed through sanitizeForDisplay and each file path is
|
||||||
* validated with requireSafePath before reading. Read-only — never mutates.
|
* 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
|
* 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.
|
* 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
|
* seed_id: frontmatter `id:` when it matches a seed id grammar (`SEED-YYMMDD-xxx`
|
||||||
* of the filename (`SEED-NNN-…`), else the whole stem. slug: the descriptive
|
* or legacy `SEED-NNN`), else the id prefix of the filename (`SEED-…-<slug>`),
|
||||||
* remainder after `SEED-NNN-`, else the stem with a leading `SEED-` stripped.
|
* else the whole stem. The prefix fallback must keep the FULL new-format id —
|
||||||
* `rawFmId` is `unknown` because frontmatter values are not guaranteed strings.
|
* 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 } {
|
function deriveSeedIdentity(stem: string, rawFmId: unknown): { seed_id: string; slug: string } {
|
||||||
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
||||||
let seedId: string;
|
let seedId: string;
|
||||||
if (/^SEED-\d+$/i.test(fmId)) {
|
if (CANONICAL_SEED_ID_RE.test(fmId)) {
|
||||||
seedId = fmId;
|
seedId = fmId;
|
||||||
} else {
|
} else {
|
||||||
const numMatch = stem.match(/^(SEED-\d+)/i);
|
const prefixMatch = stem.match(SEED_ID_PREFIX_RE);
|
||||||
seedId = numMatch ? numMatch[1] : stem;
|
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, '');
|
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
|
||||||
return { seed_id: seedId, slug };
|
return { seed_id: seedId, slug };
|
||||||
}
|
}
|
||||||
@@ -414,9 +432,11 @@ function cmdListSeeds(cwd: string, statusFilter: string | undefined, raw: boolea
|
|||||||
// sanitizeForDisplay is for output, not comparison.
|
// sanitizeForDisplay is for output, not comparison.
|
||||||
if (wantStatus && status !== wantStatus) continue;
|
if (wantStatus && status !== wantStatus) continue;
|
||||||
|
|
||||||
// Canonical seed id is `SEED-NNN` (frontmatter `id:`, e.g. SEED-001). Fall
|
// Canonical seed ids are `SEED-YYMMDD-xxx` (frontmatter `id:`, what
|
||||||
// back to the numeric prefix of the filename, then to the whole stem. The
|
// plant-seed has minted since #4378) or legacy `SEED-NNN`; deriveSeedIdentity
|
||||||
// descriptive remainder of the filename (`SEED-NNN-<slug>.md`) is the slug.
|
// owns that grammar. Fall back to the id prefix of the filename, then to the
|
||||||
|
// whole stem. The descriptive remainder of the filename (`SEED-…-<slug>.md`)
|
||||||
|
// is the slug.
|
||||||
const stem = path.basename(entry.name, '.md');
|
const stem = path.basename(entry.name, '.md');
|
||||||
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@@ -22,13 +22,18 @@ const os = require('os');
|
|||||||
const { evaluatePredicate } = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
|
const { evaluatePredicate } = require('../gsd-core/bin/lib/gate-predicate-evaluator.cjs');
|
||||||
const { buildPredicateDeps, parsePredicateFlags } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
const { buildPredicateDeps, parsePredicateFlags } = require('../gsd-core/bin/lib/check-command-router.cjs');
|
||||||
const { runGsdTools, createTempProject, cleanup } = require('./helpers.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
|
* A real, bounded `sh -c` subprocess spawned via the production
|
||||||
* runBoundedShell dependency -- the describe block's own name is "real
|
* runBoundedShell dependency -- the describe block's own name is "real
|
||||||
* bounded sh -c subprocess."
|
* 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
|
* The same runBoundedShell call as BOUNDED_SHELL_PROBE_TIMEOUT_MS, but
|
||||||
|
|||||||
@@ -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-<digits>` (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');
|
||||||
|
}
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -213,4 +213,70 @@ describe('list-seeds command', () => {
|
|||||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||||
assert.strictEqual(result.output.trim(), '1');
|
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-<digits>` 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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
249
tests/plant-seed-id.test.cjs
Normal file
249
tests/plant-seed-id.test.cjs
Normal file
@@ -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 <step> block, or throw naming the missing step. */
|
||||||
|
function stepBlock(src, stepName) {
|
||||||
|
const start = src.indexOf(`<step name="${stepName}">`);
|
||||||
|
assert.ok(start !== -1, `plant-seed.md must contain <step name="${stepName}">`);
|
||||||
|
const end = src.indexOf('</step>', start);
|
||||||
|
assert.ok(end !== -1, `<step name="${stepName}"> 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-<date>-` (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-<date>-<suffix>'
|
||||||
|
);
|
||||||
|
|
||||||
|
// Same-day regen guard — as a find existence test, never `ls <glob>`
|
||||||
|
// (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 <glob>` 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 }
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user