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

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