fix(#4256): resolve todos from the root via todosDir everywhere (#4479)

* test(#4256): pin todos as root-scoped under workstreams (RED)

* fix(#4256): resolve todos from the root via todosDir everywhere

* chore(#4256): changeset fragment (pr number to backfill)

* chore(#4256): backfill PR number in changeset

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-09-07 08:24:14 -04:00
committed by GitHub
parent 6ebe6372ce
commit 0a0905705a
6 changed files with 410 additions and 17 deletions

View File

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

View File

@@ -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<ThreadItem> {
* 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<TodoItem> {
const pendingDir = path.join(planDir, 'todos', 'pending');
function scanTodos(todosBase: string): ScanOutcome<TodoItem> {
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<TodoItem> {
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') {

View File

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

View File

@@ -104,6 +104,7 @@ const {
planningPaths,
planningDir,
planningRoot,
todosDir,
listAvailableWorkstreams,
peekActiveWorkstream,
diagnoseUnresolvedActiveWorkstream,
@@ -2356,7 +2357,13 @@ function renderPendingTodoBullet(todo: Record<string, unknown>, 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/<ws>/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<string, unknown>[] = [];
// #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

View File

@@ -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/<ws>/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,

View File

@@ -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/<ws>/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/<project>/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 <base>/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;
}
});
});