diff --git a/.changeset/jolly-moles-snooze.md b/.changeset/jolly-moles-snooze.md new file mode 100644 index 000000000..e131d5477 --- /dev/null +++ b/.changeset/jolly-moles-snooze.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 4479 +--- +**Todos stay visible under a workstream** — todos are root-scoped shared state, but every code reader resolved them through the workstream-aware planning dir, so with a workstream active todos read as empty, `todo complete` refused existing files, and the milestone-close audit-open gate passed with pending todos on disk. (#4256) diff --git a/src/audit.cts b/src/audit.cts index 53d512a40..50da84a5c 100644 --- a/src/audit.cts +++ b/src/audit.cts @@ -22,7 +22,7 @@ import coreUtils = require('./core-utils.cjs'); const { normalizeLineEndings } = coreUtils; // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningDir, quickDirFrom } = planningWorkspace; +const { planningDir, quickDirFrom, todosDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter, spliceFrontmatter } = frontmatter; @@ -711,9 +711,16 @@ function scanThreads(planDir: string): ScanOutcome { * Scan .planning/todos/pending/ for pending todos. * Returns array of { filename, priority, area, summary }. * Display limited to first 5 + count of remainder. + * + * #4256: takes the ROOT-scoped todos base (`todosDir(cwd)`), NOT the + * workstream-scoped planning dir the other scans use — todos are shared + * project state (the migrateToWorkstreams contract keeps them at + * .planning/todos/), so the close gate must read the root or it clears + * vacuously under a workstream. The requireSafePath boundary below moves + * with the base. */ -function scanTodos(planDir: string): ScanOutcome { - const pendingDir = path.join(planDir, 'todos', 'pending'); +function scanTodos(todosBase: string): ScanOutcome { + const pendingDir = path.join(todosBase, 'pending'); if (!fs.existsSync(pendingDir)) return { items: [], acknowledged: 0 }; let files: fs.Dirent[]; @@ -741,7 +748,7 @@ function scanTodos(planDir: string): ScanOutcome { let safeFilePath: string; try { - safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true }); + safeFilePath = requireSafePath(filePath, todosBase, 'todo file', { allowAbsolute: true }); } catch { continue; } @@ -1314,7 +1321,12 @@ function auditOpenArtifacts(cwd: string): AuditResult { })(); const todos = (() => { - try { return scanTodos(planDir); } catch { return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 }; } + // #4256: the ONE root-scoped category — todos are shared project state, + // so the close gate reads todosDir(cwd) (the root), not the workstream- + // scoped planDir every other scan below receives. Reading planDir here + // made audit-open print "All artifact types clear. Safe to proceed." + // with pending todos on disk under a workstream. + try { return scanTodos(todosDir(cwd)); } catch { return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 }; } })(); const seeds = (() => { @@ -1751,7 +1763,12 @@ function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void { currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase(); } else if (category === 'todos') { if (!filename) ioError('--filename is required for --category todos'); - safeFilePath = requireSafePath(path.join(planDir, 'todos', 'pending', filename as string), planDir, 'audit acknowledge target', { allowAbsolute: true }); + // #4256: todos are root-scoped shared state — derive the todos base and + // pass it as BOTH the path base and the requireSafePath boundary. The + // old workstream-scoped planDir boundary would refuse a root todos file + // outright, and even a path fix alone would have thrown here. + const rootTodos = todosDir(cwd); + safeFilePath = requireSafePath(path.join(rootTodos, 'pending', filename as string), rootTodos, 'audit acknowledge target', { allowAbsolute: true }); if (!fs.existsSync(safeFilePath)) ioError(`file not found: todos/pending/${filename as string}`); currentValue = ''; // presence-only — see scanTodos } else if (category === 'quick_tasks') { diff --git a/src/commands.cts b/src/commands.cts index 1230b678f..875629738 100644 --- a/src/commands.cts +++ b/src/commands.cts @@ -49,7 +49,7 @@ import { parseCodexAgentToml, renderCodexAgentToml, stripModel, stripReasoningEf import hostIntegrationMod = require('./host-integration.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningDir, planningPaths } = planningWorkspace; +const { planningDir, planningPaths, todosDir } = planningWorkspace; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatter = require('./frontmatter.cjs'); const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter; @@ -239,7 +239,10 @@ function cmdCurrentTimestamp(format: string | undefined, raw: boolean): void { } function cmdListTodos(cwd: string, area: string | undefined, raw: boolean): void { - const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); + // #4256: todos are root-scoped shared state — resolve via todosDir(cwd), + // never planningDir(cwd) (workstream-scoped), or the listing goes empty + // under a workstream. + const pendingDir = path.join(todosDir(cwd), 'pending'); let count = 0; const todos: Array<{ file: string; created: string; title: string; area: string; path: string; severity?: string }> = []; @@ -2778,7 +2781,8 @@ function cmdProgressRender(cwd: string, format: string | undefined, raw: boolean function cmdTodoMatchPhase(cwd: string, phase: string | undefined, raw: boolean): void { if (!phase) { error('phase required for todo match-phase'); } - const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); + // #4256: root-scoped todos read — see cmdListTodos. + const pendingDir = path.join(todosDir(cwd), 'pending'); const todos: Array<{ file: string; title: string; @@ -2945,8 +2949,12 @@ function cmdTodoComplete(cwd: string, filename: string | undefined, options: Tod error('filename required for todo complete'); } - const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); - const completedDir = path.join(planningDir(cwd), 'todos', 'completed'); + // #4256: root-scoped todos read/write — see cmdListTodos. The pending and + // completed halves of the move must resolve from the SAME root or the + // completion would strand files where no reader looks. + const todosRoot = todosDir(cwd); + const pendingDir = path.join(todosRoot, 'pending'); + const completedDir = path.join(todosRoot, 'completed'); const sourcePath = path.join(pendingDir, filename as string); if (!fs.existsSync(sourcePath)) { diff --git a/src/init.cts b/src/init.cts index b11de2485..3203e97a0 100644 --- a/src/init.cts +++ b/src/init.cts @@ -104,6 +104,7 @@ const { planningPaths, planningDir, planningRoot, + todosDir, listAvailableWorkstreams, peekActiveWorkstream, diagnoseUnresolvedActiveWorkstream, @@ -2356,7 +2357,13 @@ function renderPendingTodoBullet(todo: Record, projectRoot?: st function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void { const config = loadConfig(cwd); - const pendingDir = path.join(planningDir(cwd), 'todos', 'pending'); + // #4256: todos are root-scoped shared state (migrateToWorkstreams keeps + // them at .planning/todos/ and every workflow writer writes that literal + // path), so this read resolves via todosDir(cwd) — NOT planningDir(cwd), + // which would look in .planning/workstreams//todos/ under a workstream + // (a directory nothing creates) and report existing todos as absent. + const todosRoot = todosDir(cwd); + const pendingDir = path.join(todosRoot, 'pending'); let count = 0; const todos: Record[] = []; // #2618: distinct from "genuinely zero pending todos" — false only when @@ -2412,7 +2419,7 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void title: titleMatch ? titleMatch[1].trim() : 'Untitled', area: todoArea, // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. - path: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending', file)), + path: toPosixPath(path.join(pendingDir, file)), ...(severityMatch ? { severity: severityMatch[1].trim() } : {}), ...(needs ? { needs } : {}), }); @@ -2438,12 +2445,15 @@ function cmdInitTodos(cwd: string, area: string | undefined, raw: boolean): void area_filter: area || null, // #2376: absolute — see comment on phase_dir in cmdInitExecutePhase. - pending_dir: toPosixPath(path.join(planningDir(cwd), 'todos', 'pending')), - completed_dir: toPosixPath(path.join(planningDir(cwd), 'todos', 'completed')), + // #4256: both dir fields probe the ROOT todos tree via todosDir(cwd). + pending_dir: toPosixPath(pendingDir), + completed_dir: toPosixPath(path.join(todosRoot, 'completed')), + // planning_exists intentionally stays workstream/project-scoped — it + // answers "does the ACTIVE planning dir exist", not a todos question. 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')), + todos_dir_exists: fs.existsSync(todosRoot), + pending_dir_exists: fs.existsSync(pendingDir), // #2618: see PENDING_TODO_BULLET_MAX_CHARS comment / design doc. Consumed // by add-todo.md / check-todos.md's update_state step; omitted entirely diff --git a/src/planning-workspace.cts b/src/planning-workspace.cts index 33fb125de..037a4e325 100644 --- a/src/planning-workspace.cts +++ b/src/planning-workspace.cts @@ -304,6 +304,7 @@ interface PlanningPaths { requirements: string; debug: string; quick: string; + todos: string; } // #2142: the quick-task directory. Exported as its own function (not only as a @@ -316,6 +317,33 @@ function quickDirFrom(planningBase: string): string { return path.join(planningBase, 'quick'); } +// #4256: the todos directory — deliberately ROOT-SCOPED, unlike every other +// planningPaths key. Todos are shared project state by construction: the +// migrateToWorkstreams contract keeps them among the shared files that "stay +// in place" at .planning/todos/ (workstream.cts), and every workflow writer +// writes that literal cwd-relative root path. The six todos readers +// previously hand-composed `path.join(planningDir(cwd), 'todos', ...)`, +// which silently re-scoped to .planning/workstreams//todos/ — a +// directory nothing creates — under a workstream, so todos went invisible +// and audit-open passed the milestone-close gate vacuously. Same +// two-composers-of-one-path shape the `debug` (#3149) and `quick` (#2142) +// keys were introduced to eliminate (DEFECT.GENERATIVE-FIX). +// +// Exported as its own function pair (not only as a `planningPaths` key) +// because `audit.cts`'s `scanTodos`/`cmdAuditAcknowledge` consume an +// already-resolved todos base rather than a `cwd`, mirroring how #2142 +// exported `quickDirFrom` for `scanQuickTasks`. `todosDir` takes NO ws/project +// parameter — todos have no workstream- or project-scoped form anywhere, so +// there is no discriminator to thread. This is also the single root #4327's +// future filename-containment guard should enforce against. +function todosDirFrom(planningBase: string): string { + return path.join(planningBase, 'todos'); +} + +function todosDir(cwd: string): string { + return todosDirFrom(planningRoot(cwd)); +} + function planningPaths(cwd: string, ws?: string | null): PlanningPaths { const base = planningDir(cwd, ws); return { @@ -332,6 +360,11 @@ function planningPaths(cwd: string, ws?: string | null): PlanningPaths { debug: path.join(base, 'debug'), // #2142: quick-task directory, composed via the shared quickDirFrom helper. quick: quickDirFrom(base), + // #4256: todos directory — deliberately ROOT-scoped while the rest of + // this record follows the active workstream/project (todos are shared + // project state per the migrateToWorkstreams contract), composed via the + // shared todosDir helper so this key and every direct caller agree. + todos: todosDir(cwd), }; } @@ -638,6 +671,8 @@ export = { listAvailableWorkstreams, planningPaths, quickDirFrom, + todosDirFrom, + todosDir, withPlanningLock, getActiveWorkstream, peekActiveWorkstream, diff --git a/tests/todos-workstream-scope.test.cjs b/tests/todos-workstream-scope.test.cjs new file mode 100644 index 000000000..7c6d28192 --- /dev/null +++ b/tests/todos-workstream-scope.test.cjs @@ -0,0 +1,318 @@ +'use strict'; + +// ───────────────────────────────────────────────────────────────────────────── +// #4256 — todos are root-scoped shared state; readers must read the root. +// +// The migrateToWorkstreams contract (src/workstream.cts) keeps todos among the +// SHARED files that "stay in place" at .planning/todos/, and every workflow +// writer writes that literal root path. But all six code readers composed +// their todos path from the workstream-aware planningDir(cwd), so under a +// workstream they read .planning/workstreams//todos/pending/ — a directory +// nothing creates — and each reader's ENOENT guard turned "wrong directory" +// into a legitimate-looking zero: invisible todos, `todo complete` refusing +// files that exist, and `audit-open` (a milestone-close gate) printing "All +// artifact types clear" with pending todos on disk. +// +// The fix converges every reader on the root-scoped todosDir(cwd) resolver +// (src/planning-workspace.cts, beside quickDirFrom per #2142/#3149), and moves +// audit acknowledge's requireSafePath boundary with it. These rows pin the +// converged behavior under a workstream AND the byte-identical flat-mode +// control (planningDir === planningRoot with no project/workstream active). +// ───────────────────────────────────────────────────────────────────────────── + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempProject, cleanup, runGsdTools, toPosixPath } = require('./helpers.cjs'); +const planningWorkspace = require('../gsd-core/bin/lib/planning-workspace.cjs'); + +const TODO_A = '2026-09-01-one.md'; +const TODO_B = '2026-09-02-two.md'; + +function seedRootTodos(tmpDir) { + fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'pending'), { recursive: true }); + fs.mkdirSync(path.join(tmpDir, '.planning', 'todos', 'completed'), { recursive: true }); + for (const [file, title, created] of [[TODO_A, 'One', '2026-09-01'], [TODO_B, 'Two', '2026-09-02']]) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'todos', 'pending', file), + `---\ntitle: ${title}\ncreated: ${created}\narea: general\npriority: high\n---\nbody\n`, + ); + } +} + +function seedWorkstreamDir(tmpDir, ws) { + // Minimal workstream layout — enough for the ws discriminator to resolve a + // real directory; nothing more is needed to reproduce the scope split. + fs.mkdirSync(path.join(tmpDir, '.planning', 'workstreams', ws, 'phases'), { recursive: true }); +} + +function queryJson(args, cwd, env = {}) { + const r = runGsdTools(args, cwd, env); + assert.ok(r.success, `gsd-tools ${args.join(' ')} failed: ${r.error}`); + return JSON.parse(r.output); +} + +describe('#4256: todos readers resolve the root under a workstream', () => { + test('init.todos counts root todos with GSD_WORKSTREAM set (regression)', (t) => { + const tmpDir = createTempProject('gsd-4256-initws-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + + assert.equal(out.todo_count, 2, `root todos must be visible under a workstream; got ${out.todo_count}`); + assert.ok( + toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'), + `pending_dir must be the ROOT pending dir, got ${out.pending_dir}`, + ); + assert.equal(out.todos_dir_exists, true, 'todos_dir_exists must probe the root todos dir'); + assert.equal(out.pending_dir_exists, true, 'pending_dir_exists must probe the root pending dir'); + assert.ok( + Array.isArray(out.todos) && out.todos.length === 2, + 'the todo list itself must carry both root files', + ); + assert.ok( + toPosixPath(out.todos[0].path).includes(`.planning/todos/pending/${TODO_A}`), + `per-todo path must point at the root file, got ${out.todos[0].path}`, + ); + }); + + test('init.todos counts root todos via explicit --ws flag', (t) => { + const tmpDir = createTempProject('gsd-4256-initflag-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'init.todos', '--ws', 'feature-a', '--raw'], tmpDir); + + assert.equal(out.todo_count, 2, '--ws must not hide root todos'); + assert.ok( + toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'), + `pending_dir must stay root-scoped under --ws, got ${out.pending_dir}`, + ); + }); + + test('list-todos returns root todos under a workstream', (t) => { + const tmpDir = createTempProject('gsd-4256-listws-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'list-todos'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + + assert.equal(out.count, 2, `list-todos must see root todos under a workstream; got ${out.count}`); + const files = out.todos.map((x) => x.file).sort(); + assert.deepEqual(files, [TODO_A, TODO_B]); + assert.ok( + toPosixPath(out.todos[0].path).startsWith('.planning/todos/pending/'), + `list-todos paths must be root-relative, got ${out.todos[0].path}`, + ); + }); + + test('todo match-phase sees the root todo set under a workstream', (t) => { + const tmpDir = createTempProject('gsd-4256-matchws-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'todo', 'match-phase', '1', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + + assert.equal(out.todo_count, 2, `match-phase must scan the root todo set; got ${out.todo_count}`); + }); + + test('todo complete moves the ROOT file pending -> completed under a workstream', (t) => { + const tmpDir = createTempProject('gsd-4256-complete-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'todo', 'complete', TODO_A], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + assert.equal(out.completed, true); + + const rootPending = path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A); + const rootCompleted = path.join(tmpDir, '.planning', 'todos', 'completed', TODO_A); + assert.ok(!fs.existsSync(rootPending), 'source must be removed from the ROOT pending dir'); + assert.ok(fs.existsSync(rootCompleted), 'target must land in the ROOT completed dir'); + const content = fs.readFileSync(rootCompleted, 'utf8'); + assert.match(content, /^status: completed$/m, 'completion fields must be written into frontmatter'); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning', 'workstreams', 'feature-a', 'todos')), + 'complete must NOT materialize the divergent workstream todos dir', + ); + }); + + test('todo complete --dry-run finds the root todo and mutates nothing (#4096 fence intact)', (t) => { + const tmpDir = createTempProject('gsd-4256-dryrun-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['query', 'todo', 'complete', TODO_A, '--dry-run'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + + assert.equal(out.dry_run, true); + assert.equal(out.would_complete, true); + assert.ok( + toPosixPath(out.would_move.source).endsWith(`.planning/todos/pending/${TODO_A}`), + `dry-run source must be the ROOT pending file, got ${out.would_move.source}`, + ); + assert.ok( + toPosixPath(out.would_move.target).endsWith(`.planning/todos/completed/${TODO_A}`), + `dry-run target must be the ROOT completed file, got ${out.would_move.target}`, + ); + assert.ok( + fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A)) && + fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'pending', TODO_B)), + 'a dry run must leave both root files untouched', + ); + assert.ok( + !fs.existsSync(path.join(tmpDir, '.planning', 'todos', 'completed', TODO_A)), + 'a dry run must not write the completed copy', + ); + }); + + test('audit-open counts root pending todos under a workstream — the close gate FAILS', (t) => { + const tmpDir = createTempProject('gsd-4256-gate-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const out = queryJson(['audit-open', '--json'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + + assert.equal(out.counts.todos, 2, `the close gate must count the root todos; got ${out.counts.todos}`); + assert.equal(out.has_open_items, true, 'pending root todos must block the milestone close'); + const names = out.items.todos.map((i) => i.filename).sort(); + assert.deepEqual(names, [TODO_A, TODO_B]); + }); + + test('audit acknowledge writes the marker into the ROOT todo under a workstream (boundary moved)', (t) => { + const tmpDir = createTempProject('gsd-4256-ack-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + const env = { GSD_WORKSTREAM: 'feature-a' }; + + const ack = queryJson( + ['audit-open', 'acknowledge', '--category', 'todos', '--milestone', 'v1.0', '--filename', TODO_A], + tmpDir, + env, + ); + assert.equal(ack.acknowledged, true); + + const rootFile = path.join(tmpDir, '.planning', 'todos', 'pending', TODO_A); + assert.ok(fs.existsSync(rootFile), 'the acknowledged todo stays in the ROOT pending dir'); + const content = fs.readFileSync(rootFile, 'utf8'); + assert.match(content, /^audit_acknowledged:/m, 'the marker must be spliced into the ROOT file'); + + const after = queryJson(['audit-open', '--json'], tmpDir, env); + assert.equal(after.acknowledged.todos, 1, 'the acknowledged root todo must be tallied, not open'); + assert.equal(after.counts.todos, 1, 'only the unacknowledged root todo stays open'); + }); +}); + +describe('#4256: negative space — flat, project, and boundary scopes', () => { + test('flat mode (no workstream) counts are unchanged', (t) => { + const tmpDir = createTempProject('gsd-4256-flat-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + + const init = queryJson(['query', 'init.todos', '--raw'], tmpDir); + assert.equal(init.todo_count, 2); + assert.ok(toPosixPath(init.pending_dir).endsWith('.planning/todos/pending')); + + const audit = queryJson(['audit-open', '--json'], tmpDir); + assert.equal(audit.counts.todos, 2); + }); + + test('GSD_PROJECT mode also reads the cwd-relative root todos dir', (t) => { + const tmpDir = createTempProject('gsd-4256-project-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + + // Workflow writers use the literal cwd-relative `.planning/todos/...`, so + // under a project scope the files live at the planning ROOT, not under + // `.planning//todos/` — the reader must converge on the root. + const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_PROJECT: 'myapp' }); + assert.equal(out.todo_count, 2, `root todos must be visible under GSD_PROJECT; got ${out.todo_count}`); + assert.ok( + toPosixPath(out.pending_dir).endsWith('.planning/todos/pending'), + `pending_dir must stay at the root under GSD_PROJECT, got ${out.pending_dir}`, + ); + }); + + test('legacy per-workstream symlink workaround becomes inert — count stays correct', (t) => { + const tmpDir = createTempProject('gsd-4256-symlink-'); + t.after(() => cleanup(tmpDir)); + seedRootTodos(tmpDir); + seedWorkstreamDir(tmpDir, 'feature-a'); + + // The pre-fix workaround: a relative symlink redirecting the workstream + // path to the root. Post-fix readers never traverse the workstream + // prefix, so it must neither break nor double-count. + const wsDir = path.join(tmpDir, '.planning', 'workstreams', 'feature-a'); + try { + fs.symlinkSync('../../todos', path.join(wsDir, 'todos'), 'dir'); + } catch (err) { + t.skip(`filesystem does not support symlinks: ${err.code}`); + return; + } + + const out = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + assert.equal(out.todo_count, 2, `count must stay exact with the workaround symlink present; got ${out.todo_count}`); + }); + + test('fresh project with no todos dir under a workstream is a legitimate zero', (t) => { + const tmpDir = createTempProject('gsd-4256-empty-'); + t.after(() => cleanup(tmpDir)); + seedWorkstreamDir(tmpDir, 'feature-a'); + + const init = queryJson(['query', 'init.todos', '--raw'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + assert.equal(init.todo_count, 0); + assert.equal(init.pending_dir_exists, false); + + const list = queryJson(['query', 'list-todos'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + assert.equal(list.count, 0); + + const audit = queryJson(['audit-open', '--json'], tmpDir, { GSD_WORKSTREAM: 'feature-a' }); + assert.equal(audit.counts.todos, 0); + }); +}); + +describe('#4256: todosDir resolver owns the path (planning-workspace)', () => { + const { todosDirFrom, todosDir, planningPaths, planningRoot } = planningWorkspace; + + test('todosDirFrom(base) composes /todos', () => { + assert.equal(todosDirFrom(path.join('/fake', 'planning')), path.join('/fake', 'planning', 'todos')); + }); + + test('todosDir(cwd) is root-scoped — GSD_WORKSTREAM does not move it', (t) => { + const saved = process.env.GSD_WORKSTREAM; + process.env.GSD_WORKSTREAM = 'feature-a'; + t.after(() => { + if (saved === undefined) delete process.env.GSD_WORKSTREAM; + else process.env.GSD_WORKSTREAM = saved; + }); + + assert.equal(todosDir('/fake/repo'), path.join('/fake', 'repo', '.planning', 'todos')); + assert.equal(todosDir('/fake/repo'), path.join(planningRoot('/fake/repo'), 'todos')); + }); + + test('planningPaths keeps workstream keys scoped while todos stays root-scoped', () => { + const saved = process.env.GSD_WORKSTREAM; + delete process.env.GSD_WORKSTREAM; + try { + const paths = planningPaths('/fake/repo', 'feature-x'); + assert.ok(toPosixPath(paths.planning).endsWith('.planning/workstreams/feature-x')); + assert.ok(toPosixPath(paths.state).endsWith('.planning/workstreams/feature-x/STATE.md')); + assert.ok(toPosixPath(paths.quick).endsWith('.planning/workstreams/feature-x/quick')); + assert.ok( + toPosixPath(paths.todos).endsWith('.planning/todos'), + `planningPaths().todos must be deliberately root-scoped, got ${paths.todos}`, + ); + } finally { + if (saved !== undefined) process.env.GSD_WORKSTREAM = saved; + } + }); +});