Files
msd-core/src/audit.cts
Tom Boucher 343835facc refactor(#3183): route live-plan counting through scanPhasePlans (#3199)
* refactor(#3183): route live-plan counting through scanPhasePlans

scanPhasePlans becomes the sole owner of the live-plan derivation. Twenty-one
independent re-derivations across seven modules now route through it, and
scripts/lint-plan-count-drift.cjs reports zero, scanning the whole repo rather
than an allowlist (ADR-3180 Decision 4a).

The epic scoped this at three copies. A whole-repo guard found twenty-six sites
across nine files, so Phase 1 absorbs every live-plan re-derivation and Phase 3
narrows to window plus sentinel enumeration.

Two sites are exempt with a documented reason rather than a bare allowlist:
audit.cts scans one quick task's own directory for a single completion record,
and gsd2-import.cts reads a foreign GSD-2 tasks/ layout during a one-time
import. Neither is a phase directory.

scanPhasePlans gains allPlanFiles (pre-supersession) alongside planFiles so one
owner answers both questions: verify.cts's numbering-gap check wants every plan
on disk, its pairing check wants the live set. Both fields are additive.

Highest-severity fix: cmdPhasePlanIndex, which feeds execute-phase wave
scheduling, was scheduling status:superseded plans into waves and reporting zero
plans for the post-#3139 nested layout.

filterPlanFiles and filterSummaryFiles are deleted; getPhaseFileStats orphaned
them and only their own tests still called them.

New leaf module src/planning-scope.cts carries the frozen SCOPE discriminator,
with its six-gate ripple closed: gitignore, inventory manifest, INVENTORY.md and
the CONTEXT.md glossary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* docs(#3183): amend ADR-3180 for the Phase 1/3 boundary re-slice

The contract held; the phase boundary did not. The whole-repo drift guard found
26 re-derivations across 9 files against the epic's estimate of 3, and
cmdProgressRender re-derives both enumeration and plan counting on adjacent
lines, so DW4 was unsatisfiable within Phase 1's original file scope.

Records the amended scope, scanPhasePlans's new allPlanFiles field,
findOrphanSummaries, the two documented exemptions, the re-derived Tier-2
table, and the describeNonCanonicalPlans trap for later phases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): complete the canonical pairing rule and gate the naming diagnostic

The remote runner went red with 13 deterministic failures on both lanes,
and they were right: replacing verify.cts's canonicalPlanStem pairing with
summaryCandidates dropped a case the bespoke rule covered. A plan carrying a
descriptive slug after its id (68-01-scaffolding-PLAN.md) pairs with its
canonical-stem summary (68-01-SUMMARY.md), and summaryCandidates generated no
such candidate, so the plan read unsummarized.

The fix is to complete the one rule rather than restore a second:
summaryCandidates gains a canonical-id candidate, narrowed to fire only when an
id pair was actually extracted. countMatchedSummaries, findUnsummarizedPlans
and findOrphanSummaries all inherit it. The two-plans-one-summary collision
behaviour of the original rule is preserved deliberately and documented in
place.

Second defect, independently root-caused while verifying: routing the #2893
naming diagnostic through scanPhasePlans exposed it to the loose /PLAN/i
fallback, which is correct for counting and wrong for a naming check — a
non-canonically-named file was accepted as a valid plan and the diagnostic
went silent. cmdPhasesList, cmdFindPhase and cmdPhasePlanIndex now intersect
with a strict isCanonicalPlanFile predicate before reporting names.

Same class as the describeNonCanonicalPlans trap already recorded in ADR-3180:
a question about file naming wants the physical, strictly-matched set; only a
question about outstanding work wants the live set.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): register planning-scope.cjs in the eslint migration list

tests/repo-invariants.test.cjs asserts every bin/lib/*.cjs is linted xor
ignored per its ADR-457 migration state. The new planning-scope module closed
five of the six .cts ripple gates - gitignore, inventory manifest, INVENTORY.md
and the CONTEXT.md glossary - but not eslint, because that one is enforced by a
test rather than by lint:ci, so the local pipeline stayed green while it was
missing.

Generated from src/planning-scope.cts, so the .cjs is ignored and the .cts is
linted, matching every other migrated module.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* fix(#3183): replace the plan-count drift detector with a literal tokenizer

CodeQL reported 4 high-severity js/redos alerts on REGEX_LITERAL_MD_RE, the
backtracking regex that finds "a regex literal mentioning PLAN/SUMMARY and an
escaped \.md". Five review rounds found it had two defects, not one:

  - EXPONENTIAL, then CUBIC. Its "any char" atom `(?:\\.|[^/\r\n])` let a `\.`
    pair be consumed either as one escape or as two class characters, which is
    exponential backtracking: 27,464ms on `"/\.mdplan" + "\.".repeat(28) + "X"`.
    Excluding `\` from the class killed that but left a cubic path — 23ms at
    N=200, 172ms at N=400, 1362ms at N=800 on `"/" + "PLAN\.md".repeat(N)` with
    no closing `/`. This guard is the last stage of `npm run lint:ci`, which CI
    runs on fork pull requests, so a crafted src/*.cts could stall the job.
  - A DETECTION HOLE. A character class holding a bare, unescaped `/` — e.g.
    `/SUMMARY[^/]*\.md$/`, an ordinary path-excluding filter — terminated the
    literal at that `/`, so the scan never reached `\.md` and the guard missed
    it entirely. (Classes holding an ESCAPED `\/` were already matched; the
    tests cover those separately as parity, not as regressions.)

Both defects have one root cause: regex-literal grammar — `\x` escapes, and
`/` inside `[...]` not terminating — is not expressible in a backtracking
regex. So the detector is now a tokenizer, not a regex.

readRegexLiteralAt reads the literal at a given `/` in a single left-to-right
pass with no backtracking, treating escapes as two-character units and
suppressing the `/` terminator inside a character class. findRegexLiteralMdMatch
restarts it at every `/` on the line, preserving the old "find anywhere"
behaviour; MAX_REGEX_LITERAL_LEN (400) bounds each read — including the
trailing-flag scan — which keeps the whole-line cost linear.

Results: cubic shape flat at 0.06-0.39ms out to N=3200 (25KB), exponential
shape 0.01ms at 28 reps and 0.00ms at 64, and the bare-`/` class shapes are now
caught. Differential against the old regex over 28,474 lines (those matching
FILENAME_TEST_RE but not PLAN_SUMMARY_LITERAL_RE, across src/tests/scripts/
gsd-core/bin/eslint-rules, excluding 265 lines with >6 backslashes on which the
old regex hangs): 6 differences, all the tokenizer returning the fuller or
newly-correct literal, 0 old-only misses. The `\.md` token stays
case-insensitive, matching the `/i` the old regex carried.

Also closes three holes in the same new file:

  - walk() tested entry.isFile(), false for a symlink, so a symlinked
    src/*.cts was silently unscanned — an evasion of a guard whose stated
    principle (ADR-3180 Decision 4a) is whole-repo discovery with no allowlist.
    It now resolves symlinks, but confined: file links must resolve inside the
    repo root, directory links inside the scanned dir itself. Every sibling
    drift guard in scripts/ uses the Dirent classification and never follows
    links, so following them unconfined would have made this the only linter
    able to read outside the tree — on fork PRs an arbitrary out-of-repo read
    whose matched fragments reach a public CI log. The narrower directory rule
    additionally stops `src/up -> ..` from sweeping the whole repo, and the
    skip list is now checked against resolved paths so `src/g -> ../.git`
    cannot reach .git/** or node_modules/**. Real paths are de-duplicated and
    files reported canonically, so a symlink alias cannot shift which
    FUNCTION_SCOPED_EXEMPTIONS key applies.
  - Both the reported fragment and the reported FILE PATH are attacker-
    controlled source text written straight to a CI log, and git permits
    control bytes in a filename. Both are now escaped — C0/C1/DEL plus the
    bidi and zero-width controls — so a crafted literal or filename cannot
    recolour the log, overwrite a line with CR, or fabricate a line that looks
    like this guard's own success output.

Regression coverage in tests/plan-count-single-owner.test.cjs: a child-process
probe over both pathological shapes (catastrophic backtracking is synchronous
and would freeze the suite rather than fail one test), the bare-`/` class
shapes verified to fail against the parent-commit blob, root-confinement tests
covering the outside-file, outside-directory, cycle, broken-link and duplicate
cases, direct isInsideRoot coverage including the sibling-prefix case that a
bare startsWith would let through, sanitizeForReport coverage, and
limit-1/limit/limit+1 coverage of MAX_REGEX_LITERAL_LEN derived from the
exported constant. The earlier structural assertion was dropped — it checked
for the substring `[^/`, which respelling the class as `[^\r\n/]` defeats
while staying exponential.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

* chore(#3183): backfill changeset PR number

Restores b77931869, which a force-push during the ReDoS remediation dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012qYy4ZWif3sscQyMsup6Ma

---------

Co-authored-by: sim <sim@local>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 01:20:55 -04:00

999 lines
33 KiB
TypeScript

/**
* Open Artifact Audit — Cross-type unresolved state scanner
*
* Scans all .planning/ artifact categories for items with open/unresolved state.
* Returns structured JSON for workflow consumption.
* Called by: gsd-tools.cjs audit-open
* Used by: /gsd:complete-milestone pre-close gate
*
* ADR-457 build-at-publish: the hand-written bin/lib/audit.cjs collapsed
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
* from the prior hand-written .cjs; only strict types are added.
*/
import fs from 'node:fs';
import path from 'node:path';
import { platformReadSync } from './shell-command-projection.cjs';
import { collectSection } from './markdown-sectionizer.cjs';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspace = require('./planning-workspace.cjs');
const { planningDir } = planningWorkspace;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import frontmatter = require('./frontmatter.cjs');
const { extractFrontmatter } = frontmatter;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
import { requireSafePath, sanitizeForDisplay } from './security.cjs';
// ─── Types ────────────────────────────────────────────────────────────────────
interface DebugSessionItem {
slug: string;
status: string;
updated: string;
hypothesis: string;
scan_error?: boolean;
}
interface QuickTaskItem {
slug: string;
date: string;
status: string;
description: string;
scan_error?: boolean;
}
interface ThreadItem {
slug: string;
status: string;
updated: string;
title: string;
scan_error?: boolean;
}
interface TodoItem {
filename: string;
priority: string;
area: string;
summary: string;
scan_error?: boolean;
_remainder_count?: number;
}
interface SeedItem {
seed_id: string;
slug: string;
status: string;
title: string;
scan_error?: boolean;
}
interface UatGapItem {
phase: string;
file: string;
status: string;
open_scenario_count: number;
scan_error?: boolean;
}
interface VerificationGapItem {
phase: string;
file: string;
status: string;
scan_error?: boolean;
}
interface ContextQuestionItem {
phase: string;
file: string;
question_count: number;
questions: string[];
scan_error?: boolean;
}
interface DeferredItem {
phase: string;
file: string;
text: string;
scan_error?: boolean;
}
/**
* Minimal structural view of `uat.cjs` — only the export `scanDeferredItems`
* lazily requires. Mirrors the local-interface convention in
* `audit-command-router.cts`, which types its lazy requires the same way.
*/
interface UatDeferredModule {
parseDeferredItems(content: string): Array<{ name: string }>;
}
interface AuditCounts {
debug_sessions: number;
quick_tasks: number;
threads: number;
todos: number;
seeds: number;
uat_gaps: number;
verification_gaps: number;
context_questions: number;
deferred_items: number;
total: number;
}
interface AuditResult {
scanned_at: string;
has_open_items: boolean;
counts: AuditCounts;
items: {
debug_sessions: DebugSessionItem[];
quick_tasks: QuickTaskItem[];
threads: ThreadItem[];
todos: TodoItem[];
seeds: SeedItem[];
uat_gaps: UatGapItem[];
verification_gaps: VerificationGapItem[];
context_questions: ContextQuestionItem[];
deferred_items: DeferredItem[];
};
}
// The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
// verbatim with the #2287 phase-boundary reader in `uat.cts`.
const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
// not recreated on each loop iteration.
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
// ─── scanDebugSessions ────────────────────────────────────────────────────────
/**
* Scan .planning/debug/ for open sessions.
* Open = status NOT in ['resolved', 'complete'].
* Ignores the resolved/ subdirectory.
*/
function scanDebugSessions(planDir: string): DebugSessionItem[] {
const debugDir = path.join(planDir, 'debug');
if (!fs.existsSync(debugDir)) return [];
const results: DebugSessionItem[] = [];
let files: fs.Dirent[];
try {
files = fs.readdirSync(debugDir, { withFileTypes: true });
} catch {
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
}
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = path.join(debugDir, entry.name);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'debug session file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
const status = ((fm.status as string) || 'unknown').toLowerCase();
if (status === 'resolved' || status === 'complete') continue;
// Extract hypothesis from "Current Focus" block if parseable
let hypothesis = '';
const focusSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
if (focusSection) {
const focusText = focusSection.body.trim().split('\n')[0].trim();
hypothesis = sanitizeForDisplay(focusText.slice(0, 100));
}
const slug = path.basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
hypothesis,
});
}
return results;
}
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
/**
* Scan .planning/quick/ for incomplete tasks.
* Incomplete if SUMMARY.md missing or status !== 'complete'.
*/
function scanQuickTasks(planDir: string): QuickTaskItem[] {
const quickDir = path.join(planDir, 'quick');
if (!fs.existsSync(quickDir)) return [];
let entries: fs.Dirent[];
try {
entries = fs.readdirSync(quickDir, { withFileTypes: true });
} catch {
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
}
const results: QuickTaskItem[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
const dirName = entry.name;
const taskDir = path.join(quickDir, dirName);
let safeTaskDir: string;
try {
safeTaskDir = requireSafePath(taskDir, planDir, 'quick task dir', { allowAbsolute: true });
} catch {
continue;
}
// workflows/quick.md mandates `${quick_id}-SUMMARY.md`; older flows used
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
//
// #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
// scanPhasePlans migration): this scans a quick task's OWN directory
// (`.planning/quick/<task>/`) for THAT task's single completion record —
// "does this one quick task have a SUMMARY.md" — not a phase directory's
// live-plan/summary counting question. scanPhasePlans is the wrong tool
// here; there is no plan/summary PAIRING to derive, only a single
// filename presence check local to a non-phase directory.
let summaryPath: string | null = null;
try {
const summaryFiles = fs.readdirSync(safeTaskDir, { withFileTypes: true })
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
if (summaryFiles.length > 0) {
// Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|| summaryFiles[0];
summaryPath = path.join(safeTaskDir, preferred.name);
}
} catch {
// fall through with summaryPath = null → status: missing
}
let status = 'missing';
const description = '';
if (summaryPath && fs.existsSync(summaryPath)) {
let safeSum: string;
try {
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeSum);
if (content === null) {
status = 'unreadable';
} else {
const fm = extractFrontmatter(content, safeSum);
status = ((fm.status as string) || 'unknown').toLowerCase();
}
}
if (status === 'complete') continue;
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
let date = '';
let slug = sanitizeForDisplay(dirName);
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
if (dateMatch) {
date = dateMatch[1];
slug = sanitizeForDisplay(dateMatch[2]);
}
results.push({
slug,
date,
status: sanitizeForDisplay(status),
description,
});
}
return results;
}
// ─── scanThreads ──────────────────────────────────────────────────────────────
/**
* Scan .planning/threads/ for open threads.
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
*/
function scanThreads(planDir: string): ThreadItem[] {
const threadsDir = path.join(planDir, 'threads');
if (!fs.existsSync(threadsDir)) return [];
let files: fs.Dirent[];
try {
files = fs.readdirSync(threadsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
}
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
const results: ThreadItem[] = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.md')) continue;
const filePath = path.join(threadsDir, entry.name);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'thread file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
let status = ((fm.status as string) || '').toLowerCase().trim();
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
if (!status) {
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
if (bodyStatusMatch) {
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
}
}
if (!openStatuses.has(status)) continue;
// Extract title from # Thread: heading or frontmatter title
let title = sanitizeForDisplay(fm.title || '');
if (!title) {
const headingMatch = content.match(/^#\s*Thread:\s*(.+)$/m);
if (headingMatch) {
title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
}
const slug = path.basename(entry.name, '.md');
results.push({
slug: sanitizeForDisplay(slug),
status: sanitizeForDisplay(status),
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
title,
});
}
return results;
}
// ─── scanTodos ────────────────────────────────────────────────────────────────
/**
* Scan .planning/todos/pending/ for pending todos.
* Returns array of { filename, priority, area, summary }.
* Display limited to first 5 + count of remainder.
*/
function scanTodos(planDir: string): TodoItem[] {
const pendingDir = path.join(planDir, 'todos', 'pending');
if (!fs.existsSync(pendingDir)) return [];
let files: fs.Dirent[];
try {
files = fs.readdirSync(pendingDir, { withFileTypes: true });
} catch {
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
}
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
const results: TodoItem[] = [];
const displayFiles = mdFiles.slice(0, 5);
for (const entry of displayFiles) {
const filePath = path.join(pendingDir, entry.name);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
// Extract first line of body after frontmatter
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
const firstLine = bodyMatch.trim().split('\n')[0] || '';
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
results.push({
filename: sanitizeForDisplay(entry.name),
priority: sanitizeForDisplay(fm.priority || ''),
area: sanitizeForDisplay(fm.area || ''),
summary,
});
}
if (mdFiles.length > 5) {
results.push({ _remainder_count: mdFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
}
return results;
}
// ─── scanSeeds ────────────────────────────────────────────────────────────────
/**
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
* Unimplemented if status in ['dormant', 'active', 'triggered'].
*/
function scanSeeds(planDir: string): SeedItem[] {
const seedsDir = path.join(planDir, 'seeds');
if (!fs.existsSync(seedsDir)) return [];
let files: fs.Dirent[];
try {
files = fs.readdirSync(seedsDir, { withFileTypes: true });
} catch {
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
}
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
const results: SeedItem[] = [];
for (const entry of files) {
if (!entry.isFile()) continue;
if (!entry.name.startsWith('SEED-') || !entry.name.endsWith('.md')) continue;
const filePath = path.join(seedsDir, entry.name);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'seed file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
const status = ((fm.status as string) || 'dormant').toLowerCase();
if (!unimplementedStatuses.has(status)) continue;
// Extract seed_id from filename or frontmatter
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
const slug = sanitizeForDisplay(seed_id.replace(/^SEED-/, ''));
let title = sanitizeForDisplay(fm.title || '');
if (!title) {
const headingMatch = content.match(/^#\s*(.+)$/m);
if (headingMatch) title = sanitizeForDisplay(headingMatch[1].trim().slice(0, 100));
}
results.push({
seed_id: sanitizeForDisplay(seed_id),
slug,
status: sanitizeForDisplay(status),
title,
});
}
return results;
}
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
/**
* Scan .planning/phases for UAT gaps (UAT files with status != 'complete').
*/
function scanUatGaps(planDir: string): UatGapItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
}
const results: UatGapItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-UAT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
const status = ((fm.status as string) || 'unknown').toLowerCase();
const result = ((fm.result as string) || '').toLowerCase();
// Also accept `result: all_pass` as a fallback when status is absent
// — covers UATs that omit `status:`.
if (TERMINAL_UAT_STATUSES.has(status)) continue;
if (status === 'unknown' && result === 'all_pass') continue;
// Count open scenarios
const pendingMatches = (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
open_scenario_count: pendingMatches,
});
}
}
return results;
}
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
/**
* Scan .planning/phases for VERIFICATION gaps.
*/
function scanVerificationGaps(planDir: string): VerificationGapItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true, phase: '', file: '', status: '' }];
}
const results: VerificationGapItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
const status = ((fm.status as string) || 'unknown').toLowerCase();
if (status !== 'gaps_found' && status !== 'human_needed') continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
status: sanitizeForDisplay(status),
});
}
}
return results;
}
// ─── scanContextQuestions ─────────────────────────────────────────────────────
/**
* Scan .planning/phases for CONTEXT files with open_questions.
*/
function scanContextQuestions(planDir: string): ContextQuestionItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
}
const results: ContextQuestionItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
let files: string[];
try {
files = fs.readdirSync(phaseDir);
} catch {
continue;
}
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
const filePath = path.join(phaseDir, file);
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
const fm = extractFrontmatter(content, safeFilePath);
// Check frontmatter open_questions field
let questions: string[] = [];
if (fm.open_questions) {
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
questions = (fm.open_questions as unknown[]).map(q => sanitizeForDisplay(String(q).slice(0, 200)));
}
}
// Also check for ## Open Questions section in body
if (questions.length === 0) {
const oqSection = collectSection(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
if (oqSection) {
const oqBody = oqSection.body.trim();
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
const items = oqBody.split('\n')
.map((l: string) => l.trim())
.filter((l: string) => l && l !== '-' && l !== '*')
.filter((l: string) => /^[-*\d]/.test(l) || l.includes('?'));
questions = items.slice(0, 3).map((q: string) => sanitizeForDisplay(q.slice(0, 200)));
}
}
}
if (questions.length === 0) continue;
results.push({
phase: sanitizeForDisplay(phaseNum),
file: sanitizeForDisplay(file),
question_count: questions.length,
questions: questions.slice(0, 3),
});
}
}
return results;
}
// ─── scanDeferredItems ────────────────────────────────────────────────────────
/**
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646).
*
* The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
* log an out-of-scope discovery here rather than fix it. #2287 made that file
* readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this
* scanner closes the remaining reader gap one boundary up, so an entry still
* unresolved at MILESTONE close surfaces in the pre-close audit alongside the
* other eight categories and the existing `[R]/[A]/[C]` prompt applies to it.
* Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871)
* and the entry leaves the live tree having never been triaged.
*
* The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
* already exports `parseDeferredItems`, which owns the parsing rule (entries
* under a `## Deferred Items` level-2 heading, else the whole file fail-safe;
* RESOLVED only on an explicit case-insensitive `status: resolved` field).
* Duplicating that inequality is how two readers of the same file drift into
* disagreeing about what "open" means. The require is deliberately LAZY,
* inside the scan, to preserve `audit-command-router.cts`'s property that a
* route never loads the module it does not need.
*/
function scanDeferredItems(planDir: string): DeferredItem[] {
const phasesDir = path.join(planDir, 'phases');
if (!fs.existsSync(phasesDir)) return [];
let dirs: string[];
try {
dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
.filter(e => e.isDirectory())
.map(e => e.name)
.sort();
} catch {
return [{ scan_error: true, phase: '', file: '', text: '' }];
}
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
const uat: UatDeferredModule = require('./uat.cjs');
const results: DeferredItem[] = [];
for (const dir of dirs) {
const phaseDir = path.join(phasesDir, dir);
const phaseMatch = dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
const phaseNum = phaseMatch ? phaseMatch[1] : dir;
const filePath = path.join(phaseDir, DEFERRED_ITEMS_FILENAME);
if (!fs.existsSync(filePath)) continue;
let safeFilePath: string;
try {
safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', { allowAbsolute: true });
} catch {
continue;
}
const content = platformReadSync(safeFilePath);
if (content === null) continue;
for (const item of uat.parseDeferredItems(content)) {
results.push({
phase: sanitizeForDisplay(phaseNum),
file: DEFERRED_ITEMS_FILENAME,
text: sanitizeForDisplay(item.name),
});
}
}
return results;
}
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
/**
* Main audit function. Scans all .planning/ artifact categories.
*
* @param cwd - Project root directory
* @returns Structured audit result
*/
function auditOpenArtifacts(cwd: string): AuditResult {
const planDir = planningDir(cwd);
const debugSessions = (() => {
try { return scanDebugSessions(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }]; }
})();
const quickTasks = (() => {
try { return scanQuickTasks(planDir); } catch { return [{ scan_error: true, slug: '', date: '', status: '', description: '' }]; }
})();
const threads = (() => {
try { return scanThreads(planDir); } catch { return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }]; }
})();
const todos = (() => {
try { return scanTodos(planDir); } catch { return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }]; }
})();
const seeds = (() => {
try { return scanSeeds(planDir); } catch { return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }]; }
})();
const uatGaps = (() => {
try { return scanUatGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }]; }
})();
const verificationGaps = (() => {
try { return scanVerificationGaps(planDir); } catch { return [{ scan_error: true, phase: '', file: '', status: '' }]; }
})();
const contextQuestions = (() => {
try { return scanContextQuestions(planDir); } catch { return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }]; }
})();
const deferredItems = (() => {
try { return scanDeferredItems(planDir); } catch { return [{ scan_error: true, phase: '', file: '', text: '' }]; }
})();
// Count real items (not scan_error sentinels)
const countReal = (arr: Array<{ scan_error?: boolean; _remainder_count?: number }>) =>
arr.filter(i => !i.scan_error && !i._remainder_count).length;
const counts: AuditCounts = {
debug_sessions: countReal(debugSessions),
quick_tasks: countReal(quickTasks),
threads: countReal(threads),
todos: countReal(todos),
seeds: countReal(seeds),
uat_gaps: countReal(uatGaps),
verification_gaps: countReal(verificationGaps),
context_questions: countReal(contextQuestions),
deferred_items: countReal(deferredItems),
total: 0,
};
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions + counts.deferred_items;
return {
scanned_at: new Date().toISOString(),
has_open_items: counts.total > 0,
counts,
items: {
debug_sessions: debugSessions,
quick_tasks: quickTasks,
threads,
todos,
seeds,
uat_gaps: uatGaps,
verification_gaps: verificationGaps,
context_questions: contextQuestions,
deferred_items: deferredItems,
},
};
}
// ─── formatAuditReport ────────────────────────────────────────────────────────
/**
* Format the audit result as a human-readable report.
*
* @param auditResult - Result from auditOpenArtifacts()
* @returns Formatted report
*/
function formatAuditReport(auditResult: AuditResult): string {
const { counts, items, has_open_items } = auditResult;
const lines: string[] = [];
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
lines.push(hr);
lines.push(' Milestone Close: Open Artifact Audit');
lines.push(hr);
if (!has_open_items) {
lines.push('');
lines.push(' All artifact types clear. Safe to proceed.');
lines.push('');
lines.push(hr);
return lines.join('\n');
}
// Debug sessions (blocking quality — red)
if (counts.debug_sessions > 0) {
lines.push('');
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
}
}
// UAT gaps (blocking quality — red)
if (counts.uat_gaps > 0) {
lines.push('');
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
}
}
// Verification gaps (blocking quality — red)
if (counts.verification_gaps > 0) {
lines.push('');
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} [${item.status}]`);
}
}
// Quick tasks (incomplete work — yellow)
if (counts.quick_tasks > 0) {
lines.push('');
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
const d = item.date ? ` (${item.date})` : '';
lines.push(` • ${item.slug}${d} [${item.status}]`);
}
}
// Todos (incomplete work — yellow)
if (counts.todos > 0) {
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
const remainder = items.todos.find(i => i._remainder_count);
lines.push('');
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
for (const item of realTodos) {
const area = item.area ? ` [${item.area}]` : '';
const pri = item.priority ? ` (${item.priority})` : '';
lines.push(` • ${item.filename}${area}${pri}`);
if (item.summary) lines.push(` ${item.summary}`);
}
if (remainder) {
lines.push(` ... and ${remainder._remainder_count} more`);
}
}
// Threads (deferred decisions — blue)
if (counts.threads > 0) {
lines.push('');
lines.push(`🔵 Open Threads (${counts.threads} active)`);
for (const item of items.threads.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.slug} [${item.status}]${title}`);
}
}
// Seeds (deferred decisions — blue)
if (counts.seeds > 0) {
lines.push('');
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
for (const item of items.seeds.filter(i => !i.scan_error)) {
const title = item.title ? ` — ${item.title}` : '';
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
}
}
// Context questions (deferred decisions — blue)
if (counts.context_questions > 0) {
lines.push('');
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
for (const item of items.context_questions.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
for (const q of item.questions) {
lines.push(` - ${q}`);
}
}
}
// Deferred items (deferred decisions — blue). Out-of-scope discoveries a
// phase agent recorded rather than fixed, still unresolved at close (#2646).
if (counts.deferred_items > 0) {
lines.push('');
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
lines.push(` • Phase ${item.phase}: ${item.text}`);
}
}
lines.push('');
lines.push(hr);
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
lines.push(hr);
return lines.join('\n');
}
export = { auditOpenArtifacts, formatAuditReport };