From b7406b293fc469ab17b3df3947490a0b39697233 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Sun, 6 Sep 2026 08:06:39 -0400 Subject: [PATCH] enhance(#2618): render pending todos as one bounded bullet per todo (#4384) --- .changeset/mellow-koalas-caper.md | 5 + docs/COMMANDS.md | 2 + gsd-core/templates/state.md | 8 +- gsd-core/workflows/add-todo.md | 5 +- gsd-core/workflows/check-todos.md | 6 +- scripts/lint-test-file-count.allowlist.json | 1 + src/init.cts | 137 +++++++++- tests/state-todos-render.test.cjs | 270 ++++++++++++++++++++ 8 files changed, 424 insertions(+), 10 deletions(-) create mode 100644 .changeset/mellow-koalas-caper.md create mode 100644 tests/state-todos-render.test.cjs diff --git a/.changeset/mellow-koalas-caper.md b/.changeset/mellow-koalas-caper.md new file mode 100644 index 000000000..0e348e855 --- /dev/null +++ b/.changeset/mellow-koalas-caper.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 4384 +--- +**Pending todos now render as one bounded bullet per todo in STATE.md.** Each capture used to append to a single run-on sentence in "### Pending Todos", growing unbounded and wrecking `git diff` readability; captures now produce one bullet per todo, capped at 240 characters, with a fail-safe refresh that leaves the section untouched on a malformed lookup. (#2618) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 4f4f41b5b..d45e1346e 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -1938,6 +1938,8 @@ Capture ideas, tasks, notes, and seeds to their appropriate destination. Default **Produces:** `.planning/todos/` (default), note files (--note), ROADMAP.md backlog section (--backlog), `.planning/seeds/SEED-NNN-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 ...`. 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. + ```bash /gsd-capture "Consider adding dark mode support" # Add todo /gsd-capture --note "Caching strategy idea" # Quick note diff --git a/gsd-core/templates/state.md b/gsd-core/templates/state.md index 09a15ad1f..32afd9a90 100644 --- a/gsd-core/templates/state.md +++ b/gsd-core/templates/state.md @@ -172,9 +172,11 @@ Updated after each plan completion. **Decisions:** Reference to PROJECT.md Key Decisions table, plus recent decisions summary for quick access. Full decision log lives in PROJECT.md. **Pending Todos:** Ideas captured via /gsd-add-todo -- Count of pending todos -- Reference to .planning/todos/pending/ -- Brief list if few, count if many (e.g., "5 pending todos — see /gsd:capture --list") +- One bullet per pending todo, rendered by `init.todos`'s `pending_todos_markdown` + (each bullet capped at 240 characters: `- [date] [area] title — [todo file](path) — Needs ...`) +- `None yet.` when there are no pending todos +- No collapse-by-count fallback — every pending todo gets its own line, always + (see #2618 design doc for why a "count if many" fallback was rejected) **Blockers/Concerns:** From "Next Phase Readiness" sections - Issues that affect future work diff --git a/gsd-core/workflows/add-todo.md b/gsd-core/workflows/add-todo.md index bc378b7ea..db1045b6e 100644 --- a/gsd-core/workflows/add-todo.md +++ b/gsd-core/workflows/add-todo.md @@ -145,8 +145,9 @@ files: If `.planning/STATE.md` exists: -1. Use `todo_count` from init context (or re-run `init todos` if count changed) -2. Update "### Pending Todos" under "## Accumulated Context" +1. Re-run `gsd_run query init.todos` to get a fresh, post-write JSON snapshot (the count and rendering must reflect the change just made). +2. **Fail-safe check (#2618):** if the JSON failed to parse, or `pending_read_ok` is not `true`, or `pending_todos_markdown` is not a string, do NOT touch the "### Pending Todos" section — leave it exactly as-is and continue to the next step. A partial or malformed `init.todos` result must never overwrite a good existing section. +3. Otherwise, replace the entire body of "### Pending Todos" (under "## Accumulated Context") with the literal value of `pending_todos_markdown` — verbatim, one bullet per pending todo already rendered and length-capped by `init.todos`. Do not reformat, re-wrap, re-order, or hand-edit the bullets; do not append to the old body — replace it wholesale (this is what makes an old run-on-sentence section get superseded cleanly with no migration step). diff --git a/gsd-core/workflows/check-todos.md b/gsd-core/workflows/check-todos.md index 335d46ff9..99a95bcf9 100644 --- a/gsd-core/workflows/check-todos.md +++ b/gsd-core/workflows/check-todos.md @@ -150,9 +150,11 @@ Return to list_todos step. -After any action that changes todo count: +If `.planning/STATE.md` exists: -Re-run `init todos` to get updated count, then update STATE.md "### Pending Todos" section if exists. +1. Re-run `gsd_run query init.todos` to get a fresh, post-write JSON snapshot (the count and rendering must reflect the change just made). +2. **Fail-safe check (#2618):** if the JSON failed to parse, or `pending_read_ok` is not `true`, or `pending_todos_markdown` is not a string, do NOT touch the "### Pending Todos" section — leave it exactly as-is and continue to the next step. A partial or malformed `init.todos` result must never overwrite a good existing section. +3. Otherwise, replace the entire body of "### Pending Todos" (under "## Accumulated Context") with the literal value of `pending_todos_markdown` — verbatim, one bullet per pending todo already rendered and length-capped by `init.todos`. Do not reformat, re-wrap, re-order, or hand-edit the bullets; do not append to the old body — replace it wholesale (this is what makes an old run-on-sentence section get superseded cleanly with no migration step). diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index b5332bc6c..f81d44ed5 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -123,6 +123,7 @@ "state-prune.test.cjs", "state-rebuild-cli.test.cjs", "state-rebuild.test.cjs", + "state-todos-render.test.cjs", "state-write-path-drift-guard.test.cjs", "state.test.cjs" ], diff --git a/src/init.cts b/src/init.cts index 52a37db13..0192d76ce 100644 --- a/src/init.cts +++ b/src/init.cts @@ -12,6 +12,7 @@ import os from 'node:os'; import { execGit, platformWriteSync, platformReadSync, toNativePath, posixNormalize } from './shell-command-projection.cjs'; import { realClock } from './clock.cjs'; import { escapeRegex } from './pattern.cjs'; +import { collectSection } from './markdown-sectionizer.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- io.cjs is an export= CommonJS module import io = require('./io.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module @@ -2233,15 +2234,118 @@ function cmdInitPhaseOp(cwd: string, phase: string, raw: boolean): void { output(withProjectRoot(cwd, result), raw); } +// #2618: bullet-cap and title-floor for renderPendingTodosMarkdown below. +// 240 matches the bound already vetted by maintainer review on the prior +// attempt at this issue (PR #2662) — re-deriving a different number would be +// pure bikeshedding, not a correctness improvement. See +// .gsd/phase/feat-2618-compact-todo-pointers/40-design.md. +const PENDING_TODO_BULLET_MAX_CHARS = 240; +const PENDING_TODO_TITLE_FLOOR = 15; +const PENDING_TODO_AREA_FLOOR = 3; + +function sanitizePendingTodoInline(value: string): string { + // Defensive: the regex captures that populate title/area/needs can only + // ever match a single line, so this is belt-and-suspenders against any + // future non-regex-sourced input, not a reachable case today. + return value.replace(/[\r\n]+/g, ' ').trim(); +} + +function truncatePendingTodoText(value: string, maxLen: number): string { + if (value.length <= maxLen) return value; + if (maxLen <= 1) return value.slice(0, Math.max(0, maxLen)); + return `${value.slice(0, maxLen - 1)}…`; +} + +/** + * #2618: pure renderer for STATE.md's "### Pending Todos" section BODY (not + * the heading). One bullet per todo, each capped at + * PENDING_TODO_BULLET_MAX_CHARS. `gsd-core/workflows/add-todo.md` and + * `check-todos.md` splice this string in verbatim instead of free-hand + * editing STATE.md — see the design doc for why this is real, unit-tested + * code rather than a prose algorithm (DEFECT.GENERATIVE-FIX: a prose + * algorithm duplicated as a test oracle is exactly the divergence class + * this avoids). + */ +function renderPendingTodosMarkdown(todos: Record[]): string { + if (!Array.isArray(todos) || todos.length === 0) { + return 'None yet.'; + } + return todos.map((todo) => renderPendingTodoBullet(todo)).join('\n'); +} + +function pendingTodoFieldAsString(value: unknown, fallback: string): string { + return typeof value === 'string' && value.length > 0 ? value : fallback; +} + +function renderPendingTodoBullet(todo: Record): string { + const date = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['created'], 'unknown')); + let area = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['area'], 'general')); + let title = sanitizePendingTodoInline(pendingTodoFieldAsString(todo['title'], 'Untitled')); + // Strip trailing "." so the fixed "Needs ....` template below never + // produces a doubled period when the source text already ended in one. + let needs = + typeof todo['needs'] === 'string' + ? sanitizePendingTodoInline(todo['needs']).replace(/\.+$/, '') + : ''; + const link = `[todo file](${pendingTodoFieldAsString(todo['path'], '')})`; + + const assemble = (): string => { + const needsClause = needs ? ` — Needs ${needs}.` : ''; + return `- [${date}] [${area}] ${title} — ${link}${needsClause}`; + }; + + let line = assemble(); + if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line; + + // 1) Drop the needs clause entirely first — date/area/title/link untouched. + needs = ''; + line = assemble(); + if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line; + + // 2) Shorten the title next, down to a floor — date/area/link untouched. + const titleOverage = line.length - PENDING_TODO_BULLET_MAX_CHARS; + const targetTitleLen = Math.max(PENDING_TODO_TITLE_FLOOR, title.length - titleOverage); + if (targetTitleLen < title.length) { + title = truncatePendingTodoText(title, targetTitleLen); + line = assemble(); + } + if (line.length <= PENDING_TODO_BULLET_MAX_CHARS) return line; + + // 3) Shorten area as a last resort — date and the markdown link are never + // altered (link correctness > strict cap; see design doc "Known limits"). + const areaOverage = line.length - PENDING_TODO_BULLET_MAX_CHARS; + const targetAreaLen = Math.max(PENDING_TODO_AREA_FLOOR, area.length - areaOverage); + if (targetAreaLen < area.length) { + area = truncatePendingTodoText(area, targetAreaLen); + line = assemble(); + } + + return line; +} + function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void { const config = loadConfig(cwd); const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); let count = 0; const todos: Record[] = []; + // #2618: distinct from "genuinely zero pending todos" — false only when + // readdirSync itself failed for a reason OTHER than the directory simply + // not existing yet (ENOENT), mirroring the ENOENT-vs-other-errno split + // already used above in this file (#3885, ADR-3473 §8.5). Without this, + // a real I/O/permission error on the pending dir would look identical to + // "no pending todos" and could wipe an existing, non-empty Pending Todos + // section in STATE.md on refresh — the fail-safe requirement for #2618. + let pendingReadOk = true; try { - const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.md')); + // #2618: sorted so pending_todos_markdown's bullet order is stable across + // runs — readdirSync's order is filesystem-dependent, not contractually + // stable, and an unstable order would reorder every bullet on an + // unrelated re-render, turning a one-line git diff into a full-section + // rewrite (must-have #3). Filenames are `YYYY-MM-DD-slug.md`, so this + // also yields a sensible chronological order as a side effect. + const files = fs.readdirSync(pendingDir).filter((f) => f.endsWith('.md')).sort(); for (const file of files) { const content = platformReadSync(path.join(pendingDir, file)); if (content === null) continue; @@ -2252,6 +2356,21 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void // #2337: kept in parity with cmdListTodos — surface severity when // present, omit the key entirely for todos with no severity line. const severityMatch = content.match(/^severity:\s*(.+)$/m); + // #2618: first non-empty line of the `## Solution` body, used as the + // bullet's "Needs ..." clause. "TBD" (the create_file template's own + // placeholder for an unresolved solution) renders no clause at all + // rather than the useless literal "Needs TBD.". + const solutionSection = collectSection(content, (h) => h.level === 2 && h.text.trim() === 'Solution'); + let needs: string | undefined; + if (solutionSection) { + const firstLine = solutionSection.body + .split('\n') + .map((l) => l.trim()) + .find((l) => l.length > 0); + if (firstLine && firstLine.toUpperCase() !== 'TBD') { + needs = firstLine; + } + } const todoArea = areaMatch ? areaMatch[1].trim() : 'general'; if (area && todoArea !== area) continue; @@ -2265,13 +2384,17 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. path: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending', file)), ...(severityMatch ? { severity: severityMatch[1].trim() } : {}), + ...(needs ? { needs } : {}), }); } catch { /* intentionally empty */ } } - } catch { - /* intentionally empty */ + } catch (err) { + const code = (err as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + pendingReadOk = false; + } } const result: Record = { @@ -2291,6 +2414,13 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void planning_exists: fs.existsSync(planningDir(cwd)), todos_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos')), pending_dir_exists: fs.existsSync(path.join(planningDir(cwd), 'todos', 'pending')), + + // #2618: see PENDING_TODO_BULLET_MAX_CHARS comment / design doc. Consumed + // by add-todo.md / check-todos.md's update_state step; omitted entirely + // (rather than emitted with possibly-wrong data) when pendingReadOk is + // false, so the workflow's fail-safe check can key off field presence. + pending_read_ok: pendingReadOk, + ...(pendingReadOk ? { pending_todos_markdown: renderPendingTodosMarkdown(todos) } : {}), }; output(withProjectRoot(cwd, result), raw); @@ -4188,4 +4318,5 @@ export = { cmdAgentSkills, buildSkillManifest, cmdSkillManifest, + renderPendingTodosMarkdown, }; diff --git a/tests/state-todos-render.test.cjs b/tests/state-todos-render.test.cjs new file mode 100644 index 000000000..5574f0e39 --- /dev/null +++ b/tests/state-todos-render.test.cjs @@ -0,0 +1,270 @@ +'use strict'; + +// #2618: deterministic renderer for STATE.md's "### Pending Todos" section +// body. See .gsd/phase/feat-2618-compact-todo-pointers/40-design.md and +// 50-test-matrix.md for the full rationale and case list. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const fc = require('fast-check'); + +const initLib = require('../gsd-core/bin/lib/init.cjs'); +const { renderPendingTodosMarkdown } = initLib; +const { cleanup } = require('./helpers.cjs'); +const { escapeRegex } = require('../gsd-core/bin/lib/pattern.cjs'); + +const MAX = 240; + +function makeTodo(overrides) { + return Object.assign( + { + created: '2026-09-01', + area: 'api', + title: 'Fix retry logic', + path: '.planning/todos/pending/2026-09-01-fix-retry-logic.md', + }, + overrides, + ); +} + +test('renderPendingTodosMarkdown: zero todos renders exactly "None yet."', () => { + assert.equal(renderPendingTodosMarkdown([]), 'None yet.'); +}); + +test('renderPendingTodosMarkdown: one todo without a needs clause', () => { + const body = renderPendingTodosMarkdown([makeTodo()]); + const lines = body.split('\n'); + assert.equal(lines.length, 1); + assert.match(lines[0], /^- \[2026-09-01\] \[api\] Fix retry logic — \[todo file\]\(.+\)$/); + assert.doesNotMatch(lines[0], /Needs/); +}); + +test('renderPendingTodosMarkdown: "TBD" solution omits the needs clause', () => { + const body = renderPendingTodosMarkdown([makeTodo({ needs: undefined })]); + assert.doesNotMatch(body, /Needs/); +}); + +test('renderPendingTodosMarkdown: real solution text becomes a "Needs ..." clause', () => { + const body = renderPendingTodosMarkdown([makeTodo({ needs: 'define retry behavior' })]); + assert.match(body, /Needs define retry behavior\.$/); +}); + +test('renderPendingTodosMarkdown: needs text already ending in "." does not double the period', () => { + const body = renderPendingTodosMarkdown([makeTodo({ needs: 'Add a max-attempts cap.' })]); + assert.match(body, /Needs Add a max-attempts cap\.$/); + assert.doesNotMatch(body, /\.\.$/); +}); + +test('renderPendingTodosMarkdown: many todos render one bullet per todo, in order', () => { + const todos = Array.from({ length: 5 }, (_, i) => + makeTodo({ title: `Todo number ${i}`, path: `.planning/todos/pending/todo-${i}.md` }), + ); + const body = renderPendingTodosMarkdown(todos); + const lines = body.split('\n'); + assert.equal(lines.length, 5); + lines.forEach((line, i) => { + assert.match(line, new RegExp(`Todo number ${i} `)); + }); +}); + +test('renderPendingTodosMarkdown: markdown-special characters in title/area render verbatim', () => { + const body = renderPendingTodosMarkdown([ + makeTodo({ title: 'Fix `parse()` for [bracketed] input', area: 'db|cache' }), + ]); + assert.match(body, /Fix `parse\(\)` for \[bracketed\] input/); + assert.match(body, /\[db\|cache\]/); +}); + +test('renderPendingTodosMarkdown: boundary — 239 chars (limit-1) is not truncated', () => { + // Assemble a title so the full line lands at exactly 239 chars, then assert + // byte-identical (untruncated) output. + const base = makeTodo({ needs: undefined, title: 'X' }); + const probe = renderPendingTodosMarkdown([base]); + const pad = 239 - probe.length; + assert.ok(pad > 0, 'test fixture sanity: base line must be shorter than 239 chars'); + const title = 'X'.repeat(1 + pad); + const todo = makeTodo({ needs: undefined, title }); + const line = renderPendingTodosMarkdown([todo]); + assert.equal(line.length, 239); + assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`); +}); + +test('renderPendingTodosMarkdown: boundary — 240 chars (limit) is not truncated', () => { + const base = makeTodo({ needs: undefined, title: 'X' }); + const probe = renderPendingTodosMarkdown([base]); + const pad = 240 - probe.length; + const title = 'X'.repeat(1 + pad); + const todo = makeTodo({ needs: undefined, title }); + const line = renderPendingTodosMarkdown([todo]); + assert.equal(line.length, 240); + assert.equal(line, `- [2026-09-01] [api] ${title} — [todo file](${todo.path})`); +}); + +test('renderPendingTodosMarkdown: boundary — 241 chars (limit+1) truncates, needs dropped first', () => { + const base = makeTodo({ needs: 'x', title: 'X' }); + const probe = renderPendingTodosMarkdown([base]); + const pad = 241 - probe.length; + const title = 'X'.repeat(1 + Math.max(pad, 0)); + const todo = makeTodo({ needs: 'a real needs clause that should get dropped', title }); + const line = renderPendingTodosMarkdown([todo]); + assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`); + assert.doesNotMatch(line, /Needs/, 'needs clause must be dropped before title is touched'); +}); + +test('renderPendingTodosMarkdown: title truncated with floor + ellipsis when needs-drop is insufficient', () => { + const longTitle = 'A'.repeat(400); + const todo = makeTodo({ title: longTitle, needs: 'something' }); + const line = renderPendingTodosMarkdown([todo]); + assert.ok(line.length <= MAX, `expected <= ${MAX}, got ${line.length}`); + assert.doesNotMatch(line, /Needs/); + assert.match(line, /…/); + // date/area/link remain byte-identical to the untruncated assembly. + assert.match(line, /^- \[2026-09-01\] \[api\] /); + assert.match(line, new RegExp(`\\[todo file\\]\\(${escapeRegex(todo.path)}\\)$`)); +}); + +test('renderPendingTodosMarkdown: pathological — title floor + link alone exceeds cap is allowed to overflow', () => { + const veryLongPath = `.planning/todos/pending/${'p'.repeat(400)}.md`; + const todo = makeTodo({ title: 'A'.repeat(400), path: veryLongPath, needs: 'x' }); + const line = renderPendingTodosMarkdown([todo]); + // Documented known limit: link is never sacrificed even if it blows the cap. + assert.ok(line.includes(veryLongPath), 'link must remain verbatim even when cap is exceeded'); +}); + +test('property: rendered body always has one line per todo, each line <= 240 chars, link preserved verbatim', () => { + // Path length is bounded so the algorithm's floor assembly (date + 3-char + // area floor + 15-char title floor + link, needs always droppable) never + // exceeds 240 — i.e. the cap is always achievable without touching the + // link. This keeps the <= 240 assertion a real, provable invariant rather + // than a vacuous one; the pathological "cap not achievable" case is + // covered separately by the fixed "pathological" unit test above. + const todoArb = fc.record({ + created: fc.constantFrom('2026-01-01', '2025-12-31', 'unknown'), + area: fc.string({ minLength: 0, maxLength: 40 }), + title: fc.string({ minLength: 0, maxLength: 500 }), + path: fc + .string({ minLength: 1, maxLength: 150 }) + .map((s) => `.planning/todos/pending/${s}.md`), + needs: fc.option(fc.string({ minLength: 0, maxLength: 300 }), { nil: undefined }), + }); + + fc.assert( + fc.property(fc.array(todoArb, { minLength: 0, maxLength: 20 }), (todos) => { + const body = renderPendingTodosMarkdown(todos); + if (todos.length === 0) { + return body === 'None yet.'; + } + const lines = body.split('\n'); + if (lines.length !== todos.length) return false; + return lines.every((line, i) => { + const link = `[todo file](${todos[i].path})`; + return line.length <= 240 && line.includes(link); + }); + }), + ); +}); + +// ─── pending_read_ok / pending_todos_markdown via the real CLI surface ───── + +const { spawnSync } = require('node:child_process'); + +function runQueryInitTodos(cwd) { + const gsdTools = path.join(__dirname, '..', 'gsd-core', 'bin', 'gsd-tools.cjs'); + const result = spawnSync(process.execPath, [gsdTools, 'query', 'init.todos'], { + cwd, + encoding: 'utf8', + timeout: 15000, + }); + assert.equal(result.status, 0, `gsd_run query init.todos failed: ${result.stderr}`); + return JSON.parse(result.stdout); +} + +test('cmdInitTodos: pending_read_ok is true and pending_todos_markdown present for a healthy empty dir', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-')); + t.after(() => cleanup(dir)); + fs.mkdirSync(path.join(dir, '.planning', 'todos', 'pending'), { recursive: true }); + + const json = runQueryInitTodos(dir); + assert.equal(json.pending_read_ok, true); + assert.equal(json.pending_todos_markdown, 'None yet.'); + assert.equal(json.todo_count, 0); +}); + +test('cmdInitTodos: real todo file produces a rendered bullet via the CLI', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-')); + t.after(() => cleanup(dir)); + const pendingDir = path.join(dir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + fs.writeFileSync( + path.join(pendingDir, '2026-09-01-fix-retry-logic.md'), + [ + '---', + 'created: 2026-09-01T00:00:00.000Z', + 'title: Fix retry logic', + 'area: api', + 'severity: major', + 'files:', + ' - src/api/client.cts:42', + '---', + '', + '## Problem', + '', + 'Retries are unbounded.', + '', + '## Solution', + '', + 'Add a max-attempts cap.', + '', + ].join('\n'), + ); + + const json = runQueryInitTodos(dir); + assert.equal(json.pending_read_ok, true); + assert.equal(json.todo_count, 1); + assert.match(json.pending_todos_markdown, /Fix retry logic/); + assert.match(json.pending_todos_markdown, /Needs Add a max-attempts cap\.$/m); +}); + +test('cmdInitTodos: bullet order is filename-sorted regardless of write/insertion order', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2618-')); + t.after(() => cleanup(dir)); + const pendingDir = path.join(dir, '.planning', 'todos', 'pending'); + fs.mkdirSync(pendingDir, { recursive: true }); + // Written in reverse-alphabetical insertion order on purpose — readdirSync + // order is filesystem-dependent, not contractually stable, so the ONLY way + // to assert a deterministic bullet order is to sort explicitly. See #2618 + // spec review finding on git-diff stability (must-have #3). + const todoBody = (title) => + ['---', 'created: 2026-09-01', `title: ${title}`, 'area: api', '---', ''].join('\n'); + fs.writeFileSync(path.join(pendingDir, '2026-09-03-third.md'), todoBody('Third todo')); + fs.writeFileSync(path.join(pendingDir, '2026-09-01-first.md'), todoBody('First todo')); + fs.writeFileSync(path.join(pendingDir, '2026-09-02-second.md'), todoBody('Second todo')); + + const json = runQueryInitTodos(dir); + const titles = json.todos.map((t2) => t2.title); + assert.deepEqual(titles, ['First todo', 'Second todo', 'Third todo']); + const lines = json.pending_todos_markdown.split('\n'); + assert.ok(lines[0].includes('First todo')); + assert.ok(lines[1].includes('Second todo')); + assert.ok(lines[2].includes('Third todo')); +}); + +// ─── workflow parity guard (DEFECT.GENERATIVE-FIX) ───────────────────────── + +function extractUpdateStateStep(workflowPath) { + const content = fs.readFileSync(workflowPath, 'utf8'); + const match = content.match(/([\s\S]{0,20000}?)<\/step>/); + assert.ok(match, `update_state step not found in ${workflowPath}`); + return match[1].trim(); +} + +test('workflow parity: add-todo.md and check-todos.md update_state steps are byte-identical', () => { + const addTodoPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'add-todo.md'); + const checkTodosPath = path.join(__dirname, '..', 'gsd-core', 'workflows', 'check-todos.md'); + const a = extractUpdateStateStep(addTodoPath); + const b = extractUpdateStateStep(checkTodosPath); + assert.equal(a, b, 'update_state step prose must be identical across both workflows (DEFECT.GENERATIVE-FIX)'); +});