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