* 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:
5
.changeset/jolly-moles-snooze.md
Normal file
5
.changeset/jolly-moles-snooze.md
Normal 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)
|
||||
@@ -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') {
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
22
src/init.cts
22
src/init.cts
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
318
tests/todos-workstream-scope.test.cjs
Normal file
318
tests/todos-workstream-scope.test.cjs
Normal 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;
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user