* 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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user