* 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>
999 lines
33 KiB
TypeScript
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 };
|