* test(#3884): failing-first coverage for strict argv and absence-signalling --pick ADR-3473 §8.4 says failure is a value. Three families currently encode failure as success, and this commit pins each one RED before the fix lands. Measured on this tree, 2026-08-26: gsd-tools generate-slug "test" --pick nonexistent -> empty stdout, exit 0 (#3365) gsd-tools audit-open --pick nonexistent_field -> dumps the entire human-readable audit report, exit 0 gsd-tools generate-slug "Hello World" --raw --pick bogus -> prints "hello-world", another field's value, exit 0 gsd-tools query state.planned-phase 3 (positional, no --phase) -> exit 0; STATE.md's "Phase: 2 of 5 (Widget Support)" is overwritten to "Phase: null - READY TO EXECUTE" and the frontmatter gains a corrupted current_phase_name (#3358) tests/pick-flag.test.cjs:27 previously asserted the #3365 defect as the contract ("returns empty string for missing field", success === true). That assertion is replaced by the required behavior rather than deleted. The new parseNamedArgs block calls the spec-object signature that does not exist yet, so it fails today by construction. The 11 existing behavior-lock tests are left untouched here; they are corrected in the implementation commit. C1/C4 assert at the consumer's output - STATE.md's bytes - per ADR-3180 Decision 4(b). A unit assertion on the parser would have passed throughout this defect's life. Design: .gsd/phase/feat-3884-failure-is-a-value/40-design.md Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * enhance(#3884): failure is a value — strict argv, and --pick that signals absence Implements ADR-3473 §8.4. Absence, emptiness and failure stop being interchangeable ways to say "I could not answer". parseNamedArgs (src/command-arg-projection.cts) Takes a spec object with a REQUIRED `positionals: number | 'rest'` and returns the hub's Result shape instead of a bare Record. Declaring the positional arity is what makes #3358's call site unrepresentable rather than merely detectable: an unrecognized flag or a token past the declared boundary is now InvalidArgs, naming the offending token and listing the accepted flags. The legacy positional-array call shape throws a TypeError — an internal invariant violation per ADR-3473 Decision 2, so a stale hand-written .cjs call site fails loudly instead of destructuring undefined off a Result. parseNamedArgsOrExit projects a failure onto the caller's error(); it is a projection over the one parser, not a second parser. Measured before, against a STATE.md with a populated phase-2 block: query state.planned-phase 3 (positional, no --phase) -> exit 0; "Phase: 2 of 5 (Widget Support)" overwritten to "Phase: null - READY TO EXECUTE", frontmatter gains a corrupted current_phase_name After: exit 1, `unexpected positional argument "3"`, STATE.md byte-identical. The flag form is unchanged and still updates STATE.md. --pick <field> (gsd-core/bin/gsd-tools.cjs) extractField returns {found,value}, and the pick block no longer shares one catch between "output was not JSON" and "field was absent". An absent field exits 1 with pick_field_absent, naming the field and the keys that do exist; non-JSON output exits 1 with pick_output_not_json instead of dumping the command's entire output. A field that is PRESENT with value null, '', 0 or false still prints at exit 0 — that is an answer, not a failure, and it is what keeps `--pick count` printing 0 on a fresh project. Measured before: `audit-open --pick nonexistent_field` printed the whole human-readable audit report at exit 0, and `generate-slug X --raw --pick bogus` printed "hello-world" — a different field's value, confidently, at exit 0. ADR-3409 Decision 7 explicitly deferred this contract fix to #3473; this is it. The sub-issue's "returns 0 when the count is zero OR absent" wording is superseded by the ADR rule it implements: zero prints 0, absence exits non-zero. Defaulting absence to 0 would demote "could not answer" to "the answer is zero" — the hazard docs/how-to/resolve-unreachable-guard-findings.md already warns against. Guard ledger (ADR-3473 Decision 6) scripts/lint-unreachable-guard-drift.cjs Detector A is RETIRED. Its premise — that a `--pick ... || echo` arm can never fire — is now false, so the shape it forbade is the correct idiom and keeping it would forbid the fix. Detector B (glob-consuming cat/ls, a nullglob mechanism this change does not touch) is retained in full, as are the shared scanner, the escape-marker parser and the baseline. Net: -1 detector, 0 added. The file is not deleted. Call-site audit 45 prompt-layer --pick invocations, every one a plain X=$(...) assignment — none in an if test, && chain, or a pipeline whose status is consumed, and no shell block in workflows/commands/agents/references sets -e. Of the 13 (command, field) pairs the prompt layer reads, 10 are always present; the 3 sometimes-absent ones each sit behind a prior found/existence check. No ADR-3409-class "field the command never produces" remains. Design: .gsd/phase/feat-3884-failure-is-a-value/40-design.md Test matrix: .gsd/phase/feat-3884-failure-is-a-value/50-test-matrix.md Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): escape untrusted tokens in diagnostics, and cover five unpinned rows Two review findings, both fixed here rather than recorded as limits. 1. A newline in an untrusted token forged a second stderr line. Before, plain-text mode: $ gsd-tools query state.planned-phase $'foo\nError: forged second line' Error: unexpected positional argument "foo Error: forged second line" After: Error: unexpected positional argument "foo\nError: forged second line" --json-errors mode was never affected — io.error runs that payload through JSON.stringify. Plain-text mode writes 'Error: ' + message verbatim, and the three new InvalidArgs reasons plus the two new --pick diagnostics all interpolate a token that comes straight from argv. Fixed with ONE shared helper, formatDiagnosticToken (src/io.cts), applied at every interpolation site — not a copy per site. It is deliberately NOT applied inside error() itself: several callers in this tree emit intentional multi-line diagnostics, and escaping newlines there would mangle them. The available-top-level-keys list needed the same treatment for a reason the review did not anticipate: `frontmatter get <file>` reads an ARBITRARY user document and echoes that document's own keys into the diagnostic. Verified reachable — a frontmatter key containing a newline reaches the key list — so formatKeyForDiagnosticList is guarding a live path, not a hypothetical one. Ordinary keys still render plain and unquoted; a fix that merely dropped the key would also have passed a "one line" assertion, so the test pins the escaped key's presence too. 2. Five behavior-table rows were implemented but nothing pinned them: B7 a dotted path that dies partway B9 bracket syntax on a non-array B10 a negative array index, in and out of range B14 a JSON root that is not an object B17 an @file: payload over 50KB B17 is the load-bearing one. output() writes @file:<path> instead of inline JSON past 50000 characters, and --pick resolves that BEFORE parsing; with no test, a future reordering of those two steps turns every large result into a false pick_output_not_json. The fixture seeds 1200 phase directories and measures the payload at 62474 characters, asserting the spill actually happened rather than assuming it. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): correct the strict-argv surface against a full verification run The first full run came back with 90 failures across 12 files, none in the new tests. They were the argv surface telling me what it actually is. Ten root causes; each classified before anything was changed. I over-implemented, and that is reverted. ADR-3473 §8.4 says parseNamedArgs rejects "unrecognized and positional tokens". It says nothing about a value flag whose value is missing. Making that an error was my design decision, not the rule, and it broke a deliberately recorded contract: `--prd` with no value resolving to null (tests/init.test.cjs emptyPrdValueIsFalsyAndTreatedAsAbsent, row B5; tests/section-manifest-init-facts.test.cjs "flag-shaped value"). The "requires a value" branch is deleted outright rather than kept behind an option — an unused strictness mode is speculative generality. Unknown-flag and unexpected-positional rejection, which is what §8.4 actually mandates, is unchanged. --wave needed a third flag kind the original design did not anticipate. `--wave N` is documented (commands/gsd/execute-phase.md:4,48) and the shipped workflow reconstructs and passes it (execute-phase.md:84), while #2932 records token-PRESENCE semantics: the CLI cares only that the flag appeared, and the value belongs to the workflow layer. That is neither a boolean flag nor a value flag, so `optionalValueFlags` now exists — presence-only in `data`, and the validation cursor consumes a following non-flag token so it is not reported as a stray positional. Every other declared boolean flag was checked against every argument-hint and prose usage in commands/, workflows/, agents/ and docs/; `--wave` is the only one of this shape. Five tests were pinning forms that never worked. tests/adr857-core-without-capabilities.test.cjs passed `init plan-phase --phase 01-stub`, but the documented form is positional (docs/CLI-TOOLS.md:776) and the handler reads args[2] — which for that form is the literal string "--phase". Measured on the pre-fix build against a real .planning/phases/01-stub/ directory: init plan-phase 01-stub -> phase_found=true init plan-phase --phase 01-stub -> phase_found=false The test asserted only exit 0 and key presence, so it had been green while proving nothing about phase resolution. Corrected to the documented form and strengthened to assert phase_found === true. Same class in state.test.cjs (`--plan-count`, a flag that does not exist; the real one is `--plans`), milestone-archive.test.cjs (`init new-milestone --json`, silently ignored), and concurrency-safety.test.cjs (a bare positional field name whose OR-assertion passed because a whole-document dump happens to contain the substring it looked for). Six handlers had no argv validation at all — the same #3358 shape this phase exists to close, found while fixing the rest: init verify-work / phase-op / review / todos / remove-workspace read args[2] with nothing checking the rest, and validate health read --repair/--backfill through a bare args.includes() scan that bypassed the parser entirely. All now go through the seam, so the flag has one owner. tests/init-debug.test.cjs rows C4/C5 asserted that an unrecognized flag must NOT fail. That is the behavior §8.4 removes, and Decision 8 says a caller's local expectation does not override §8, so they are inverted and renamed — a test still called "ignores an unrecognized flag" while asserting rejection would be its own defect. Row C6's point is its PWNED canary; that assertion is kept verbatim and only its exit-status expectation changed, because the hostile token is now rejected rather than absorbed. The blast-radius estimate in 40-design.md is corrected rather than quietly left wrong. get_impact reported MEDIUM / 8 symbols upstream, and that was accurate for what the graph can see — parseNamedArgs's callers. It cannot see that those callers' handlers accept argv shapes wider than the code reading args[2] suggests, which is where the real surface was. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#3884): withdraw the validate-health tightening, finish the A2/A3 revert Second full run: 46 failures, down from 90. Four causes, two of them mine. Reverted `validate health` entirely — it was scope creep, and it broke a real flag. ~30 of the 46 read `unknown flag "--json"; accepted: --repair, --backfill`. The previous commit routed `validate health` through the parser on the reasoning that a flag should have one owner. That was wrong twice over: §8.4 names parseNamedArgs and count queries, and `validate health` was never a parseNamedArgs call site — it read its flags, just not through the parser, so it had no silent-drop defect to fix. Tightening it omitted `--json`, which the health-diagnostic suites use heavily. The handler is now byte-for-behaviour back to its pre-branch form. `validate context` stays converted: it genuinely was a call site, and its `--json` is now declared rather than read by a second `args.includes` scan. The five handlers that had NO validation at all — init verify-work / phase-op / review / todos / remove-workspace — stay fixed. Those read args[2] with nothing checking the rest, which is the #3358 shape this phase owns. Finished the A2/A3 revert. Three tests still encoded the deleted "a value flag with a missing value is an error" rule, including one added by the previous commit for that rule. All three now assert the reverted null contract, and the ones whose titles said "rejected" are renamed — a test named for a contract it no longer asserts is its own defect. `--wave=` and `--wave --weird` are correctly rejected. Neither is documented in commands/gsd/execute-phase.md, gsd-core/workflows/execute-phase.md or docs/, and neither is emitted by the shipped prompt layer, so both are unrecognized tokens that §8.4 mandates rejecting. `doesNotConsumeFollowingFlagAsWaveValue` keeps the property it exists for — asserted directly now, at the parser, that `--wave` does not swallow a following flag as its value — and only its exit-status expectation changed. A contradiction inside this branch, surfaced by the audit and resolved the safe way. Two pre-existing #3573 tests call `state begin-phase '2'` and `state planned-phase '2'` with a bare positional, relying on the old permissive parser to ignore it. This branch's own #3358 regression test requires that exact argv to be REJECTED. The two are mutually exclusive. Widening the router to accept a bare positional — mirroring complete-phase — would have silently re-opened #3358, and was verified to do exactly that: with the widened router, `query state.planned-phase 3` returned exit 0 and wrote current_phase_name again. It is reverted. docs/CLI-TOOLS.md:116 and docs/COMMANDS.md:2192 document only the `--phase N` form for both verbs, so the two #3573 tests move to it. Their assertions were never about the call shape — only that total_phases survives the resync — and both still pass. complete-phase is untouched: its bare positional IS documented, and it keeps the dynamic boundary and the negative-space note that record why. The audit that produced this is in the PR body: for every handler whose declaration changed, the flags it reads anywhere in its body, the flags the shipped surface documents, and the shapes the suite passes, compared. The `--json` miss was a pattern, not an accident — declaring a handler's flags from its parseNamedArgs call alone misses whatever it reads elsewhere. Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#3884): backfill the changeset PR number Refs #3884 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1790 lines
80 KiB
TypeScript
1790 lines
80 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 crypto from 'node:crypto';
|
|
import { platformReadSync } from './shell-command-projection.cjs';
|
|
import { collectSection } from './markdown-sectionizer.cjs';
|
|
import { splitLines } from './text-lines.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import coreUtils = require('./core-utils.cjs');
|
|
const { normalizeLineEndings } = coreUtils;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import planningWorkspace = require('./planning-workspace.cjs');
|
|
const { planningDir, quickDirFrom } = planningWorkspace;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import frontmatter = require('./frontmatter.cjs');
|
|
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import phaseIdMod = require('./phase-id.cjs');
|
|
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import phaseLocator = require('./phase-locator.cjs');
|
|
const { getArchivedPhaseDirs } = phaseLocator;
|
|
import { requireSafePath, sanitizeForDisplay, sanitizeLabel } from './security.cjs';
|
|
import { platformWriteSync } from './shell-command-projection.cjs';
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
import io = require('./io.cjs');
|
|
const { output, error: ioError } = io;
|
|
import { parseNamedArgsOrExit } from './command-arg-projection.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;
|
|
archived_milestone?: string;
|
|
}
|
|
|
|
interface VerificationGapItem {
|
|
phase: string;
|
|
file: string;
|
|
status: string;
|
|
scan_error?: boolean;
|
|
archived_milestone?: string;
|
|
}
|
|
|
|
interface ContextQuestionItem {
|
|
phase: string;
|
|
file: string;
|
|
question_count: number;
|
|
questions: string[];
|
|
scan_error?: boolean;
|
|
archived_milestone?: string;
|
|
}
|
|
|
|
interface DeferredItem {
|
|
phase: string;
|
|
file: string;
|
|
text: string;
|
|
scan_error?: boolean;
|
|
archived_milestone?: string;
|
|
}
|
|
|
|
/**
|
|
* 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 }>;
|
|
/**
|
|
* #3458 follow-up: like `parseDeferredItems`, but returns EVERY entry
|
|
* (including `status: resolved` ones) together with its raw, unlowercased
|
|
* `status:` field value (`''` when absent) so a caller can distinguish
|
|
* "resolved" (fixed for real — never counted) from the new "acknowledged"
|
|
* (suppressed-but-tallied) from anything else (open). `parseDeferredItems`
|
|
* itself is defined in terms of this — see uat.cts — so the two can never
|
|
* drift on what an entry's text/boundaries are.
|
|
*/
|
|
parseDeferredItemsWithStatus(content: string): Array<{ name: string; status: string }>;
|
|
/**
|
|
* Writer half of the #3458 follow-up seam (A4): sets a matched deferred
|
|
* entry's `status:` field to `acknowledged` in place, verdict-preserving
|
|
* (never touches an entry already `status: resolved`) and scoped to the
|
|
* BULLET-only (headless) `## Deferred Items` shape — see the doc comment on
|
|
* the implementation in uat.cts for why the heading-delimited (#3457) shape
|
|
* is refused rather than attempted.
|
|
*/
|
|
acknowledgeDeferredItem(content: string, targetText: string): AcknowledgeDeferredItemResult;
|
|
}
|
|
|
|
/** Result of `UatDeferredModule.acknowledgeDeferredItem`. */
|
|
interface AcknowledgeDeferredItemResult {
|
|
content: string;
|
|
status: 'ok' | 'not_found' | 'ambiguous' | 'unsupported_heading_shape' | 'already_resolved' | 'match_verification_failed';
|
|
}
|
|
|
|
/**
|
|
* A scanner's items PLUS how many otherwise-open items it suppressed via a
|
|
* current `audit_acknowledged` marker (#3458 follow-up, A5). Every
|
|
* phase-scoped and flat scanner returns this shape now instead of a bare
|
|
* array, so `auditOpenArtifacts` can report both halves without a second
|
|
* scan pass.
|
|
*/
|
|
interface ScanOutcome<T> {
|
|
items: T[];
|
|
acknowledged: number;
|
|
}
|
|
|
|
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;
|
|
/**
|
|
* Per-category count of items SUPPRESSED by a current (non-stale)
|
|
* `audit_acknowledged` marker (#3458 follow-up, design point A5) — mirrors
|
|
* `counts`'s shape exactly. A suppressed item never appears in `counts` or
|
|
* `items`; this is the only place it is still observable, so a reviewer can
|
|
* tell "clean because fixed" apart from "clean because silenced".
|
|
*/
|
|
acknowledged: 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']);
|
|
|
|
// ─── Acknowledgment marker (suppression) ──────────────────────────────────────
|
|
//
|
|
// #3458 follow-up: `query audit-open` now scans archived milestone phase dirs,
|
|
// so an item still unresolved when a milestone closed resurfaces at EVERY
|
|
// later close, forever — `[A] Acknowledge all` documented that decision to
|
|
// STATE.md but never suppressed it. This section is the suppression seam.
|
|
//
|
|
// The marker lives INSIDE the artifact it suppresses, as an
|
|
// `audit_acknowledged` frontmatter map (no ledger, no id minting — see
|
|
// `uat.cts:891-897`'s `deferred-items.md` in-place `status: resolved`
|
|
// convention, which this generalizes):
|
|
//
|
|
// audit_acknowledged:
|
|
// milestone: v1.0 # which milestone close acknowledged it
|
|
// at: 2026-08-15 # ISO date
|
|
// status: gaps_found # snapshot of the artifact's state AT acknowledgment
|
|
// # (named `gap_snapshot` — status + open-scenario
|
|
// # count — for `uat_gaps`, and `questions_digest`
|
|
// # — a content hash of the question set, not just
|
|
// # its count — for `context_questions`; see
|
|
// # `isAuditItemAcknowledged`'s `snapshotKey` param
|
|
// # and each category's `deriveXxx` snapshot
|
|
// # helper for why a bare status/count was not
|
|
// # enough for those two — #3458 follow-up review)
|
|
//
|
|
// It is VERDICT-PRESERVING (this section never writes `status:` itself — see
|
|
// `cmdAuditAcknowledge` below) and SELF-INVALIDATING: it suppresses ONLY while
|
|
// `snapshotKey`'s recorded value still equals the artifact's CURRENT
|
|
// effective value. Edit the artifact after acknowledging it and the item
|
|
// resurfaces automatically — no separate revive/carry-forward state, and a
|
|
// stale acknowledgment can never hide a NEW problem, PROVIDED the category's
|
|
// snapshot actually captures the dimension that changed — `uat_gaps` and
|
|
// `context_questions` snapshot more than their status/count for exactly this
|
|
// reason (see above); every other category's only tracked dimension IS its
|
|
// `status:` (or, for `todos`, presence), so a bare status/presence snapshot
|
|
// is already complete for those.
|
|
//
|
|
// `isAuditItemAcknowledged` is the ONE shared predicate every scanner below
|
|
// routes through — this file has already been through the "hand-rolled the
|
|
// same check nine times" defect family twice this PR; a tenth hand-roll here
|
|
// is exactly that class. `deferred_items` is the deliberate exception: its
|
|
// suppression key lives PER-ENTRY inside `deferred-items.md`'s own
|
|
// `status:` field (see `uat.cts`'s `parseDeferredItemsWithStatus`), not in a
|
|
// file-level `audit_acknowledged` map, because a single deferred-items.md can
|
|
// carry many independently-acknowledgeable entries.
|
|
|
|
/**
|
|
* Parse and validate an artifact's `audit_acknowledged` frontmatter marker,
|
|
* then decide whether it suppresses the item given the artifact's CURRENT
|
|
* effective state.
|
|
*
|
|
* `snapshotKey` names which sub-field of the marker map carries the snapshot
|
|
* comparison value (`'status'` for every category except CONTEXT files, which
|
|
* use `'question_count'`). `presenceOnly: true` (used only for `todos`, which
|
|
* has no natural status field to snapshot) skips the snapshot comparison
|
|
* entirely — marker PRESENCE alone suppresses.
|
|
*
|
|
* A marker that is not a plain object/map, or is missing a non-empty string
|
|
* `milestone`/`at`, or — when a snapshot comparison applies — missing a
|
|
* string at `snapshotKey`, is MALFORMED and treated as ABSENT: this function
|
|
* returns `false` and the item surfaces. A bad marker must never suppress.
|
|
*/
|
|
function isAuditItemAcknowledged(
|
|
fm: Record<string, unknown>,
|
|
opts: { snapshotKey: string; currentValue: string; presenceOnly?: boolean },
|
|
): boolean {
|
|
const raw = fm.audit_acknowledged;
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return false;
|
|
const marker = raw as Record<string, unknown>;
|
|
if (typeof marker.milestone !== 'string' || !marker.milestone) return false;
|
|
if (typeof marker.at !== 'string' || !marker.at) return false;
|
|
if (opts.presenceOnly) return true;
|
|
const snapshot = marker[opts.snapshotKey];
|
|
if (typeof snapshot !== 'string') return false;
|
|
return snapshot === opts.currentValue;
|
|
}
|
|
|
|
/**
|
|
* Derive a THREAD file's effective status: frontmatter `status:` when
|
|
* present, else the `## Status: OPEN|IN PROGRESS` body fallback — the same
|
|
* two-step derivation `scanThreads` already performed inline. Extracted so
|
|
* `cmdAuditAcknowledge` computes the CURRENT snapshot value with the exact
|
|
* same logic the scanner used to produce the marker's recorded value,
|
|
* instead of a second hand-derivation that could silently drift from it.
|
|
*/
|
|
function deriveThreadStatus(fm: Record<string, unknown>, content: string): string {
|
|
let status = ((fm.status as string) || '').toLowerCase().trim();
|
|
if (!status) {
|
|
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
|
if (bodyStatusMatch) {
|
|
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
|
}
|
|
}
|
|
return status;
|
|
}
|
|
|
|
/**
|
|
* Count a UAT file's still-open (`result: pending`/`[pending]`) scenarios.
|
|
* Extracted from `scanUatGaps`'s inline logic for the same reason as
|
|
* `deriveThreadStatus` — one derivation, shared by the scanner and
|
|
* `cmdAuditAcknowledge`'s `deriveUatGapSnapshotValue` below.
|
|
*/
|
|
function deriveUatGapOpenScenarioCount(content: string): number {
|
|
return (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
|
|
}
|
|
|
|
/**
|
|
* Stable snapshot value for a `uat_gaps` item (WARNING 2, #3458 follow-up
|
|
* review). `status` alone is COUNT-blind the other direction: a UAT file can
|
|
* stay in the SAME open status (`gaps_found`) while gaining MORE pending
|
|
* scenarios (measured: 1→6 pending, status unchanged, item stayed
|
|
* suppressed under the old status-only scheme). Composing `status` with the
|
|
* open-scenario count means either dimension changing invalidates the
|
|
* snapshot.
|
|
*/
|
|
function deriveUatGapSnapshotValue(status: string, content: string): string {
|
|
return `${status}::scenarios=${deriveUatGapOpenScenarioCount(content)}`;
|
|
}
|
|
|
|
/**
|
|
* Derive a CONTEXT file's FULL, UNTRUNCATED open-questions list: the
|
|
* structured `open_questions` frontmatter array when present and
|
|
* non-empty, else EVERY qualifying line of the `## Open Questions` body
|
|
* section. Extracted from `scanContextQuestions`'s inline logic for the same
|
|
* reason as `deriveThreadStatus` — one derivation, shared by the scanner and
|
|
* `cmdAuditAcknowledge`, so the acknowledged `question_count`/digest snapshot
|
|
* can never diverge from what the scanner counts.
|
|
*
|
|
* F2 (#3458 follow-up review, sibling of the deferred_items span-carrying
|
|
* fix): this used to `slice(0, 3)` the body-section list AND clamp each
|
|
* question to 200 chars BEFORE returning — a value meant for DISPLAY reused
|
|
* for the IDENTITY snapshot `deriveOpenQuestionsDigest` hashes. A 4th+
|
|
* question, or anything past char 200 of an earlier one, was invisible to
|
|
* the digest: an attacker could ship 3 innocuous questions first, then add
|
|
* real blockers afterward with zero effect on the recorded snapshot. Every
|
|
* caller that wants a bounded list for DISPLAY (`scanContextQuestions`'s
|
|
* `questions` field) truncates its OWN copy at the call site; this function
|
|
* always returns the complete, unclamped set.
|
|
*/
|
|
function deriveOpenQuestions(content: string, fm: Record<string, unknown>): string[] {
|
|
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)));
|
|
}
|
|
}
|
|
|
|
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.map((q: string) => sanitizeForDisplay(q));
|
|
}
|
|
}
|
|
}
|
|
|
|
return questions;
|
|
}
|
|
|
|
/** Bound a question's DISPLAY text (never fed into the identity digest — see `deriveOpenQuestions`'s doc comment). */
|
|
function truncateQuestionForDisplay(question: string): string {
|
|
return question.slice(0, 200);
|
|
}
|
|
|
|
/**
|
|
* Stable normalized digest of a CONTEXT file's open-questions set (WARNING 2,
|
|
* #3458 follow-up review). `question_count` alone is COUNT-only: replacing
|
|
* every question's TEXT with brand-new ones while holding the count steady
|
|
* left an acknowledged item permanently suppressed (measured: 2 questions
|
|
* acknowledged, then both replaced with unrelated new blockers — still
|
|
* `counts:0`). Hashing the full, ordered question text means ANY edit —
|
|
* add, remove, reword, or reorder — changes the digest and the item
|
|
* resurfaces. sha256 (not the raw joined string) keeps the marker's stored
|
|
* value bounded regardless of question length/count.
|
|
*
|
|
* `questions` MUST be the untruncated, unclamped set `deriveOpenQuestions`
|
|
* returns — never a display-sliced/-clamped copy (F2, #3458 follow-up
|
|
* review); a truncated input reintroduces exactly the blind spot this digest
|
|
* exists to close.
|
|
*
|
|
* Length-prefixed, separator-free encoding (SWEEP finding, #3458 follow-up
|
|
* review) — NOT a plain join (the prior revision joined on a literal
|
|
* embedded NUL byte, `questions.join('\\0')` written as a raw control
|
|
* character in the SOURCE FILE itself — invisible in a normal diff/editor
|
|
* and still forgeable: attacker-controlled markdown CAN contain a literal
|
|
* NUL codepoint, since the file is read as UTF-8 text, so that scheme never
|
|
* actually closed the boundary-collision gap it was reaching for). A bare
|
|
* separator-joined string has no reliably unambiguous element boundary: two
|
|
* DIFFERENT question arrays can render the identical joined string and
|
|
* collide on the same digest — e.g. `['- Is X ready?', '- Y done?']` and
|
|
* `['- Is X ready? - Y', 'done?']` both join to
|
|
* `'- Is X ready? - Y done?'` under a space-join, and both could be forced to
|
|
* collide under a NUL-join too by an attacker who embeds the separator
|
|
* itself. Prefixing each element with its own CHARACTER LENGTH
|
|
* (`<len>:<text>`, concatenated with no separator at all) makes the encoding
|
|
* self-delimiting instead: decoding always consumes exactly `<len>`
|
|
* characters after each `:` before reading the next length prefix, so no two
|
|
* distinct arrays can ever encode to the same string — regardless of what
|
|
* characters the questions themselves contain.
|
|
*/
|
|
function deriveOpenQuestionsDigest(questions: string[]): string {
|
|
const encoded = questions.map((q) => `${q.length}:${q}`).join('');
|
|
return crypto.createHash('sha256').update(encoded).digest('hex');
|
|
}
|
|
|
|
// ─── scanDebugSessions ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/debug/ for open sessions.
|
|
* Open = status NOT in ['resolved', 'complete'].
|
|
* Ignores the resolved/ subdirectory.
|
|
*/
|
|
function scanDebugSessions(planDir: string): ScanOutcome<DebugSessionItem> {
|
|
const debugDir = path.join(planDir, 'debug');
|
|
if (!fs.existsSync(debugDir)) return { items: [], acknowledged: 0 };
|
|
|
|
const results: DebugSessionItem[] = [];
|
|
let acknowledged = 0;
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(debugDir, { withFileTypes: true });
|
|
} catch {
|
|
return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
if (status === 'resolved' || status === 'complete') continue;
|
|
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
acknowledged++;
|
|
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: sanitizeLabel(slug),
|
|
status: sanitizeForDisplay(status),
|
|
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
|
hypothesis,
|
|
});
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── resolveQuickTaskSummaryFile ───────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve a quick task's SUMMARY file, if any exists, under its own
|
|
* directory (`taskDir`). workflows/quick.md mandates `${quick_id}-SUMMARY.md`;
|
|
* older flows used bare `SUMMARY.md` — accept either to avoid a
|
|
* false-positive "missing", preferring the per-task `${dirName}-SUMMARY.md`
|
|
* form when more than one candidate exists.
|
|
*
|
|
* #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.
|
|
*
|
|
* Extracted (#3458 follow-up) so `scanQuickTasks` (read) and
|
|
* `cmdAuditAcknowledge`'s quick_tasks writer share the ONE discovery rule —
|
|
* previously the writer would have had to hand-roll this exact filter a
|
|
* second time, which is exactly the re-derivation-drift class
|
|
* `scripts/lint-plan-count-drift.cjs` exists to catch (see its
|
|
* `FUNCTION_SCOPED_EXEMPTIONS` entry for this function).
|
|
*
|
|
* Returns `null` (never throws) on an unreadable `taskDir` or when no
|
|
* SUMMARY-shaped file exists.
|
|
*/
|
|
function resolveQuickTaskSummaryFile(taskDir: string, dirName: string): string | null {
|
|
let summaryFiles: fs.Dirent[];
|
|
try {
|
|
summaryFiles = fs.readdirSync(taskDir, { withFileTypes: true })
|
|
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (summaryFiles.length === 0) return null;
|
|
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
|
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
|
|| summaryFiles[0];
|
|
return path.join(taskDir, preferred.name);
|
|
}
|
|
|
|
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/quick/ for incomplete tasks.
|
|
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
|
*/
|
|
function scanQuickTasks(planDir: string): ScanOutcome<QuickTaskItem> {
|
|
// #2142: routed through the shared quickDirFrom composer (planning-workspace.cts)
|
|
// so `.planning/quick` has exactly ONE owner instead of two ad-hoc path.joins.
|
|
const quickDir = quickDirFrom(planDir);
|
|
if (!fs.existsSync(quickDir)) return { items: [], acknowledged: 0 };
|
|
|
|
let entries: fs.Dirent[];
|
|
try {
|
|
entries = fs.readdirSync(quickDir, { withFileTypes: true });
|
|
} catch {
|
|
return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
|
|
}
|
|
|
|
const results: QuickTaskItem[] = [];
|
|
let acknowledged = 0;
|
|
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;
|
|
}
|
|
|
|
const summaryPath = resolveQuickTaskSummaryFile(safeTaskDir, dirName);
|
|
|
|
let status = 'missing';
|
|
const description = '';
|
|
let fm: Record<string, unknown> | null = null;
|
|
|
|
if (summaryPath && fs.existsSync(summaryPath)) {
|
|
let safeSum: string;
|
|
try {
|
|
safeSum = requireSafePath(summaryPath, planDir, 'quick task summary', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
// #3078-CR MEDIUM 2: same normalize-at-read-boundary fix as the other
|
|
// scans in this module — see the comment above `scanDebugSessions`'s
|
|
// read.
|
|
const rawContent = platformReadSync(safeSum);
|
|
if (rawContent === null) {
|
|
status = 'unreadable';
|
|
} else {
|
|
const content = normalizeLineEndings(rawContent);
|
|
fm = extractFrontmatter(content, safeSum);
|
|
status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
}
|
|
}
|
|
|
|
if (status === 'complete') continue;
|
|
|
|
// Acknowledgment marker only ever lives in the SUMMARY file's own
|
|
// frontmatter — a task with no summary (status: 'missing') has nowhere to
|
|
// carry one, so `fm` is null and this is skipped (never suppressed).
|
|
if (fm && isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
|
|
let date = '';
|
|
let slug = sanitizeLabel(dirName);
|
|
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
|
|
if (dateMatch) {
|
|
// dateMatch[1] is regex-constrained to `\d{4}-?\d{2}-?\d{2}` (digits and
|
|
// literal hyphens only — the same "constrained at the source" shape as
|
|
// `archived_milestone`), so it cannot itself carry a control byte.
|
|
// Still routed through sanitizeLabel as defense-in-depth for
|
|
// consistency with every other directory-name-derived field here.
|
|
date = sanitizeLabel(dateMatch[1]);
|
|
slug = sanitizeLabel(dateMatch[2]);
|
|
}
|
|
|
|
results.push({
|
|
slug,
|
|
date,
|
|
status: sanitizeForDisplay(status),
|
|
description,
|
|
});
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/threads/ for open threads.
|
|
* Open if status in ['open', 'in_progress', 'in progress'] (case-insensitive).
|
|
*/
|
|
function scanThreads(planDir: string): ScanOutcome<ThreadItem> {
|
|
const threadsDir = path.join(planDir, 'threads');
|
|
if (!fs.existsSync(threadsDir)) return { items: [], acknowledged: 0 };
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(threadsDir, { withFileTypes: true });
|
|
} catch {
|
|
return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
|
|
}
|
|
|
|
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
|
const results: ThreadItem[] = [];
|
|
let acknowledged = 0;
|
|
|
|
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;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = deriveThreadStatus(fm, content);
|
|
|
|
if (!openStatuses.has(status)) continue;
|
|
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
acknowledged++;
|
|
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: sanitizeLabel(slug),
|
|
status: sanitizeForDisplay(status),
|
|
updated: sanitizeForDisplay(fm.updated || fm.date || ''),
|
|
title,
|
|
});
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── 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): ScanOutcome<TodoItem> {
|
|
const pendingDir = path.join(planDir, 'todos', 'pending');
|
|
if (!fs.existsSync(pendingDir)) return { items: [], acknowledged: 0 };
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(pendingDir, { withFileTypes: true });
|
|
} catch {
|
|
return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
|
|
}
|
|
|
|
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
const results: TodoItem[] = [];
|
|
let acknowledged = 0;
|
|
|
|
// BLOCKER 2 (#3458 follow-up review): filter acknowledged items BEFORE
|
|
// the display cap. Capping the RAW file list to 5 first meant an
|
|
// acknowledge of one of those 5 files simply revealed the 6th on the next
|
|
// scan — files 6/7/... (never shown, never acknowledgeable via the CLI's
|
|
// own remedy) permanently vanished from every later scan once 5+ items
|
|
// existed, because `mdFiles.length` (not the post-filter open count) drove
|
|
// both the cap and the remainder count. Read every file's acknowledgment
|
|
// state first, THEN cap the OPEN (unacknowledged) set for display.
|
|
const openFiles: { entry: fs.Dirent; content: string; fm: Record<string, unknown> }[] = [];
|
|
for (const entry of mdFiles) {
|
|
const filePath = path.join(pendingDir, entry.name);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'todo file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
|
|
// Todos carry no natural status field — presence in pending/ IS "open" by
|
|
// definition (a resolved todo is moved out, not status-flagged). So the
|
|
// acknowledgment check here is PRESENCE-ONLY: no snapshot to go stale, no
|
|
// self-invalidation on edit — see `isAuditItemAcknowledged`'s doc comment.
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: '', presenceOnly: true })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
openFiles.push({ entry, content, fm });
|
|
}
|
|
|
|
const displayFiles = openFiles.slice(0, 5);
|
|
for (const { entry, content, fm } of displayFiles) {
|
|
// Extract first line of body after frontmatter
|
|
const bodyMatch = content.replace(/^---[\s\S]*?---\r?\n?/, '');
|
|
const firstLine = splitLines(bodyMatch.trim())[0] || '';
|
|
const summary = sanitizeForDisplay(firstLine.slice(0, 100));
|
|
|
|
results.push({
|
|
filename: sanitizeLabel(entry.name),
|
|
priority: sanitizeForDisplay(fm.priority || ''),
|
|
area: sanitizeForDisplay(fm.area || ''),
|
|
summary,
|
|
});
|
|
}
|
|
|
|
if (openFiles.length > 5) {
|
|
results.push({ _remainder_count: openFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/seeds/SEED-*.md for unimplemented seeds.
|
|
* Unimplemented if status in ['dormant', 'active', 'triggered'].
|
|
*/
|
|
function scanSeeds(planDir: string): ScanOutcome<SeedItem> {
|
|
const seedsDir = path.join(planDir, 'seeds');
|
|
if (!fs.existsSync(seedsDir)) return { items: [], acknowledged: 0 };
|
|
|
|
let files: fs.Dirent[];
|
|
try {
|
|
files = fs.readdirSync(seedsDir, { withFileTypes: true });
|
|
} catch {
|
|
return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
|
|
}
|
|
|
|
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
|
const results: SeedItem[] = [];
|
|
let acknowledged = 0;
|
|
|
|
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;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'dormant').toLowerCase();
|
|
|
|
if (!unimplementedStatuses.has(status)) continue;
|
|
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
// Extract seed_id from filename or frontmatter. The regex match is
|
|
// `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
|
|
// but the fallback taken when a filename doesn't fully match — e.g. a
|
|
// `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
|
|
// the middle, which still passes the `startsWith`/`endsWith` filter above
|
|
// — is the raw, unconstrained basename. Both branches are routed through
|
|
// sanitizeLabel below.
|
|
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
|
const seed_id = seedIdMatch ? seedIdMatch[1] : path.basename(entry.name, '.md');
|
|
const slug = sanitizeLabel(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: sanitizeLabel(seed_id),
|
|
slug,
|
|
status: sanitizeForDisplay(status),
|
|
title,
|
|
});
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── listAuditPhaseTargets ────────────────────────────────────────────────────
|
|
|
|
interface AuditPhaseTarget {
|
|
dir: string;
|
|
fullPath: string;
|
|
milestone?: string;
|
|
}
|
|
|
|
/**
|
|
* Enumerate phase directories across BOTH the active `.planning/phases/` root
|
|
* and every archived `.planning/milestones/vX.Y-phases/` root. Shared by the
|
|
* four phase-scoped scanners below (#3458 — epic #3473 B2/F2). Previously each
|
|
* scanner hand-rolled its own active-only `readdirSync(phasesDir)` walk and
|
|
* bailed out entirely when the active root was missing, so items still
|
|
* unresolved when a milestone closed and its phase dirs archived became
|
|
* permanently invisible to every later audit.
|
|
*
|
|
* ACTIVE dirs: raw readdirSync + isDirectory filter + sort. The enumeration
|
|
* walk itself (readdirSync + isDirectory filter + sort) is UNCHANGED from the
|
|
* scanners' prior inline behavior; what IS new is that a failed read here no
|
|
* longer aborts the whole scan the way each scanner's own inline
|
|
* `if (!fs.existsSync) return []` / try-readdirSync-catch-return-sentinel
|
|
* pair used to — see `activeUnreadable` below, which is how that signal is
|
|
* now surfaced to callers instead. Deliberately NOT routed through
|
|
* listMilestonePhaseDirs: these scanners are deliberately not
|
|
* milestone-filtered today, and switching would silently add window/sentinel
|
|
* filtering — a behavior change belonging to #3372, not here.
|
|
*
|
|
* A missing/unreadable active root does NOT short-circuit the archive walk —
|
|
* the old `if (!fs.existsSync(phasesDir)) return []` was the whole bug in a
|
|
* fully-archived project; it degrades to "skip the active half" only. An
|
|
* UNREADABLE (as opposed to merely absent) active root is reported back via
|
|
* `activeUnreadable: true` so each of the four callers can still emit the
|
|
* `scan_error` sentinel they emitted pre-#3458 for this exact case (a real
|
|
* I/O failure, not "verified clean") — see each scanner's own use of it.
|
|
*
|
|
* ARCHIVED dirs: sourced from `getArchivedPhaseDirs` (phase-locator.cjs), the
|
|
* canonical archive-enumeration seam `uat.cts`'s `cmdAuditUat` already uses.
|
|
* Archived dirs are deliberately NOT milestone-filtered either — see the
|
|
* comment at src/uat.cts:107-111: listMilestonePhaseDirs derives the CURRENT
|
|
* milestone's phase dirs (window + sentinel filtered) from ROADMAP.md, and
|
|
* archived phases belong to past milestones by definition, so filtering them
|
|
* discards every one and silently reinstates the bug this function fixes.
|
|
*
|
|
* An unreadable/unresolvable ARCHIVE root does NOT get its own sentinel.
|
|
* Pre-#3458 there was no archived read at all, so — unlike the active root —
|
|
* there is no prior `scan_error` contract to preserve here, and no existing
|
|
* consumer can regress. It also degrades the same way `listArchiveVersionDirs`
|
|
* (phase-locator.cts) already treats an absent `milestones/` dir: a real
|
|
* empty, not a failure, matching this function's existing "skip that root"
|
|
* idiom for the missing-active-dir case above. Adding a second sentinel path
|
|
* would let a machine consumer conflate "no milestones archived yet" (the
|
|
* overwhelmingly common case for an active project) with an actual read
|
|
* failure, which is a worse signal-to-noise trade than the one this
|
|
* function's own fix removes for the active root.
|
|
*
|
|
* Same-named dirs in both roots (e.g. "01-alpha" active AND archived) are
|
|
* DISTINCT targets — no dedupe.
|
|
*/
|
|
function listAuditPhaseTargets(planDir: string, cwd: string): { targets: AuditPhaseTarget[]; activeUnreadable: boolean } {
|
|
const targets: AuditPhaseTarget[] = [];
|
|
let activeUnreadable = false;
|
|
|
|
const phasesDir = path.join(planDir, 'phases');
|
|
if (fs.existsSync(phasesDir)) {
|
|
try {
|
|
const dirs = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
.filter(e => e.isDirectory())
|
|
.map(e => e.name)
|
|
.sort();
|
|
for (const dir of dirs) {
|
|
targets.push({ dir, fullPath: path.join(phasesDir, dir) });
|
|
}
|
|
} catch {
|
|
// Unreadable active root: skip it, do not abort the archive walk, but
|
|
// report it so callers can emit their pre-#3458 scan_error sentinel.
|
|
activeUnreadable = true;
|
|
}
|
|
}
|
|
|
|
try {
|
|
for (const archived of getArchivedPhaseDirs(cwd)) {
|
|
targets.push({ dir: archived.name, fullPath: archived.fullPath, milestone: archived.milestone });
|
|
}
|
|
} catch {
|
|
// Unreadable/unresolvable archive root: skip it, keep whatever active
|
|
// targets were already collected. No sentinel — see docstring above.
|
|
}
|
|
|
|
return { targets, activeUnreadable };
|
|
}
|
|
|
|
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
* for UAT gaps (UAT files with status != 'complete'/'resolved').
|
|
*/
|
|
function scanUatGaps(planDir: string, cwd: string): ScanOutcome<UatGapItem> {
|
|
const results: UatGapItem[] = [];
|
|
let acknowledged = 0;
|
|
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
if (activeUnreadable) {
|
|
results.push({ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 });
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(target.fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
|
|
// ad-hoc UAT file cannot surface as this phase's gap — same fix as
|
|
// scanVerificationGaps below.
|
|
for (const file of scopeToPhase(
|
|
files.filter(f => f.includes('-UAT') && f.endsWith('.md')),
|
|
target.dir,
|
|
)) {
|
|
const filePath = path.join(target.fullPath, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
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 — computed BEFORE the acknowledged check
|
|
// (WARNING 2) so the snapshot comparison sees it too, not just status.
|
|
const pendingMatches = deriveUatGapOpenScenarioCount(content);
|
|
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
const item: UatGapItem = {
|
|
phase: sanitizeLabel(phaseNum),
|
|
file: sanitizeLabel(file),
|
|
status: sanitizeForDisplay(status),
|
|
open_scenario_count: pendingMatches,
|
|
};
|
|
if (target.milestone !== undefined) item.archived_milestone = sanitizeLabel(target.milestone);
|
|
results.push(item);
|
|
}
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
* for VERIFICATION gaps.
|
|
*/
|
|
function scanVerificationGaps(planDir: string, cwd: string): ScanOutcome<VerificationGapItem> {
|
|
const results: VerificationGapItem[] = [];
|
|
let acknowledged = 0;
|
|
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
if (activeUnreadable) {
|
|
results.push({ scan_error: true, phase: '', file: '', status: '' });
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(target.fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
|
|
// ad-hoc VERIFICATION file cannot surface as this phase's gap.
|
|
for (const file of scopeToPhase(
|
|
files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')),
|
|
target.dir,
|
|
)) {
|
|
const filePath = path.join(target.fullPath, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const status = ((fm.status as string) || 'unknown').toLowerCase();
|
|
|
|
if (status !== 'gaps_found' && status !== 'human_needed') continue;
|
|
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
const item: VerificationGapItem = {
|
|
phase: sanitizeLabel(phaseNum),
|
|
file: sanitizeLabel(file),
|
|
status: sanitizeForDisplay(status),
|
|
};
|
|
if (target.milestone !== undefined) item.archived_milestone = sanitizeLabel(target.milestone);
|
|
results.push(item);
|
|
}
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
* for CONTEXT files with open_questions.
|
|
*/
|
|
function scanContextQuestions(planDir: string, cwd: string): ScanOutcome<ContextQuestionItem> {
|
|
const results: ContextQuestionItem[] = [];
|
|
let acknowledged = 0;
|
|
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
if (activeUnreadable) {
|
|
results.push({ scan_error: true, phase: '', file: '', question_count: 0, questions: [] });
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
|
|
let files: string[];
|
|
try {
|
|
files = fs.readdirSync(target.fullPath);
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
|
const filePath = path.join(target.fullPath, file);
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2 (security review follow-up): normalize a lone-CR
|
|
// document at this read boundary, same seam as `src/uat.cts`'s
|
|
// `readNormalizedDocument` — `platformReadSync` performs no line-ending
|
|
// normalization itself, and extractFrontmatter/status-derivation below
|
|
// degrade a lone-CR file's frontmatter to `unknown`, which every scan
|
|
// in this module treats as "not open" (fail-open, the permissive
|
|
// direction) rather than a real parse gap.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
const questions = deriveOpenQuestions(content, fm);
|
|
|
|
if (questions.length === 0) continue;
|
|
|
|
// WARNING 2 (#3458 follow-up review): snapshot the QUESTIONS
|
|
// THEMSELVES (a content digest), not just their count — a count-only
|
|
// snapshot cannot see the same-count REPLACEMENT of every question
|
|
// with brand-new ones (measured: 2 acknowledged, then both swapped for
|
|
// unrelated new blockers, still suppressed under the old scheme).
|
|
if (isAuditItemAcknowledged(fm, { snapshotKey: 'questions_digest', currentValue: deriveOpenQuestionsDigest(questions) })) {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
const item: ContextQuestionItem = {
|
|
phase: sanitizeLabel(phaseNum),
|
|
file: sanitizeLabel(file),
|
|
question_count: questions.length,
|
|
questions: questions.slice(0, 3).map(truncateQuestionForDisplay),
|
|
};
|
|
if (target.milestone !== undefined) item.archived_milestone = sanitizeLabel(target.milestone);
|
|
results.push(item);
|
|
}
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── scanDeferredItems ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646),
|
|
* across both .planning/phases (active) and .planning/milestones/vX.Y-phases
|
|
* (archived).
|
|
*
|
|
* 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 `parseDeferredItemsWithStatus`, which owns the parsing rule
|
|
* (entries under a `## Deferred Items` level-2 heading, else the whole file
|
|
* fail-safe) and — unlike `parseDeferredItems` — surfaces each entry's raw
|
|
* `status:` field instead of filtering `resolved` internally, so THIS scanner
|
|
* can apply the three-way split (#3458 follow-up): `resolved` (fixed for
|
|
* real — dropped, never counted, matching pre-existing behavior exactly),
|
|
* `acknowledged` (suppressed AND tallied — the new deferred_items marker;
|
|
* see the module doc comment above `isAuditItemAcknowledged`), else open.
|
|
* Duplicating either 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, cwd: string): ScanOutcome<DeferredItem> {
|
|
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const uat: UatDeferredModule = require('./uat.cjs');
|
|
|
|
const results: DeferredItem[] = [];
|
|
let acknowledged = 0;
|
|
if (activeUnreadable) {
|
|
results.push({ scan_error: true, phase: '', file: '', text: '' });
|
|
}
|
|
|
|
for (const target of targets) {
|
|
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
|
|
const filePath = path.join(target.fullPath, DEFERRED_ITEMS_FILENAME);
|
|
if (!fs.existsSync(filePath)) continue;
|
|
|
|
let safeFilePath: string;
|
|
try {
|
|
safeFilePath = requireSafePath(filePath, planDir, 'deferred items file', { allowAbsolute: true });
|
|
} catch {
|
|
continue;
|
|
}
|
|
|
|
// #3078-CR MEDIUM 2: normalize at this read boundary too —
|
|
// `parseDeferredItemsWithStatus` performs no normalization of its own
|
|
// (unlike `src/uat.cts`'s callers, which route through
|
|
// `readNormalizedDocument`), so a lone-CR `deferred-items.md` was read as
|
|
// one unbroken line and every entry in it silently vanished.
|
|
const rawContent = platformReadSync(safeFilePath);
|
|
if (rawContent === null) continue;
|
|
const content = normalizeLineEndings(rawContent);
|
|
|
|
for (const item of uat.parseDeferredItemsWithStatus(content)) {
|
|
const rawStatus = (item.status || '').toLowerCase();
|
|
if (rawStatus === 'resolved') continue; // fixed for real — never counted
|
|
if (rawStatus === 'acknowledged') {
|
|
acknowledged++;
|
|
continue;
|
|
}
|
|
|
|
const resultItem: DeferredItem = {
|
|
phase: sanitizeLabel(phaseNum),
|
|
file: sanitizeLabel(DEFERRED_ITEMS_FILENAME),
|
|
text: sanitizeForDisplay(item.name),
|
|
};
|
|
if (target.milestone !== undefined) resultItem.archived_milestone = sanitizeLabel(target.milestone);
|
|
results.push(resultItem);
|
|
}
|
|
}
|
|
|
|
return { items: results, acknowledged };
|
|
}
|
|
|
|
// ─── 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 { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const quickTasks = (() => {
|
|
try { return scanQuickTasks(planDir); } catch { return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const threads = (() => {
|
|
try { return scanThreads(planDir); } catch { return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const todos = (() => {
|
|
try { return scanTodos(planDir); } catch { return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const seeds = (() => {
|
|
try { return scanSeeds(planDir); } catch { return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const uatGaps = (() => {
|
|
try { return scanUatGaps(planDir, cwd); } catch { return { items: [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const verificationGaps = (() => {
|
|
try { return scanVerificationGaps(planDir, cwd); } catch { return { items: [{ scan_error: true, phase: '', file: '', status: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const contextQuestions = (() => {
|
|
try { return scanContextQuestions(planDir, cwd); } catch { return { items: [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
const deferredItems = (() => {
|
|
try { return scanDeferredItems(planDir, cwd); } catch { return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 }; }
|
|
})();
|
|
|
|
// 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.items),
|
|
quick_tasks: countReal(quickTasks.items),
|
|
threads: countReal(threads.items),
|
|
todos: countReal(todos.items),
|
|
seeds: countReal(seeds.items),
|
|
uat_gaps: countReal(uatGaps.items),
|
|
verification_gaps: countReal(verificationGaps.items),
|
|
context_questions: countReal(contextQuestions.items),
|
|
deferred_items: countReal(deferredItems.items),
|
|
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;
|
|
|
|
// #3458 follow-up (A5): mirrors `counts`'s shape exactly, so a reviewer can
|
|
// tell "clean because fixed" apart from "clean because silenced" without a
|
|
// second output contract to learn.
|
|
const acknowledged: AuditCounts = {
|
|
debug_sessions: debugSessions.acknowledged,
|
|
quick_tasks: quickTasks.acknowledged,
|
|
threads: threads.acknowledged,
|
|
todos: todos.acknowledged,
|
|
seeds: seeds.acknowledged,
|
|
uat_gaps: uatGaps.acknowledged,
|
|
verification_gaps: verificationGaps.acknowledged,
|
|
context_questions: contextQuestions.acknowledged,
|
|
deferred_items: deferredItems.acknowledged,
|
|
total: 0,
|
|
};
|
|
acknowledged.total = acknowledged.debug_sessions + acknowledged.quick_tasks + acknowledged.threads + acknowledged.todos + acknowledged.seeds + acknowledged.uat_gaps + acknowledged.verification_gaps + acknowledged.context_questions + acknowledged.deferred_items;
|
|
|
|
return {
|
|
scanned_at: new Date().toISOString(),
|
|
has_open_items: counts.total > 0,
|
|
counts,
|
|
acknowledged,
|
|
items: {
|
|
debug_sessions: debugSessions.items,
|
|
quick_tasks: quickTasks.items,
|
|
threads: threads.items,
|
|
todos: todos.items,
|
|
seeds: seeds.items,
|
|
uat_gaps: uatGaps.items,
|
|
verification_gaps: verificationGaps.items,
|
|
context_questions: contextQuestions.items,
|
|
deferred_items: deferredItems.items,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ─── 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, acknowledged } = auditResult;
|
|
const lines: string[] = [];
|
|
|
|
lines.push('### Milestone Close: Open Artifact Audit');
|
|
|
|
// WARNING 3 (#3458 follow-up review): the acknowledged tally previously
|
|
// existed only in `--json` output — the human report could not tell
|
|
// "clean because fixed" apart from "clean because silenced", which is the
|
|
// exact distinction the acknowledged/counts split exists to preserve.
|
|
if (!has_open_items) {
|
|
lines.push('');
|
|
if (acknowledged.total > 0) {
|
|
lines.push(`All artifact types clear (${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} still suppressed).`);
|
|
} else {
|
|
lines.push('All artifact types clear. Safe to proceed.');
|
|
}
|
|
lines.push('');
|
|
lines.push('---');
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// WARNING 3: per-category "N previously acknowledged" suffix, so the
|
|
// human report carries the same "clean vs silenced" signal `--json`
|
|
// already did via `acknowledged`.
|
|
const ackSuffix = (n: number): string => (n > 0 ? `, ${n} previously acknowledged` : '');
|
|
|
|
// Debug sessions (blocking quality — red)
|
|
if (counts.debug_sessions > 0) {
|
|
lines.push('');
|
|
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open${ackSuffix(acknowledged.debug_sessions)})`);
|
|
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${ackSuffix(acknowledged.uat_gaps)})`);
|
|
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
|
|
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
lines.push(` • Phase ${item.phase}${archived}: ${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${ackSuffix(acknowledged.verification_gaps)})`);
|
|
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
|
|
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}]`);
|
|
}
|
|
}
|
|
|
|
// Quick tasks (incomplete work — yellow)
|
|
if (counts.quick_tasks > 0) {
|
|
lines.push('');
|
|
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete${ackSuffix(acknowledged.quick_tasks)})`);
|
|
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${ackSuffix(acknowledged.todos)})`);
|
|
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${ackSuffix(acknowledged.threads)})`);
|
|
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${ackSuffix(acknowledged.seeds)})`);
|
|
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${ackSuffix(acknowledged.context_questions)})`);
|
|
for (const item of items.context_questions.filter(i => !i.scan_error)) {
|
|
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
lines.push(` • Phase ${item.phase}${archived}: ${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${ackSuffix(acknowledged.deferred_items)})`);
|
|
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
|
|
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
lines.push(` • Phase ${item.phase}${archived}: ${item.text}`);
|
|
}
|
|
}
|
|
|
|
lines.push('');
|
|
lines.push('---');
|
|
lines.push('');
|
|
lines.push(`**${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.**`);
|
|
if (acknowledged.total > 0) {
|
|
lines.push(`${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} also suppressed above the ${counts.total} open item${counts.total !== 1 ? 's' : ''}.`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// ─── resolvePhaseTargetDir ─────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Resolve ONE phase directory (active or archived) by its phase token, for
|
|
* `cmdAuditAcknowledge`'s `--phase [--archived-milestone]` identification of
|
|
* a uat_gaps/verification_gaps/context_questions/deferred_items item. Built
|
|
* on `listAuditPhaseTargets` — the same enumeration the four phase-scoped
|
|
* scanners use — so the writer can never resolve a DIFFERENT directory than
|
|
* the one the audit actually scanned.
|
|
*
|
|
* `archivedMilestone` absent → matches the ACTIVE `.planning/phases/<dir>`
|
|
* (a target with no `milestone`). Present → matches the archived target
|
|
* whose `milestone` equals it exactly — the same disambiguator the audit
|
|
* output's `archived_milestone` field carries.
|
|
*/
|
|
function resolvePhaseTargetDir(planDir: string, cwd: string, phase: string, archivedMilestone: string | null): string | null {
|
|
const { targets } = listAuditPhaseTargets(planDir, cwd);
|
|
const phaseTokenRe = new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i');
|
|
for (const target of targets) {
|
|
const phaseMatch = target.dir.match(phaseTokenRe);
|
|
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
if (phaseNum !== phase) continue;
|
|
if (archivedMilestone) {
|
|
if (target.milestone === archivedMilestone) return target.fullPath;
|
|
} else if (target.milestone === undefined) {
|
|
return target.fullPath;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// ─── cmdAuditAcknowledge ────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* CLI writer for the #3458 follow-up suppression seam (design point A4). Sets
|
|
* (or refreshes) the `audit_acknowledged` marker on ONE identified artifact,
|
|
* snapshotting its CURRENT effective state itself so the marker is never
|
|
* hand-authored and can never drift from what the scanners actually compute.
|
|
*
|
|
* `--category` selects which of the nine audit categories is being
|
|
* acknowledged, and which OTHER flags are required to identify the artifact —
|
|
* mirroring the fields the audit's OWN JSON output already carries per
|
|
* category (phase/file/archived_milestone for the four phase-scoped
|
|
* categories; slug/seed-id/dir/filename for the five flat ones), the same
|
|
* convention `frontmatter get/set/merge/validate` uses for `--file`/`--field`.
|
|
*
|
|
* VERDICT-PRESERVING: this function never writes to the artifact's own
|
|
* `status:` field (the audit's real verdict) for the 8 frontmatter-marker
|
|
* categories — only the sibling `audit_acknowledged` map. `deferred_items` is
|
|
* the sole, deliberate exception (see `uat.cts`'s `acknowledgeDeferredItem`):
|
|
* there, the marker IS the entry's own `status:` field, because a
|
|
* deferred-items.md entry carries no OTHER meaning for that field.
|
|
*
|
|
* Every path this function writes is routed through `requireSafePath`, so an
|
|
* artifact identifier that resolves outside the project is refused before
|
|
* any read or write is attempted.
|
|
*/
|
|
function cmdAuditAcknowledge(cwd: string, args: string[], raw: boolean): void {
|
|
// args already has the family + subcommand tokens stripped by the caller
|
|
// (audit-command-router.cts:147 passes `hubArgs.slice(2)`), so validation
|
|
// begins at index 0 — there is no positional this handler owns itself.
|
|
const {
|
|
category, milestone, at: atFlag,
|
|
phase, file, 'archived-milestone': archivedMilestone,
|
|
slug, 'seed-id': seedId, dir: quickDir, filename, text,
|
|
} = parseNamedArgsOrExit(args, {
|
|
valueFlags: [
|
|
'category', 'milestone', 'at',
|
|
'phase', 'file', 'archived-milestone',
|
|
'slug', 'seed-id', 'dir', 'filename', 'text',
|
|
],
|
|
positionals: 0,
|
|
}, ioError);
|
|
|
|
if (!category) ioError('--category is required');
|
|
if (!milestone) ioError('--milestone is required');
|
|
// All declared flags above are value flags, so each resolves to `string |
|
|
// null` at runtime; the cast narrows away the `boolean` arm of
|
|
// ParsedNamedArgs's value type that this call site never produces.
|
|
const at = (atFlag as string | null) || new Date().toISOString().slice(0, 10);
|
|
|
|
const planDir = planningDir(cwd);
|
|
const markerBase = { milestone: milestone as string, at };
|
|
|
|
// #3078-CR MEDIUM 2: every `fs.readFileSync` in this function (below, and
|
|
// in the flat-category branch further down) is DELIBERATELY left raw,
|
|
// unlike `auditOpenArtifacts`'s scan reads (which now route through
|
|
// `normalizeLineEndings`). This function splices frontmatter into the
|
|
// EXISTING content and writes the result back via `platformWriteSync` /
|
|
// `uat.acknowledgeDeferredItem` — both `spliceFrontmatter` and
|
|
// `acknowledgeDeferredItem` locate and rewrite a specific byte span
|
|
// (frontmatter block / matched deferred-item text) in the file exactly as
|
|
// it exists on disk. Normalizing first would rewrite the file's line
|
|
// endings as a side effect of an unrelated acknowledge operation, and a
|
|
// splice computed against normalized text can land at the wrong offset
|
|
// when written back over the RAW (un-normalized) original. The snapshot
|
|
// VALUE computed below IS normalized (on a separate in-memory copy, never
|
|
// the spliced one) so it agrees with the scanner's frame — see the comment
|
|
// at `normalizedContent` further down.
|
|
// ── The four phase-scoped categories: --phase --file [--archived-milestone] ──
|
|
const PHASE_SCOPED = new Set(['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']);
|
|
if (PHASE_SCOPED.has(category as string)) {
|
|
if (!phase) ioError('--phase is required for this --category');
|
|
if (!file) ioError('--file is required for this --category');
|
|
const targetDir = resolvePhaseTargetDir(planDir, cwd, phase as string, archivedMilestone as string | null);
|
|
if (!targetDir) {
|
|
ioError(`no phase directory found for phase "${phase as string}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
|
|
}
|
|
const filePath = path.join(targetDir as string, file as string);
|
|
const safeFilePath = requireSafePath(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
if (!fs.existsSync(safeFilePath)) ioError(`file not found: ${file as string}`);
|
|
|
|
if (category === 'deferred_items') {
|
|
if (!text) ioError('--text is required for --category deferred_items');
|
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
const uat: UatDeferredModule = require('./uat.cjs');
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
const result = uat.acknowledgeDeferredItem(content, text as string);
|
|
if (result.status === 'not_found') ioError(`no deferred item matched --text "${text as string}"`);
|
|
if (result.status === 'ambiguous') ioError(`--text "${text as string}" matches more than one deferred item — text must be unique`);
|
|
if (result.status === 'already_resolved') ioError(`deferred item is already "status: resolved" — acknowledging a resolved item is a no-op`);
|
|
if (result.status === 'unsupported_heading_shape') {
|
|
ioError('this deferred-items.md uses the heading-delimited (#3457) entry shape, which the CLI writer does not yet support — edit the file directly');
|
|
}
|
|
if (result.status === 'match_verification_failed') {
|
|
ioError(`internal error: matched span for --text "${text as string}" did not re-verify before write — refused rather than risk writing the wrong entry`);
|
|
}
|
|
platformWriteSync(safeFilePath, result.content);
|
|
output({ acknowledged: true, category, phase, file, text }, raw, 'true');
|
|
return;
|
|
}
|
|
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
// Mixed-frame fix (security review, 4th instance on this branch): the
|
|
// splice above and below stays keyed to RAW `content` (raw byte offsets
|
|
// must not shift), but `scanUatGaps`/`scanContextQuestions` now derive
|
|
// their comparison values from `normalizeLineEndings`d content. Deriving
|
|
// the snapshot here from raw `content` would make a lone-CR file's
|
|
// stored value permanently disagree with what the scanner recomputes —
|
|
// `audit acknowledge` would be a silent no-op for lone-CR artifacts. Feed
|
|
// the derive functions a normalized COPY; never splice from it.
|
|
const normalizedContent = normalizeLineEndings(content);
|
|
let snapshotKey: string;
|
|
let currentValue: string;
|
|
if (category === 'uat_gaps') {
|
|
// WARNING 2 (#3458 follow-up review): status alone can't see MORE
|
|
// pending scenarios added under the same status — snapshot the
|
|
// composite `deriveUatGapSnapshotValue` instead (see its doc comment).
|
|
snapshotKey = 'gap_snapshot';
|
|
currentValue = deriveUatGapSnapshotValue(((fm.status as string) || 'unknown').toLowerCase(), normalizedContent);
|
|
} else if (category === 'verification_gaps') {
|
|
snapshotKey = 'status';
|
|
currentValue = ((fm.status as string) || 'unknown').toLowerCase();
|
|
} else {
|
|
// context_questions — WARNING 2: snapshot a content digest of the
|
|
// question set, not just its count (see `deriveOpenQuestionsDigest`'s
|
|
// doc comment).
|
|
snapshotKey = 'questions_digest';
|
|
currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(normalizedContent, fm));
|
|
}
|
|
fm.audit_acknowledged = { ...markerBase, [snapshotKey]: currentValue };
|
|
const newContent = spliceFrontmatter(content, fm);
|
|
platformWriteSync(safeFilePath, newContent);
|
|
output({ acknowledged: true, category, phase, file, [snapshotKey]: currentValue }, raw, 'true');
|
|
return;
|
|
}
|
|
|
|
// ── The five flat categories: category-specific identifier flag ──
|
|
// `status` for all five per the architecture's per-category table (`todos`
|
|
// is presence-only and never reads `snapshotKey`, so it stays a constant).
|
|
const snapshotKey = 'status';
|
|
let safeFilePath: string;
|
|
let currentValue: string;
|
|
let createIfMissing = false;
|
|
// Same value shape `Frontmatter`/`extractFrontmatter` use (frontmatter.cts
|
|
// does not export the `Frontmatter` type name itself, so it is spelled out
|
|
// structurally here) — keeps this and `extractFrontmatter`'s return type
|
|
// unifying to the SAME type below instead of a lossy `Record<string,
|
|
// unknown>` that `spliceFrontmatter`'s `Frontmatter` parameter would reject.
|
|
let fmForCreate: Record<string, string | string[] | Record<string, unknown>> = {};
|
|
|
|
if (category === 'debug_sessions') {
|
|
if (!slug) ioError('--slug is required for --category debug_sessions');
|
|
safeFilePath = requireSafePath(path.join(planDir, 'debug', `${slug as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
if (!fs.existsSync(safeFilePath)) ioError(`file not found: debug/${slug as string}.md`);
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'unknown').toLowerCase();
|
|
} else if (category === 'threads') {
|
|
if (!slug) ioError('--slug is required for --category threads');
|
|
safeFilePath = requireSafePath(path.join(planDir, 'threads', `${slug as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
if (!fs.existsSync(safeFilePath)) ioError(`file not found: threads/${slug as string}.md`);
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
|
|
} else if (category === 'seeds') {
|
|
if (!seedId) ioError('--seed-id is required for --category seeds');
|
|
safeFilePath = requireSafePath(path.join(planDir, 'seeds', `${seedId as string}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
if (!fs.existsSync(safeFilePath)) ioError(`file not found: seeds/${seedId as string}.md`);
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'dormant').toLowerCase();
|
|
} else if (category === 'todos') {
|
|
if (!filename) ioError('--filename is required for --category todos');
|
|
safeFilePath = requireSafePath(path.join(planDir, 'todos', 'pending', filename as string), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
if (!fs.existsSync(safeFilePath)) ioError(`file not found: todos/pending/${filename as string}`);
|
|
currentValue = ''; // presence-only — see scanTodos
|
|
} else if (category === 'quick_tasks') {
|
|
if (!quickDir) ioError('--dir is required for --category quick_tasks');
|
|
const taskDir = requireSafePath(path.join(planDir, 'quick', quickDir as string), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
|
|
if (!fs.existsSync(taskDir)) ioError(`directory not found: quick/${quickDir as string}`);
|
|
// Shared with scanQuickTasks (#3458 follow-up) so the reader and this
|
|
// writer can never disagree about which file is the task's record.
|
|
const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir as string);
|
|
if (resolvedSummaryPath) {
|
|
safeFilePath = requireSafePath(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
const content = fs.readFileSync(safeFilePath, 'utf-8');
|
|
currentValue = ((extractFrontmatter(content, safeFilePath).status as string) || 'unknown').toLowerCase();
|
|
} else {
|
|
// No SUMMARY.md at all — the audit's own observed status is 'missing'.
|
|
// There is nowhere to carry the marker, so create the canonical
|
|
// `${dir}-SUMMARY.md` with ONLY `status: missing` + the marker — the
|
|
// acknowledgment's own snapshot of "no summary exists yet", which
|
|
// self-invalidates the moment a real SUMMARY.md is written (the
|
|
// scanner then reads THAT file's own status instead).
|
|
safeFilePath = requireSafePath(path.join(taskDir, `${quickDir as string}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
currentValue = 'missing';
|
|
createIfMissing = true;
|
|
fmForCreate = { status: 'missing' };
|
|
}
|
|
} else {
|
|
ioError(`unknown --category "${category as string}". Available: debug_sessions, quick_tasks, threads, todos, seeds, uat_gaps, verification_gaps, context_questions, deferred_items`);
|
|
return; // unreachable — ioError throws — satisfies TS control-flow analysis
|
|
}
|
|
|
|
const presenceOnly = category === 'todos';
|
|
const fm = createIfMissing ? fmForCreate : extractFrontmatter(fs.readFileSync(safeFilePath, 'utf-8'), safeFilePath);
|
|
fm.audit_acknowledged = presenceOnly ? { ...markerBase } : { ...markerBase, [snapshotKey]: currentValue };
|
|
const newContent = createIfMissing
|
|
? spliceFrontmatter('', fm)
|
|
: spliceFrontmatter(fs.readFileSync(safeFilePath, 'utf-8'), fm);
|
|
platformWriteSync(safeFilePath, newContent);
|
|
output({ acknowledged: true, category, ...(presenceOnly ? {} : { [snapshotKey]: currentValue }) }, raw, 'true');
|
|
}
|
|
|
|
export = {
|
|
auditOpenArtifacts,
|
|
formatAuditReport,
|
|
listAuditPhaseTargets,
|
|
cmdAuditAcknowledge,
|
|
// #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README
|
|
// index generator shares this ONE discovery rule rather than re-deriving it.
|
|
resolveQuickTaskSummaryFile,
|
|
};
|