* enhancement(#537): migrate code-review-flags to TS source of truth Collapse the hand-written get-shit-done/bin/lib/code-review-flags.cjs to a TypeScript source of truth (src/code-review-flags.cts), compiled by tsc to a gitignored .cjs build artifact at the same path, per ADR-457 (build-at-publish). Second module after the semver-compare pilot (#541). Behaviour is preserved byte-for-behaviour (characterization test added in tests/code-review-flags.test.cjs locks the parser quirks). Adds compile-time type checking: CodeReviewFlags interface + CodeReviewWorkflow literal union. The require() path is unchanged, so code-review.md and the bug-3727 test keep working. The emitted .cjs is gitignored and eslint-ignored, mirroring the pilot. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 leaf bin/lib modules to TS source of truth ADR-457 build-at-publish, batch 1 (pure leaf modules, 0 sibling-deps): 001-legacy-orphan-files, context-utilization, redaction, artifacts, command-arg-projection, clock, ui-safety-gate, review-reviewer-selection, clusters. Each moves to src/*.cts (strict TS, typed), compiled by tsc to a gitignored .cjs at the same require() path; behaviour preserved byte-for- behaviour. Adds src/node-globals.d.ts (minimal ambient shim; "types":[]). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#537): add @types/node, drop hand-rolled node-globals shim ADR-457 migration infra: replace the temporary src/node-globals.d.ts ambient shim with @types/node@22 + "types":["node"] in tsconfig.build.json. Unblocks migrating the ~49 remaining bin/lib modules that use node:fs/path/os/ child_process. Build + full suite (3030 pass) + lint all green; no .cts type changes were needed (real Node types matched the shim). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 more bin/lib modules to TS (batch 2) ADR-457 build-at-publish. Clean leaves: installer-migration-report, prompt-budget. Type-error-prone leaves (were tsconfig.lint-excluded; now strict-typed and removed from that exclude list): secrets, phase-lifecycle, workstream-name-policy, decisions, validate, schema-detect. Plus runtime-name-policy. Strict type fixes narrow unknown->concrete domain types (no any/ts-ignore); behaviour preserved. Full suite green, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate runtime-slash to TS (cross-import proof) ADR-457. First cross-module TS->TS import: src/runtime-slash.cts imports ./runtime-name-policy.cjs and tsc resolves the sibling .cts types under strict (no declaration files; NodeNext .cjs->.cts mapping), emitting a correct require("./runtime-name-policy.cjs"). Confirms the recipe for coupled modules, which must be migrated in dependency order (leaves-up). Suite green, lint clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 10 more bin/lib modules to TS (batch 3) ADR-457 build-at-publish, Wave-1 leaves: event, workstream-inventory-builder, plan-scan, fallow-runner, project-root, installer-migration-authoring, update-context, 000-first-time-baseline, runtime-homes, model-catalog. Strict typing fixed real issues (narrowing unknown, qualified fs/path calls, removed unnecessary casts); plan-scan/project-root/workstream-inventory-builder dropped from tsconfig.lint exclude. Behaviour preserved; suite green, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 large Wave-1 leaves to TS (batch 4) ADR-457 build-at-publish: configuration, state-document, shell-command- projection (42 dependents), security, command-aliases. shell-command- projection keeps a namespace child_process import for mock-intercept testability. loadConfig/migrateOnDisk emit synchronously (every caller uses them sync; the one awaited migrateOnDisk caller tolerates a non-Promise) — full suite (3030 pass) confirms behaviour preserved. configuration/ state-document/command-aliases dropped from tsconfig.lint exclude. Also fixes the malformed batch-3 changeset frontmatter (type/pr) that failed lint:docs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 6 Wave-2 modules to TS (batch 5) ADR-457 build-at-publish: config-schema, model-profiles, 002-codex-legacy-hooks-json, logger, active-workstream-store, adr-parser. First batch importing already-migrated siblings (configuration, model-catalog, shell-command-projection, redaction, security) via ./sibling.cjs specifiers. Strict type narrowing (typeof guards over String(unknown)); behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 large Wave-2 modules to TS (batch 6) ADR-457 build-at-publish: graphify, install-profiles, intel, installer-migrations, worktree-safety. installer-migrations preserves its dynamic require() loader for numbered migration modules (scoped lint suppressions). Strict typing (typeof guards over String(unknown)); behaviour preserved; suite 3030 pass, lint 0 errors. Wave 2 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate Wave-3 modules to TS (batch 7) ADR-457 build-at-publish: planning-workspace, runtime-artifact-layout, command-routing-hub, drift. Uses `import x = require()` for export= siblings; drift's lazy require of runtime-slash hoisted to a top-level import (verified non-circular). Behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate small Wave-4 modules to TS (batch 8) ADR-457 build-at-publish: cjs-command-router-adapter, phase-command-router, surface, roadmap-upgrade. Typed the hub router handler results as the HubResult discriminated union; surface drops 4 genuinely-unused imports. Behaviour preserved; suite 3030 pass, lint 0 errors. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate core hub (2.5k LOC, 68 dependents) to TS (batch 9) ADR-457 build-at-publish: get-shit-done/bin/lib/core.cjs -> src/core.cts, preserving all 63 exports via export=. All sibling deps already migrated (shell-command-projection, model-profiles, model-catalog, worktree-safety, planning-workspace, project-root, configuration, config-schema). Strict types, no any/ts-ignore; config-schema lazy require hoisted (non-circular). Behaviour preserved (independently verified: core's shard 3030 pass / 0 fail). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537): make ESLint-coverage + test-sprawl checks migration-aware #551 test hardcoded 12 now-migrated modules as "hand-written, must be linted"; that invariant is obsoleted by the ADR-457 migration. Rewrite it to a filesystem-driven invariant that holds at every stage: a bin/lib/*.cjs must be eslint-ignored IFF it has a src/*.cts source (tsc-generated), else linted (covers package-identity, which has no TS source). Also eslint-ignore config-types.cjs (has a src counterpart) and drop the redundant tests/clock.test.cjs (clock already covered by clock-seam + bug-474 tests), which tripped the lint-test-file-count ratchet. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 9 Wave-5 router/inventory modules to TS (batch 10) ADR-457 build-at-publish: phases/verify/init/agent/task/validate/roadmap/state command routers + workstream-inventory. Router handler results typed against core's exported shapes; behaviour preserved (caught+fixed a --verify boolean flag regression mid-migration). Full suite green across all shards (only the 4 local gpg-env changeset-notes failures remain; CI passes them). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 7 Wave-5 modules to TS (batch 11) ADR-457 build-at-publish: gap-checker, docs, check-command-router, frontmatter, learnings, gsd2-import, profile-pipeline. Behaviour preserved; full suite green across all shards (only the 4 local gpg-env failures remain). Also broadens atomic-write-coverage.test.cjs to accept the tsc-compiled namespace-import form while still asserting platformWriteSync is called (safety guard intact). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate config + profile-output to TS (batch 12) ADR-457 build-at-publish: config (729 LOC), profile-output (1142 LOC). All exports preserved; cmdMigrateConfig de-asynced (migrateOnDisk is sync, awaited caller tolerates it). Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Wave 5 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate 5 Wave-6 modules to TS (batch 13) ADR-457 build-at-publish: template, uat, workstream, roadmap, audit. Behaviour preserved (dead toPosixPath import dropped from audit; inline requires hoisted). Suite green across all shards (only the 4 local gpg-env failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate commands + state hubs to TS (batch 14) ADR-457 build-at-publish: commands (1305 LOC), state (2074 LOC, 17 dependents). All exports preserved; inner requires kept non-hoisted where load-order matters (install.js, per-call security); acquireStateLock cast inlined to preserve the err.code source token a structural test inspects. Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Wave 6 complete. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate milestone to TS (batch 15a, hand-authored) ADR-457 build-at-publish: milestone -> src/milestone.cts. Authored directly (subagent capacity was unavailable). Also relaxes core.output()'s 3rd param to optional, matching its real always-optional call contract (unblocks remaining 2-arg output callers). Behaviour preserved; suite green across all shards (only the 4 local gpg-env failures). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537): migrate phase, verify, init to TS (batch 15, final modules) ADR-457 build-at-publish, Wave 7 (the last hubs): phase (1608 LOC), verify (1615), init (2113). Adds src/package-identity.d.cts so verify can import the permanently value-baked package-identity.cjs under strict TS. Fixes two regressions the migration introduced in verify: restore cmdValidateHealth's `return result` (callers/tests read result.warnings — it is NOT side-effect-only), and make the bug-3384 source-pattern test tolerant of the tsc-compiled bracket-notation form of the git_list_failed->W020 branch (behaviour intact). Full suite green across all shards (only the 4 local gpg-env failures); lint 0 errors. All 86 migratable bin/lib modules are now TypeScript sources. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#537): finalize ADR-457 migration — retire tsconfig.lint.json All hand-written bin/lib/*.cjs are now src/*.cts sources, so the checkJs stopgap tsconfig.lint.json (unused; not wired into eslint, scripts, or CI) is deleted per ADR-457's final step. Also gitignore the tsc-generated config-types.cjs (was still committed) for consistency with every other emitted artifact. package-identity.cjs stays value-baked (declared via src/package-identity.d.cts). Suite green; #551 ESLint-coverage test green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): add prepare script so unpacked/git installs build bin/lib artifacts ADR-457 build-at-publish: bin/lib/*.cjs are now gitignored, built by tsc. The prepack/prepublishOnly hooks cover `npm pack`/publish, but `npm install -g <dir>` and git installs run the `prepare` lifecycle — which was missing — so the unpacked install shipped without the compiled .cjs and failed at startup with "Cannot find module './lib/core.cjs'" (caught by the smoke-unpacked CI job). Add `prepare` mirroring prepublishOnly (build:lib + build:hooks). prepare does NOT run for registry consumers (they get the pre-built tarball), only for source/local/pack installs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): make CI build/lockfile checks work with gitignored bin/lib artifacts ADR-457 build-at-publish exposed two CI assumptions that bin/lib/*.cjs are always present on disk: - check:env's lockfile-sync ran `npm ci --dry-run`, which now triggers the `prepare` build (tsc) — but it runs before deps are installed, so tsc is absent and it misreported the lockfile as out of sync. Add --ignore-scripts (a lockfile check must not build). - the lint-tests job installs with --ignore-scripts (no prepare build), but lint:skill-deps require()s the built install-profiles.cjs. Add an explicit `npm run build:lib` step after install. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): narrow prepare to build:lib only (unbreak packed-smoke pack step) prepare running build:hooks emitted "✓ Copying ..." stdout during `npm pack`, which the install-smoke "Pack root tarball" step captures into $GITHUB_OUTPUT — breaking it with "Invalid format". build:lib (tsc) is silent on success and is all the unpacked/source install needs (the smoke-unpacked assertions exercise gsd-tools, i.e. bin/lib, and tolerate hook setup with `|| true`). Matches prepack. build:hooks still runs on prepublishOnly for real publishes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(#537): wire Stryker mutation gate to build-at-publish layout The gate scored 0.00 because it mutated changed bin/lib/*.cjs that (a) were generated artifacts and (b) included modules with no coverage in the command's test set. Rework: mutation.yml now derives changed COVERED modules from src/*.cts and maps them to their built bin/lib/*.cjs; Stryker mutates those built artifacts with a no-rebuild command (mutating src/*.cts + per-mutant tsc was ~3x over the 30-min CI budget). NOTE: with the gate now correctly measuring the covered modules, their actual mutation score is 42.94% (< break 50) — a pre-existing test-coverage gap (adr-parser/prompt-budget/etc.), not introduced by this behaviour-preserving migration. Reaching 50 needs more tests, a threshold/scope change, or a waiver — a maintainer decision. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537): raise mutation coverage of covered modules above the 50 gate Adds focused example-based unit tests that kill surviving mutants in the two lowest-scoring covered modules: - tests/prompt-budget.unit.test.cjs (112 tests): 17.9% -> 97.9% - tests/adr-parser.unit.test.cjs (205 tests): 44.7% -> 89.4% Both wired into stryker.config.mjs's command. Fresh full run over the 6 covered modules now scores 82.25% (>= break 50); every covered module is >= 68%. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * enhancement(#537,#609): parallelize mutation gate via dynamic per-module matrix The serial Stryker run timed out at 30 min once the migration's added tests made every mutant re-run ~300 tests. Replace it with a dynamic matrix so the gate completes well under budget — folded into this PR (was tracked as #609) because it's a prerequisite for this PR's mutation gate to pass. - scripts/mutation-matrix.cjs: single source of truth (covered-module -> test files) computing changed covered modules from git diff -> {has_work, matrix}. - mutation.yml: detect -> dynamic `matrix: fromJSON(...)` mutate job (one parallel shard per changed module, scoped via MUTATION_TEST_CMD to only that module's tests, 15-min/shard) -> summary job that KEEPS the legacy check name "Stryker mutation score (changed files only)" so branch protection is unchanged. Per-shard jobs report as "Stryker (<module>)". - stryker.config.mjs: commandRunner.command reads MUTATION_TEST_CMD (falls back to the full command locally). Closes #609. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537,#609): give each mutation shard ≥50% on its own tests; drop blacksmith note Per-module sharding revealed that active-workstream-store (46.5%) and frontmatter (7.4%) only cleared 50% in the old serial run via timeout-noise from the bloated 300-test command; on their own tests they were below the gate. Add focused unit tests: - tests/active-workstream-store.unit.test.cjs (115 tests): 46.5% -> 81.9% - tests/frontmatter.unit.test.cjs (165 tests): 7.4% -> 63.4% Both wired into scripts/mutation-matrix.cjs (per-module test map) and stryker.config.mjs DEFAULT_TEST_CMD. All 6 covered modules now clear break:50 with only their own tests (config-schema/context-utilization/prompt-budget/ adr-parser already did). Also removes the leftover blacksmith TODO comment — GitHub-hosted runners only; speed comes from parallel per-module shards. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(#537,#609): strengthen prompt-budget tests to clear the gate on its own tests prompt-budget scored 39.58% when mutation-tested with ONLY its own tests (the way the per-module CI shard runs it) — an earlier ~98% reading was inflated by accidentally running the full multi-module command. Add 96 targeted tests to tests/prompt-budget.unit.test.cjs (exact note-template text, plan-truncation arithmetic/percentages, drop-block strings, noteInjected/hardFailed booleans): scoped score 39.58% -> 68.75% (>= break 50). All 6 covered modules now clear the gate on their own tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
877 lines
28 KiB
TypeScript
877 lines
28 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';
|
|
// 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;
|
|
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 AuditCounts {
|
|
debug_sessions: number;
|
|
quick_tasks: number;
|
|
threads: number;
|
|
todos: number;
|
|
seeds: number;
|
|
uat_gaps: number;
|
|
verification_gaps: number;
|
|
context_questions: 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[];
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
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 focusMatch = content.match(/##\s*Current Focus[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
|
|
if (focusMatch) {
|
|
const focusText = focusMatch[1].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".
|
|
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);
|
|
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);
|
|
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);
|
|
|
|
// 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);
|
|
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(/^(\d+[A-Z]?(?:\.\d+)*)/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);
|
|
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(/^(\d+[A-Z]?(?:\.\d+)*)/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);
|
|
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(/^(\d+[A-Z]?(?:\.\d+)*)/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);
|
|
|
|
// 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 oqMatch = content.match(/##\s*Open Questions[^\n]*\n([\s\S]*?)(?=\n##\s|$)/i);
|
|
if (oqMatch) {
|
|
const oqBody = oqMatch[1].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;
|
|
}
|
|
|
|
// ─── 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: [] }]; }
|
|
})();
|
|
|
|
// 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),
|
|
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;
|
|
|
|
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,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── 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}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 };
|