enhance(#2618): render pending todos as one bounded bullet per todo (#4384)

This commit is contained in:
Tom Boucher
2026-09-06 08:06:39 -04:00
committed by GitHub
parent 66e4034fe4
commit b7406b293f
8 changed files with 424 additions and 10 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -145,8 +145,9 @@ files:
<step name="update_state">
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).
</step>
<step name="git_commit">

View File

@@ -150,9 +150,11 @@ Return to list_todos step.
</step>
<step name="update_state">
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).
</step>
<step name="git_commit">

View File

@@ -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"
],

View File

@@ -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, unknown>[]): 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, unknown>): 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<string, unknown>[] = [];
// #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<string, unknown> = {
@@ -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,
};

View File

@@ -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(/<step name="update_state">([\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)');
});