@opengsd/gsd-core 1.10.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +5 -5
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +37 -0
- package/agents/gsd-planner.md +44 -46
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +12 -3
- package/bin/install.js +841 -971
- package/bin/lib/ui-safety-gate.cjs +2 -0
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +1 -1
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +469 -88
- package/gsd-core/bin/lib/active-workstream-store.cjs +138 -22
- package/gsd-core/bin/lib/agent-install-check.cjs +230 -32
- package/gsd-core/bin/lib/api-coverage.cjs +3 -5
- package/gsd-core/bin/lib/artifacts.cjs +3 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +2 -4
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +876 -240
- package/gsd-core/bin/lib/broken-windows.cjs +1 -1
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-registry.cjs +575 -101
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +495 -22
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +71 -37
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/codex-agent-toml.cjs +329 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +651 -86
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +75 -0
- package/gsd-core/bin/lib/config.cjs +10 -1
- package/gsd-core/bin/lib/core-utils.cjs +127 -29
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +155 -20
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/git-base-branch.cjs +102 -0
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +145 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +265 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +173 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +431 -0
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init.cjs +321 -129
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +745 -258
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +203 -0
- package/gsd-core/bin/lib/install-profiles.cjs +134 -57
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +138 -31
- package/gsd-core/bin/lib/io.cjs +10 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +754 -70
- package/gsd-core/bin/lib/model-catalog.cjs +59 -1
- package/gsd-core/bin/lib/model-resolver.cjs +183 -40
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +1 -1
- package/gsd-core/bin/lib/phase-id.cjs +444 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +125 -18
- package/gsd-core/bin/lib/phase.cjs +646 -143
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +86 -2
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +890 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -6
- package/gsd-core/bin/lib/probe-core.cjs +1 -1
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +13 -4
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +34 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +943 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +385 -94
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +608 -46
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +426 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +115 -3
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +27 -9
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +275 -3
- package/gsd-core/bin/lib/smart-entry.cjs +142 -22
- package/gsd-core/bin/lib/state-command-router.cjs +5 -1
- package/gsd-core/bin/lib/state-document.cjs +152 -8
- package/gsd-core/bin/lib/state-transition.cjs +371 -117
- package/gsd-core/bin/lib/state.cjs +1794 -357
- package/gsd-core/bin/lib/surface.cjs +23 -9
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +9 -3
- package/gsd-core/bin/lib/uat.cjs +399 -56
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +14 -5
- package/gsd-core/bin/lib/unusable-input.cjs +24 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +37 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +258 -8
- package/gsd-core/bin/lib/verify.cjs +368 -888
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +176 -9
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +7 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/execute-mvp-tdd.md +3 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +6 -2
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +6 -2
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-planning-commit.md +2 -1
- package/gsd-core/references/loop-hook-dispatch.md +39 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planning-config.md +8 -6
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +2 -4
- package/gsd-core/templates/state.md +4 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/autonomous.md +1 -1
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +13 -3
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +38 -12
- package/gsd-core/workflows/complete-milestone.md +141 -18
- package/gsd-core/workflows/debug.md +7 -5
- package/gsd-core/workflows/diagnose-issues.md +35 -9
- package/gsd-core/workflows/discuss-phase/modes/chain.md +2 -1
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +2 -1
- package/gsd-core/workflows/edit-phase.md +26 -1
- package/gsd-core/workflows/eval-review.md +3 -5
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +31 -6
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +2 -0
- package/gsd-core/workflows/execute-phase.md +38 -50
- package/gsd-core/workflows/execute-plan.md +36 -4
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/health.md +73 -4
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +5 -5
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone.md +14 -9
- package/gsd-core/workflows/new-project.md +14 -14
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase.md +41 -17
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress.md +34 -6
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -4
- package/gsd-core/workflows/quick/steps/quick-verification.md +27 -6
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -2
- package/gsd-core/workflows/quick.md +35 -15
- package/gsd-core/workflows/review.md +26 -5
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings.md +66 -2
- package/gsd-core/workflows/ship.md +104 -44
- package/gsd-core/workflows/spec-phase.md +30 -12
- package/gsd-core/workflows/sync-skills.md +63 -8
- package/gsd-core/workflows/transition.md +46 -11
- package/gsd-core/workflows/ui-phase.md +5 -5
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update.md +1 -1
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +9 -7
- package/hooks/dist/gsd-agent-isolation-guard.js +103 -14
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +0 -3
- package/hooks/dist/gsd-cursor-subagent-start.js +77 -2
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/dist/gsd-prompt-guard.js +21 -20
- package/hooks/dist/gsd-read-injection-scanner.js +38 -24
- package/hooks/dist/gsd-statusline.js +18 -0
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/gsd-agent-isolation-guard.js +103 -14
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +77 -2
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +38 -24
- package/hooks/gsd-statusline.js +18 -0
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/package.json +21 -9
- package/pi/gsd.cjs +19 -5
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/ci-test-scope.cjs +19 -2
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/drift-scan.cjs +278 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +404 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-phase-enumeration-drift.cjs +479 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +434 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +320 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +1045 -0
- package/scripts/lint-test-file-count.allowlist.json +21 -10
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +25 -0
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/state-write-path-drift-baseline.json +19 -0
- package/scripts/sync-runtime-launcher.cjs +2 -4
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +1 -1
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -16,18 +16,28 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
16
16
|
};
|
|
17
17
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
18
18
|
const node_path_1 = __importDefault(require("node:path"));
|
|
19
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
19
20
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
20
21
|
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
22
|
+
const text_lines_cjs_1 = require("./text-lines.cjs");
|
|
21
23
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
24
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
23
|
-
const { planningDir } = planningWorkspace;
|
|
25
|
+
const { planningDir, quickDirFrom } = planningWorkspace;
|
|
24
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
25
27
|
const frontmatter = require("./frontmatter.cjs");
|
|
26
|
-
const { extractFrontmatter } = frontmatter;
|
|
28
|
+
const { extractFrontmatter, spliceFrontmatter } = frontmatter;
|
|
27
29
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
30
|
const phaseIdMod = require("./phase-id.cjs");
|
|
29
|
-
const { PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
31
|
+
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
|
|
32
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
|
+
const phaseLocator = require("./phase-locator.cjs");
|
|
34
|
+
const { getArchivedPhaseDirs } = phaseLocator;
|
|
30
35
|
const security_cjs_1 = require("./security.cjs");
|
|
36
|
+
const shell_command_projection_cjs_2 = require("./shell-command-projection.cjs");
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
|
+
const io = require("./io.cjs");
|
|
39
|
+
const { output, error: ioError } = io;
|
|
40
|
+
const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
|
|
31
41
|
// The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
|
|
32
42
|
// verbatim with the #2287 phase-boundary reader in `uat.cts`.
|
|
33
43
|
const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
|
|
@@ -35,6 +45,208 @@ const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
|
|
|
35
45
|
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
|
36
46
|
// not recreated on each loop iteration.
|
|
37
47
|
const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
|
48
|
+
// ─── Acknowledgment marker (suppression) ──────────────────────────────────────
|
|
49
|
+
//
|
|
50
|
+
// #3458 follow-up: `query audit-open` now scans archived milestone phase dirs,
|
|
51
|
+
// so an item still unresolved when a milestone closed resurfaces at EVERY
|
|
52
|
+
// later close, forever — `[A] Acknowledge all` documented that decision to
|
|
53
|
+
// STATE.md but never suppressed it. This section is the suppression seam.
|
|
54
|
+
//
|
|
55
|
+
// The marker lives INSIDE the artifact it suppresses, as an
|
|
56
|
+
// `audit_acknowledged` frontmatter map (no ledger, no id minting — see
|
|
57
|
+
// `uat.cts:891-897`'s `deferred-items.md` in-place `status: resolved`
|
|
58
|
+
// convention, which this generalizes):
|
|
59
|
+
//
|
|
60
|
+
// audit_acknowledged:
|
|
61
|
+
// milestone: v1.0 # which milestone close acknowledged it
|
|
62
|
+
// at: 2026-08-15 # ISO date
|
|
63
|
+
// status: gaps_found # snapshot of the artifact's state AT acknowledgment
|
|
64
|
+
// # (named `gap_snapshot` — status + open-scenario
|
|
65
|
+
// # count — for `uat_gaps`, and `questions_digest`
|
|
66
|
+
// # — a content hash of the question set, not just
|
|
67
|
+
// # its count — for `context_questions`; see
|
|
68
|
+
// # `isAuditItemAcknowledged`'s `snapshotKey` param
|
|
69
|
+
// # and each category's `deriveXxx` snapshot
|
|
70
|
+
// # helper for why a bare status/count was not
|
|
71
|
+
// # enough for those two — #3458 follow-up review)
|
|
72
|
+
//
|
|
73
|
+
// It is VERDICT-PRESERVING (this section never writes `status:` itself — see
|
|
74
|
+
// `cmdAuditAcknowledge` below) and SELF-INVALIDATING: it suppresses ONLY while
|
|
75
|
+
// `snapshotKey`'s recorded value still equals the artifact's CURRENT
|
|
76
|
+
// effective value. Edit the artifact after acknowledging it and the item
|
|
77
|
+
// resurfaces automatically — no separate revive/carry-forward state, and a
|
|
78
|
+
// stale acknowledgment can never hide a NEW problem, PROVIDED the category's
|
|
79
|
+
// snapshot actually captures the dimension that changed — `uat_gaps` and
|
|
80
|
+
// `context_questions` snapshot more than their status/count for exactly this
|
|
81
|
+
// reason (see above); every other category's only tracked dimension IS its
|
|
82
|
+
// `status:` (or, for `todos`, presence), so a bare status/presence snapshot
|
|
83
|
+
// is already complete for those.
|
|
84
|
+
//
|
|
85
|
+
// `isAuditItemAcknowledged` is the ONE shared predicate every scanner below
|
|
86
|
+
// routes through — this file has already been through the "hand-rolled the
|
|
87
|
+
// same check nine times" defect family twice this PR; a tenth hand-roll here
|
|
88
|
+
// is exactly that class. `deferred_items` is the deliberate exception: its
|
|
89
|
+
// suppression key lives PER-ENTRY inside `deferred-items.md`'s own
|
|
90
|
+
// `status:` field (see `uat.cts`'s `parseDeferredItemsWithStatus`), not in a
|
|
91
|
+
// file-level `audit_acknowledged` map, because a single deferred-items.md can
|
|
92
|
+
// carry many independently-acknowledgeable entries.
|
|
93
|
+
/**
|
|
94
|
+
* Parse and validate an artifact's `audit_acknowledged` frontmatter marker,
|
|
95
|
+
* then decide whether it suppresses the item given the artifact's CURRENT
|
|
96
|
+
* effective state.
|
|
97
|
+
*
|
|
98
|
+
* `snapshotKey` names which sub-field of the marker map carries the snapshot
|
|
99
|
+
* comparison value (`'status'` for every category except CONTEXT files, which
|
|
100
|
+
* use `'question_count'`). `presenceOnly: true` (used only for `todos`, which
|
|
101
|
+
* has no natural status field to snapshot) skips the snapshot comparison
|
|
102
|
+
* entirely — marker PRESENCE alone suppresses.
|
|
103
|
+
*
|
|
104
|
+
* A marker that is not a plain object/map, or is missing a non-empty string
|
|
105
|
+
* `milestone`/`at`, or — when a snapshot comparison applies — missing a
|
|
106
|
+
* string at `snapshotKey`, is MALFORMED and treated as ABSENT: this function
|
|
107
|
+
* returns `false` and the item surfaces. A bad marker must never suppress.
|
|
108
|
+
*/
|
|
109
|
+
function isAuditItemAcknowledged(fm, opts) {
|
|
110
|
+
const raw = fm.audit_acknowledged;
|
|
111
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
112
|
+
return false;
|
|
113
|
+
const marker = raw;
|
|
114
|
+
if (typeof marker.milestone !== 'string' || !marker.milestone)
|
|
115
|
+
return false;
|
|
116
|
+
if (typeof marker.at !== 'string' || !marker.at)
|
|
117
|
+
return false;
|
|
118
|
+
if (opts.presenceOnly)
|
|
119
|
+
return true;
|
|
120
|
+
const snapshot = marker[opts.snapshotKey];
|
|
121
|
+
if (typeof snapshot !== 'string')
|
|
122
|
+
return false;
|
|
123
|
+
return snapshot === opts.currentValue;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Derive a THREAD file's effective status: frontmatter `status:` when
|
|
127
|
+
* present, else the `## Status: OPEN|IN PROGRESS` body fallback — the same
|
|
128
|
+
* two-step derivation `scanThreads` already performed inline. Extracted so
|
|
129
|
+
* `cmdAuditAcknowledge` computes the CURRENT snapshot value with the exact
|
|
130
|
+
* same logic the scanner used to produce the marker's recorded value,
|
|
131
|
+
* instead of a second hand-derivation that could silently drift from it.
|
|
132
|
+
*/
|
|
133
|
+
function deriveThreadStatus(fm, content) {
|
|
134
|
+
let status = (fm.status || '').toLowerCase().trim();
|
|
135
|
+
if (!status) {
|
|
136
|
+
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
|
137
|
+
if (bodyStatusMatch) {
|
|
138
|
+
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return status;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Count a UAT file's still-open (`result: pending`/`[pending]`) scenarios.
|
|
145
|
+
* Extracted from `scanUatGaps`'s inline logic for the same reason as
|
|
146
|
+
* `deriveThreadStatus` — one derivation, shared by the scanner and
|
|
147
|
+
* `cmdAuditAcknowledge`'s `deriveUatGapSnapshotValue` below.
|
|
148
|
+
*/
|
|
149
|
+
function deriveUatGapOpenScenarioCount(content) {
|
|
150
|
+
return (content.match(/result:\s*(?:pending|\[pending\])/gi) || []).length;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Stable snapshot value for a `uat_gaps` item (WARNING 2, #3458 follow-up
|
|
154
|
+
* review). `status` alone is COUNT-blind the other direction: a UAT file can
|
|
155
|
+
* stay in the SAME open status (`gaps_found`) while gaining MORE pending
|
|
156
|
+
* scenarios (measured: 1→6 pending, status unchanged, item stayed
|
|
157
|
+
* suppressed under the old status-only scheme). Composing `status` with the
|
|
158
|
+
* open-scenario count means either dimension changing invalidates the
|
|
159
|
+
* snapshot.
|
|
160
|
+
*/
|
|
161
|
+
function deriveUatGapSnapshotValue(status, content) {
|
|
162
|
+
return `${status}::scenarios=${deriveUatGapOpenScenarioCount(content)}`;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Derive a CONTEXT file's FULL, UNTRUNCATED open-questions list: the
|
|
166
|
+
* structured `open_questions` frontmatter array when present and
|
|
167
|
+
* non-empty, else EVERY qualifying line of the `## Open Questions` body
|
|
168
|
+
* section. Extracted from `scanContextQuestions`'s inline logic for the same
|
|
169
|
+
* reason as `deriveThreadStatus` — one derivation, shared by the scanner and
|
|
170
|
+
* `cmdAuditAcknowledge`, so the acknowledged `question_count`/digest snapshot
|
|
171
|
+
* can never diverge from what the scanner counts.
|
|
172
|
+
*
|
|
173
|
+
* F2 (#3458 follow-up review, sibling of the deferred_items span-carrying
|
|
174
|
+
* fix): this used to `slice(0, 3)` the body-section list AND clamp each
|
|
175
|
+
* question to 200 chars BEFORE returning — a value meant for DISPLAY reused
|
|
176
|
+
* for the IDENTITY snapshot `deriveOpenQuestionsDigest` hashes. A 4th+
|
|
177
|
+
* question, or anything past char 200 of an earlier one, was invisible to
|
|
178
|
+
* the digest: an attacker could ship 3 innocuous questions first, then add
|
|
179
|
+
* real blockers afterward with zero effect on the recorded snapshot. Every
|
|
180
|
+
* caller that wants a bounded list for DISPLAY (`scanContextQuestions`'s
|
|
181
|
+
* `questions` field) truncates its OWN copy at the call site; this function
|
|
182
|
+
* always returns the complete, unclamped set.
|
|
183
|
+
*/
|
|
184
|
+
function deriveOpenQuestions(content, fm) {
|
|
185
|
+
let questions = [];
|
|
186
|
+
if (fm.open_questions) {
|
|
187
|
+
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
|
188
|
+
questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q)));
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
if (questions.length === 0) {
|
|
192
|
+
const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
|
|
193
|
+
if (oqSection) {
|
|
194
|
+
const oqBody = oqSection.body.trim();
|
|
195
|
+
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
|
196
|
+
const items = oqBody.split('\n')
|
|
197
|
+
.map((l) => l.trim())
|
|
198
|
+
.filter((l) => l && l !== '-' && l !== '*')
|
|
199
|
+
.filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
|
|
200
|
+
questions = items.map((q) => (0, security_cjs_1.sanitizeForDisplay)(q));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return questions;
|
|
205
|
+
}
|
|
206
|
+
/** Bound a question's DISPLAY text (never fed into the identity digest — see `deriveOpenQuestions`'s doc comment). */
|
|
207
|
+
function truncateQuestionForDisplay(question) {
|
|
208
|
+
return question.slice(0, 200);
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Stable normalized digest of a CONTEXT file's open-questions set (WARNING 2,
|
|
212
|
+
* #3458 follow-up review). `question_count` alone is COUNT-only: replacing
|
|
213
|
+
* every question's TEXT with brand-new ones while holding the count steady
|
|
214
|
+
* left an acknowledged item permanently suppressed (measured: 2 questions
|
|
215
|
+
* acknowledged, then both replaced with unrelated new blockers — still
|
|
216
|
+
* `counts:0`). Hashing the full, ordered question text means ANY edit —
|
|
217
|
+
* add, remove, reword, or reorder — changes the digest and the item
|
|
218
|
+
* resurfaces. sha256 (not the raw joined string) keeps the marker's stored
|
|
219
|
+
* value bounded regardless of question length/count.
|
|
220
|
+
*
|
|
221
|
+
* `questions` MUST be the untruncated, unclamped set `deriveOpenQuestions`
|
|
222
|
+
* returns — never a display-sliced/-clamped copy (F2, #3458 follow-up
|
|
223
|
+
* review); a truncated input reintroduces exactly the blind spot this digest
|
|
224
|
+
* exists to close.
|
|
225
|
+
*
|
|
226
|
+
* Length-prefixed, separator-free encoding (SWEEP finding, #3458 follow-up
|
|
227
|
+
* review) — NOT a plain join (the prior revision joined on a literal
|
|
228
|
+
* embedded NUL byte, `questions.join('\\0')` written as a raw control
|
|
229
|
+
* character in the SOURCE FILE itself — invisible in a normal diff/editor
|
|
230
|
+
* and still forgeable: attacker-controlled markdown CAN contain a literal
|
|
231
|
+
* NUL codepoint, since the file is read as UTF-8 text, so that scheme never
|
|
232
|
+
* actually closed the boundary-collision gap it was reaching for). A bare
|
|
233
|
+
* separator-joined string has no reliably unambiguous element boundary: two
|
|
234
|
+
* DIFFERENT question arrays can render the identical joined string and
|
|
235
|
+
* collide on the same digest — e.g. `['- Is X ready?', '- Y done?']` and
|
|
236
|
+
* `['- Is X ready? - Y', 'done?']` both join to
|
|
237
|
+
* `'- Is X ready? - Y done?'` under a space-join, and both could be forced to
|
|
238
|
+
* collide under a NUL-join too by an attacker who embeds the separator
|
|
239
|
+
* itself. Prefixing each element with its own CHARACTER LENGTH
|
|
240
|
+
* (`<len>:<text>`, concatenated with no separator at all) makes the encoding
|
|
241
|
+
* self-delimiting instead: decoding always consumes exactly `<len>`
|
|
242
|
+
* characters after each `:` before reading the next length prefix, so no two
|
|
243
|
+
* distinct arrays can ever encode to the same string — regardless of what
|
|
244
|
+
* characters the questions themselves contain.
|
|
245
|
+
*/
|
|
246
|
+
function deriveOpenQuestionsDigest(questions) {
|
|
247
|
+
const encoded = questions.map((q) => `${q.length}:${q}`).join('');
|
|
248
|
+
return node_crypto_1.default.createHash('sha256').update(encoded).digest('hex');
|
|
249
|
+
}
|
|
38
250
|
// ─── scanDebugSessions ────────────────────────────────────────────────────────
|
|
39
251
|
/**
|
|
40
252
|
* Scan .planning/debug/ for open sessions.
|
|
@@ -44,14 +256,15 @@ const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
|
|
44
256
|
function scanDebugSessions(planDir) {
|
|
45
257
|
const debugDir = node_path_1.default.join(planDir, 'debug');
|
|
46
258
|
if (!node_fs_1.default.existsSync(debugDir))
|
|
47
|
-
return [];
|
|
259
|
+
return { items: [], acknowledged: 0 };
|
|
48
260
|
const results = [];
|
|
261
|
+
let acknowledged = 0;
|
|
49
262
|
let files;
|
|
50
263
|
try {
|
|
51
264
|
files = node_fs_1.default.readdirSync(debugDir, { withFileTypes: true });
|
|
52
265
|
}
|
|
53
266
|
catch {
|
|
54
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
|
267
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
|
|
55
268
|
}
|
|
56
269
|
for (const entry of files) {
|
|
57
270
|
if (!entry.isFile())
|
|
@@ -73,6 +286,10 @@ function scanDebugSessions(planDir) {
|
|
|
73
286
|
const status = (fm.status || 'unknown').toLowerCase();
|
|
74
287
|
if (status === 'resolved' || status === 'complete')
|
|
75
288
|
continue;
|
|
289
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
290
|
+
acknowledged++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
76
293
|
// Extract hypothesis from "Current Focus" block if parseable
|
|
77
294
|
let hypothesis = '';
|
|
78
295
|
const focusSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
|
|
@@ -82,13 +299,55 @@ function scanDebugSessions(planDir) {
|
|
|
82
299
|
}
|
|
83
300
|
const slug = node_path_1.default.basename(entry.name, '.md');
|
|
84
301
|
results.push({
|
|
85
|
-
slug: (0, security_cjs_1.
|
|
302
|
+
slug: (0, security_cjs_1.sanitizeLabel)(slug),
|
|
86
303
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
87
304
|
updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
|
|
88
305
|
hypothesis,
|
|
89
306
|
});
|
|
90
307
|
}
|
|
91
|
-
return results;
|
|
308
|
+
return { items: results, acknowledged };
|
|
309
|
+
}
|
|
310
|
+
// ─── resolveQuickTaskSummaryFile ───────────────────────────────────────────────
|
|
311
|
+
/**
|
|
312
|
+
* Resolve a quick task's SUMMARY file, if any exists, under its own
|
|
313
|
+
* directory (`taskDir`). workflows/quick.md mandates `${quick_id}-SUMMARY.md`;
|
|
314
|
+
* older flows used bare `SUMMARY.md` — accept either to avoid a
|
|
315
|
+
* false-positive "missing", preferring the per-task `${dirName}-SUMMARY.md`
|
|
316
|
+
* form when more than one candidate exists.
|
|
317
|
+
*
|
|
318
|
+
* #3183 (ADR-3180 Decision 4(a) — bucket B, out of scope for the
|
|
319
|
+
* scanPhasePlans migration): this scans a quick task's OWN directory
|
|
320
|
+
* (`.planning/quick/<task>/`) for THAT task's single completion record —
|
|
321
|
+
* "does this one quick task have a SUMMARY.md" — not a phase directory's
|
|
322
|
+
* live-plan/summary counting question. scanPhasePlans is the wrong tool
|
|
323
|
+
* here; there is no plan/summary PAIRING to derive, only a single filename
|
|
324
|
+
* presence check local to a non-phase directory.
|
|
325
|
+
*
|
|
326
|
+
* Extracted (#3458 follow-up) so `scanQuickTasks` (read) and
|
|
327
|
+
* `cmdAuditAcknowledge`'s quick_tasks writer share the ONE discovery rule —
|
|
328
|
+
* previously the writer would have had to hand-roll this exact filter a
|
|
329
|
+
* second time, which is exactly the re-derivation-drift class
|
|
330
|
+
* `scripts/lint-plan-count-drift.cjs` exists to catch (see its
|
|
331
|
+
* `FUNCTION_SCOPED_EXEMPTIONS` entry for this function).
|
|
332
|
+
*
|
|
333
|
+
* Returns `null` (never throws) on an unreadable `taskDir` or when no
|
|
334
|
+
* SUMMARY-shaped file exists.
|
|
335
|
+
*/
|
|
336
|
+
function resolveQuickTaskSummaryFile(taskDir, dirName) {
|
|
337
|
+
let summaryFiles;
|
|
338
|
+
try {
|
|
339
|
+
summaryFiles = node_fs_1.default.readdirSync(taskDir, { withFileTypes: true })
|
|
340
|
+
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
return null;
|
|
344
|
+
}
|
|
345
|
+
if (summaryFiles.length === 0)
|
|
346
|
+
return null;
|
|
347
|
+
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
|
348
|
+
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
|
349
|
+
|| summaryFiles[0];
|
|
350
|
+
return node_path_1.default.join(taskDir, preferred.name);
|
|
92
351
|
}
|
|
93
352
|
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
|
94
353
|
/**
|
|
@@ -96,17 +355,20 @@ function scanDebugSessions(planDir) {
|
|
|
96
355
|
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
|
97
356
|
*/
|
|
98
357
|
function scanQuickTasks(planDir) {
|
|
99
|
-
|
|
358
|
+
// #2142: routed through the shared quickDirFrom composer (planning-workspace.cts)
|
|
359
|
+
// so `.planning/quick` has exactly ONE owner instead of two ad-hoc path.joins.
|
|
360
|
+
const quickDir = quickDirFrom(planDir);
|
|
100
361
|
if (!node_fs_1.default.existsSync(quickDir))
|
|
101
|
-
return [];
|
|
362
|
+
return { items: [], acknowledged: 0 };
|
|
102
363
|
let entries;
|
|
103
364
|
try {
|
|
104
365
|
entries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
|
|
105
366
|
}
|
|
106
367
|
catch {
|
|
107
|
-
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
|
368
|
+
return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
|
|
108
369
|
}
|
|
109
370
|
const results = [];
|
|
371
|
+
let acknowledged = 0;
|
|
110
372
|
for (const entry of entries) {
|
|
111
373
|
if (!entry.isDirectory())
|
|
112
374
|
continue;
|
|
@@ -119,25 +381,10 @@ function scanQuickTasks(planDir) {
|
|
|
119
381
|
catch {
|
|
120
382
|
continue;
|
|
121
383
|
}
|
|
122
|
-
|
|
123
|
-
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
|
124
|
-
let summaryPath = null;
|
|
125
|
-
try {
|
|
126
|
-
const summaryFiles = node_fs_1.default.readdirSync(safeTaskDir, { withFileTypes: true })
|
|
127
|
-
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
|
128
|
-
if (summaryFiles.length > 0) {
|
|
129
|
-
// Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
|
|
130
|
-
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
|
131
|
-
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
|
132
|
-
|| summaryFiles[0];
|
|
133
|
-
summaryPath = node_path_1.default.join(safeTaskDir, preferred.name);
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
catch {
|
|
137
|
-
// fall through with summaryPath = null → status: missing
|
|
138
|
-
}
|
|
384
|
+
const summaryPath = resolveQuickTaskSummaryFile(safeTaskDir, dirName);
|
|
139
385
|
let status = 'missing';
|
|
140
386
|
const description = '';
|
|
387
|
+
let fm = null;
|
|
141
388
|
if (summaryPath && node_fs_1.default.existsSync(summaryPath)) {
|
|
142
389
|
let safeSum;
|
|
143
390
|
try {
|
|
@@ -151,19 +398,31 @@ function scanQuickTasks(planDir) {
|
|
|
151
398
|
status = 'unreadable';
|
|
152
399
|
}
|
|
153
400
|
else {
|
|
154
|
-
|
|
401
|
+
fm = extractFrontmatter(content, safeSum);
|
|
155
402
|
status = (fm.status || 'unknown').toLowerCase();
|
|
156
403
|
}
|
|
157
404
|
}
|
|
158
405
|
if (status === 'complete')
|
|
159
406
|
continue;
|
|
407
|
+
// Acknowledgment marker only ever lives in the SUMMARY file's own
|
|
408
|
+
// frontmatter — a task with no summary (status: 'missing') has nowhere to
|
|
409
|
+
// carry one, so `fm` is null and this is skipped (never suppressed).
|
|
410
|
+
if (fm && isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
411
|
+
acknowledged++;
|
|
412
|
+
continue;
|
|
413
|
+
}
|
|
160
414
|
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
|
|
161
415
|
let date = '';
|
|
162
|
-
let slug = (0, security_cjs_1.
|
|
416
|
+
let slug = (0, security_cjs_1.sanitizeLabel)(dirName);
|
|
163
417
|
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
|
|
164
418
|
if (dateMatch) {
|
|
165
|
-
|
|
166
|
-
|
|
419
|
+
// dateMatch[1] is regex-constrained to `\d{4}-?\d{2}-?\d{2}` (digits and
|
|
420
|
+
// literal hyphens only — the same "constrained at the source" shape as
|
|
421
|
+
// `archived_milestone`), so it cannot itself carry a control byte.
|
|
422
|
+
// Still routed through sanitizeLabel as defense-in-depth for
|
|
423
|
+
// consistency with every other directory-name-derived field here.
|
|
424
|
+
date = (0, security_cjs_1.sanitizeLabel)(dateMatch[1]);
|
|
425
|
+
slug = (0, security_cjs_1.sanitizeLabel)(dateMatch[2]);
|
|
167
426
|
}
|
|
168
427
|
results.push({
|
|
169
428
|
slug,
|
|
@@ -172,7 +431,7 @@ function scanQuickTasks(planDir) {
|
|
|
172
431
|
description,
|
|
173
432
|
});
|
|
174
433
|
}
|
|
175
|
-
return results;
|
|
434
|
+
return { items: results, acknowledged };
|
|
176
435
|
}
|
|
177
436
|
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
|
178
437
|
/**
|
|
@@ -182,16 +441,17 @@ function scanQuickTasks(planDir) {
|
|
|
182
441
|
function scanThreads(planDir) {
|
|
183
442
|
const threadsDir = node_path_1.default.join(planDir, 'threads');
|
|
184
443
|
if (!node_fs_1.default.existsSync(threadsDir))
|
|
185
|
-
return [];
|
|
444
|
+
return { items: [], acknowledged: 0 };
|
|
186
445
|
let files;
|
|
187
446
|
try {
|
|
188
447
|
files = node_fs_1.default.readdirSync(threadsDir, { withFileTypes: true });
|
|
189
448
|
}
|
|
190
449
|
catch {
|
|
191
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
|
450
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
|
|
192
451
|
}
|
|
193
452
|
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
|
194
453
|
const results = [];
|
|
454
|
+
let acknowledged = 0;
|
|
195
455
|
for (const entry of files) {
|
|
196
456
|
if (!entry.isFile())
|
|
197
457
|
continue;
|
|
@@ -209,16 +469,13 @@ function scanThreads(planDir) {
|
|
|
209
469
|
if (content === null)
|
|
210
470
|
continue;
|
|
211
471
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
212
|
-
|
|
213
|
-
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
|
|
214
|
-
if (!status) {
|
|
215
|
-
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
|
216
|
-
if (bodyStatusMatch) {
|
|
217
|
-
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
|
218
|
-
}
|
|
219
|
-
}
|
|
472
|
+
const status = deriveThreadStatus(fm, content);
|
|
220
473
|
if (!openStatuses.has(status))
|
|
221
474
|
continue;
|
|
475
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
476
|
+
acknowledged++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
222
479
|
// Extract title from # Thread: heading or frontmatter title
|
|
223
480
|
let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
|
|
224
481
|
if (!title) {
|
|
@@ -229,13 +486,13 @@ function scanThreads(planDir) {
|
|
|
229
486
|
}
|
|
230
487
|
const slug = node_path_1.default.basename(entry.name, '.md');
|
|
231
488
|
results.push({
|
|
232
|
-
slug: (0, security_cjs_1.
|
|
489
|
+
slug: (0, security_cjs_1.sanitizeLabel)(slug),
|
|
233
490
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
234
491
|
updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
|
|
235
492
|
title,
|
|
236
493
|
});
|
|
237
494
|
}
|
|
238
|
-
return results;
|
|
495
|
+
return { items: results, acknowledged };
|
|
239
496
|
}
|
|
240
497
|
// ─── scanTodos ────────────────────────────────────────────────────────────────
|
|
241
498
|
/**
|
|
@@ -246,18 +503,27 @@ function scanThreads(planDir) {
|
|
|
246
503
|
function scanTodos(planDir) {
|
|
247
504
|
const pendingDir = node_path_1.default.join(planDir, 'todos', 'pending');
|
|
248
505
|
if (!node_fs_1.default.existsSync(pendingDir))
|
|
249
|
-
return [];
|
|
506
|
+
return { items: [], acknowledged: 0 };
|
|
250
507
|
let files;
|
|
251
508
|
try {
|
|
252
509
|
files = node_fs_1.default.readdirSync(pendingDir, { withFileTypes: true });
|
|
253
510
|
}
|
|
254
511
|
catch {
|
|
255
|
-
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
|
512
|
+
return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
|
|
256
513
|
}
|
|
257
514
|
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
258
515
|
const results = [];
|
|
259
|
-
|
|
260
|
-
|
|
516
|
+
let acknowledged = 0;
|
|
517
|
+
// BLOCKER 2 (#3458 follow-up review): filter acknowledged items BEFORE
|
|
518
|
+
// the display cap. Capping the RAW file list to 5 first meant an
|
|
519
|
+
// acknowledge of one of those 5 files simply revealed the 6th on the next
|
|
520
|
+
// scan — files 6/7/... (never shown, never acknowledgeable via the CLI's
|
|
521
|
+
// own remedy) permanently vanished from every later scan once 5+ items
|
|
522
|
+
// existed, because `mdFiles.length` (not the post-filter open count) drove
|
|
523
|
+
// both the cap and the remainder count. Read every file's acknowledgment
|
|
524
|
+
// state first, THEN cap the OPEN (unacknowledged) set for display.
|
|
525
|
+
const openFiles = [];
|
|
526
|
+
for (const entry of mdFiles) {
|
|
261
527
|
const filePath = node_path_1.default.join(pendingDir, entry.name);
|
|
262
528
|
let safeFilePath;
|
|
263
529
|
try {
|
|
@@ -270,21 +536,33 @@ function scanTodos(planDir) {
|
|
|
270
536
|
if (content === null)
|
|
271
537
|
continue;
|
|
272
538
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
539
|
+
// Todos carry no natural status field — presence in pending/ IS "open" by
|
|
540
|
+
// definition (a resolved todo is moved out, not status-flagged). So the
|
|
541
|
+
// acknowledgment check here is PRESENCE-ONLY: no snapshot to go stale, no
|
|
542
|
+
// self-invalidation on edit — see `isAuditItemAcknowledged`'s doc comment.
|
|
543
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: '', presenceOnly: true })) {
|
|
544
|
+
acknowledged++;
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
openFiles.push({ entry, content, fm });
|
|
548
|
+
}
|
|
549
|
+
const displayFiles = openFiles.slice(0, 5);
|
|
550
|
+
for (const { entry, content, fm } of displayFiles) {
|
|
273
551
|
// Extract first line of body after frontmatter
|
|
274
|
-
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
|
|
275
|
-
const firstLine = bodyMatch.trim()
|
|
552
|
+
const bodyMatch = content.replace(/^---[\s\S]*?---\r?\n?/, '');
|
|
553
|
+
const firstLine = (0, text_lines_cjs_1.splitLines)(bodyMatch.trim())[0] || '';
|
|
276
554
|
const summary = (0, security_cjs_1.sanitizeForDisplay)(firstLine.slice(0, 100));
|
|
277
555
|
results.push({
|
|
278
|
-
filename: (0, security_cjs_1.
|
|
556
|
+
filename: (0, security_cjs_1.sanitizeLabel)(entry.name),
|
|
279
557
|
priority: (0, security_cjs_1.sanitizeForDisplay)(fm.priority || ''),
|
|
280
558
|
area: (0, security_cjs_1.sanitizeForDisplay)(fm.area || ''),
|
|
281
559
|
summary,
|
|
282
560
|
});
|
|
283
561
|
}
|
|
284
|
-
if (
|
|
285
|
-
results.push({ _remainder_count:
|
|
562
|
+
if (openFiles.length > 5) {
|
|
563
|
+
results.push({ _remainder_count: openFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
|
|
286
564
|
}
|
|
287
|
-
return results;
|
|
565
|
+
return { items: results, acknowledged };
|
|
288
566
|
}
|
|
289
567
|
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
|
290
568
|
/**
|
|
@@ -294,16 +572,17 @@ function scanTodos(planDir) {
|
|
|
294
572
|
function scanSeeds(planDir) {
|
|
295
573
|
const seedsDir = node_path_1.default.join(planDir, 'seeds');
|
|
296
574
|
if (!node_fs_1.default.existsSync(seedsDir))
|
|
297
|
-
return [];
|
|
575
|
+
return { items: [], acknowledged: 0 };
|
|
298
576
|
let files;
|
|
299
577
|
try {
|
|
300
578
|
files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
|
|
301
579
|
}
|
|
302
580
|
catch {
|
|
303
|
-
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
|
581
|
+
return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
|
|
304
582
|
}
|
|
305
583
|
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
|
306
584
|
const results = [];
|
|
585
|
+
let acknowledged = 0;
|
|
307
586
|
for (const entry of files) {
|
|
308
587
|
if (!entry.isFile())
|
|
309
588
|
continue;
|
|
@@ -324,10 +603,20 @@ function scanSeeds(planDir) {
|
|
|
324
603
|
const status = (fm.status || 'dormant').toLowerCase();
|
|
325
604
|
if (!unimplementedStatuses.has(status))
|
|
326
605
|
continue;
|
|
327
|
-
|
|
606
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
607
|
+
acknowledged++;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
// Extract seed_id from filename or frontmatter. The regex match is
|
|
611
|
+
// `\w`/hyphen-constrained (safe by construction, like `archived_milestone`)
|
|
612
|
+
// but the fallback taken when a filename doesn't fully match — e.g. a
|
|
613
|
+
// `SEED-`-prefixed, `.md`-suffixed name with a control byte SOMEWHERE in
|
|
614
|
+
// the middle, which still passes the `startsWith`/`endsWith` filter above
|
|
615
|
+
// — is the raw, unconstrained basename. Both branches are routed through
|
|
616
|
+
// sanitizeLabel below.
|
|
328
617
|
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
|
329
618
|
const seed_id = seedIdMatch ? seedIdMatch[1] : node_path_1.default.basename(entry.name, '.md');
|
|
330
|
-
const slug = (0, security_cjs_1.
|
|
619
|
+
const slug = (0, security_cjs_1.sanitizeLabel)(seed_id.replace(/^SEED-/, ''));
|
|
331
620
|
let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
|
|
332
621
|
if (!title) {
|
|
333
622
|
const headingMatch = content.match(/^#\s*(.+)$/m);
|
|
@@ -335,46 +624,123 @@ function scanSeeds(planDir) {
|
|
|
335
624
|
title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
|
|
336
625
|
}
|
|
337
626
|
results.push({
|
|
338
|
-
seed_id: (0, security_cjs_1.
|
|
627
|
+
seed_id: (0, security_cjs_1.sanitizeLabel)(seed_id),
|
|
339
628
|
slug,
|
|
340
629
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
341
630
|
title,
|
|
342
631
|
});
|
|
343
632
|
}
|
|
344
|
-
return results;
|
|
633
|
+
return { items: results, acknowledged };
|
|
345
634
|
}
|
|
346
|
-
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
|
347
635
|
/**
|
|
348
|
-
*
|
|
636
|
+
* Enumerate phase directories across BOTH the active `.planning/phases/` root
|
|
637
|
+
* and every archived `.planning/milestones/vX.Y-phases/` root. Shared by the
|
|
638
|
+
* four phase-scoped scanners below (#3458 — epic #3473 B2/F2). Previously each
|
|
639
|
+
* scanner hand-rolled its own active-only `readdirSync(phasesDir)` walk and
|
|
640
|
+
* bailed out entirely when the active root was missing, so items still
|
|
641
|
+
* unresolved when a milestone closed and its phase dirs archived became
|
|
642
|
+
* permanently invisible to every later audit.
|
|
643
|
+
*
|
|
644
|
+
* ACTIVE dirs: raw readdirSync + isDirectory filter + sort. The enumeration
|
|
645
|
+
* walk itself (readdirSync + isDirectory filter + sort) is UNCHANGED from the
|
|
646
|
+
* scanners' prior inline behavior; what IS new is that a failed read here no
|
|
647
|
+
* longer aborts the whole scan the way each scanner's own inline
|
|
648
|
+
* `if (!fs.existsSync) return []` / try-readdirSync-catch-return-sentinel
|
|
649
|
+
* pair used to — see `activeUnreadable` below, which is how that signal is
|
|
650
|
+
* now surfaced to callers instead. Deliberately NOT routed through
|
|
651
|
+
* listMilestonePhaseDirs: these scanners are deliberately not
|
|
652
|
+
* milestone-filtered today, and switching would silently add window/sentinel
|
|
653
|
+
* filtering — a behavior change belonging to #3372, not here.
|
|
654
|
+
*
|
|
655
|
+
* A missing/unreadable active root does NOT short-circuit the archive walk —
|
|
656
|
+
* the old `if (!fs.existsSync(phasesDir)) return []` was the whole bug in a
|
|
657
|
+
* fully-archived project; it degrades to "skip the active half" only. An
|
|
658
|
+
* UNREADABLE (as opposed to merely absent) active root is reported back via
|
|
659
|
+
* `activeUnreadable: true` so each of the four callers can still emit the
|
|
660
|
+
* `scan_error` sentinel they emitted pre-#3458 for this exact case (a real
|
|
661
|
+
* I/O failure, not "verified clean") — see each scanner's own use of it.
|
|
662
|
+
*
|
|
663
|
+
* ARCHIVED dirs: sourced from `getArchivedPhaseDirs` (phase-locator.cjs), the
|
|
664
|
+
* canonical archive-enumeration seam `uat.cts`'s `cmdAuditUat` already uses.
|
|
665
|
+
* Archived dirs are deliberately NOT milestone-filtered either — see the
|
|
666
|
+
* comment at src/uat.cts:107-111: listMilestonePhaseDirs derives the CURRENT
|
|
667
|
+
* milestone's phase dirs (window + sentinel filtered) from ROADMAP.md, and
|
|
668
|
+
* archived phases belong to past milestones by definition, so filtering them
|
|
669
|
+
* discards every one and silently reinstates the bug this function fixes.
|
|
670
|
+
*
|
|
671
|
+
* An unreadable/unresolvable ARCHIVE root does NOT get its own sentinel.
|
|
672
|
+
* Pre-#3458 there was no archived read at all, so — unlike the active root —
|
|
673
|
+
* there is no prior `scan_error` contract to preserve here, and no existing
|
|
674
|
+
* consumer can regress. It also degrades the same way `listArchiveVersionDirs`
|
|
675
|
+
* (phase-locator.cts) already treats an absent `milestones/` dir: a real
|
|
676
|
+
* empty, not a failure, matching this function's existing "skip that root"
|
|
677
|
+
* idiom for the missing-active-dir case above. Adding a second sentinel path
|
|
678
|
+
* would let a machine consumer conflate "no milestones archived yet" (the
|
|
679
|
+
* overwhelmingly common case for an active project) with an actual read
|
|
680
|
+
* failure, which is a worse signal-to-noise trade than the one this
|
|
681
|
+
* function's own fix removes for the active root.
|
|
682
|
+
*
|
|
683
|
+
* Same-named dirs in both roots (e.g. "01-alpha" active AND archived) are
|
|
684
|
+
* DISTINCT targets — no dedupe.
|
|
349
685
|
*/
|
|
350
|
-
function
|
|
686
|
+
function listAuditPhaseTargets(planDir, cwd) {
|
|
687
|
+
const targets = [];
|
|
688
|
+
let activeUnreadable = false;
|
|
351
689
|
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
690
|
+
if (node_fs_1.default.existsSync(phasesDir)) {
|
|
691
|
+
try {
|
|
692
|
+
const dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
693
|
+
.filter(e => e.isDirectory())
|
|
694
|
+
.map(e => e.name)
|
|
695
|
+
.sort();
|
|
696
|
+
for (const dir of dirs) {
|
|
697
|
+
targets.push({ dir, fullPath: node_path_1.default.join(phasesDir, dir) });
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Unreadable active root: skip it, do not abort the archive walk, but
|
|
702
|
+
// report it so callers can emit their pre-#3458 scan_error sentinel.
|
|
703
|
+
activeUnreadable = true;
|
|
704
|
+
}
|
|
705
|
+
}
|
|
355
706
|
try {
|
|
356
|
-
|
|
357
|
-
.
|
|
358
|
-
|
|
359
|
-
.sort();
|
|
707
|
+
for (const archived of getArchivedPhaseDirs(cwd)) {
|
|
708
|
+
targets.push({ dir: archived.name, fullPath: archived.fullPath, milestone: archived.milestone });
|
|
709
|
+
}
|
|
360
710
|
}
|
|
361
711
|
catch {
|
|
362
|
-
|
|
712
|
+
// Unreadable/unresolvable archive root: skip it, keep whatever active
|
|
713
|
+
// targets were already collected. No sentinel — see docstring above.
|
|
363
714
|
}
|
|
715
|
+
return { targets, activeUnreadable };
|
|
716
|
+
}
|
|
717
|
+
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
|
718
|
+
/**
|
|
719
|
+
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
720
|
+
* for UAT gaps (UAT files with status != 'complete'/'resolved').
|
|
721
|
+
*/
|
|
722
|
+
function scanUatGaps(planDir, cwd) {
|
|
364
723
|
const results = [];
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
724
|
+
let acknowledged = 0;
|
|
725
|
+
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
726
|
+
if (activeUnreadable) {
|
|
727
|
+
results.push({ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 });
|
|
728
|
+
}
|
|
729
|
+
for (const target of targets) {
|
|
730
|
+
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
731
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
369
732
|
let files;
|
|
370
733
|
try {
|
|
371
|
-
files = node_fs_1.default.readdirSync(
|
|
734
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
372
735
|
}
|
|
373
736
|
catch {
|
|
374
737
|
continue;
|
|
375
738
|
}
|
|
376
|
-
|
|
377
|
-
|
|
739
|
+
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
|
|
740
|
+
// ad-hoc UAT file cannot surface as this phase's gap — same fix as
|
|
741
|
+
// scanVerificationGaps below.
|
|
742
|
+
for (const file of scopeToPhase(files.filter(f => f.includes('-UAT') && f.endsWith('.md')), target.dir)) {
|
|
743
|
+
const filePath = node_path_1.default.join(target.fullPath, file);
|
|
378
744
|
let safeFilePath;
|
|
379
745
|
try {
|
|
380
746
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
|
@@ -394,50 +760,52 @@ function scanUatGaps(planDir) {
|
|
|
394
760
|
continue;
|
|
395
761
|
if (status === 'unknown' && result === 'all_pass')
|
|
396
762
|
continue;
|
|
397
|
-
// Count open scenarios
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
763
|
+
// Count open scenarios — computed BEFORE the acknowledged check
|
|
764
|
+
// (WARNING 2) so the snapshot comparison sees it too, not just status.
|
|
765
|
+
const pendingMatches = deriveUatGapOpenScenarioCount(content);
|
|
766
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
|
|
767
|
+
acknowledged++;
|
|
768
|
+
continue;
|
|
769
|
+
}
|
|
770
|
+
const item = {
|
|
771
|
+
phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
|
|
772
|
+
file: (0, security_cjs_1.sanitizeLabel)(file),
|
|
402
773
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
403
774
|
open_scenario_count: pendingMatches,
|
|
404
|
-
}
|
|
775
|
+
};
|
|
776
|
+
if (target.milestone !== undefined)
|
|
777
|
+
item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
778
|
+
results.push(item);
|
|
405
779
|
}
|
|
406
780
|
}
|
|
407
|
-
return results;
|
|
781
|
+
return { items: results, acknowledged };
|
|
408
782
|
}
|
|
409
783
|
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
|
410
784
|
/**
|
|
411
|
-
* Scan .planning/phases
|
|
785
|
+
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
786
|
+
* for VERIFICATION gaps.
|
|
412
787
|
*/
|
|
413
|
-
function scanVerificationGaps(planDir) {
|
|
414
|
-
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
415
|
-
if (!node_fs_1.default.existsSync(phasesDir))
|
|
416
|
-
return [];
|
|
417
|
-
let dirs;
|
|
418
|
-
try {
|
|
419
|
-
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
420
|
-
.filter(e => e.isDirectory())
|
|
421
|
-
.map(e => e.name)
|
|
422
|
-
.sort();
|
|
423
|
-
}
|
|
424
|
-
catch {
|
|
425
|
-
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
|
426
|
-
}
|
|
788
|
+
function scanVerificationGaps(planDir, cwd) {
|
|
427
789
|
const results = [];
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
790
|
+
let acknowledged = 0;
|
|
791
|
+
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
792
|
+
if (activeUnreadable) {
|
|
793
|
+
results.push({ scan_error: true, phase: '', file: '', status: '' });
|
|
794
|
+
}
|
|
795
|
+
for (const target of targets) {
|
|
796
|
+
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
797
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
432
798
|
let files;
|
|
433
799
|
try {
|
|
434
|
-
files = node_fs_1.default.readdirSync(
|
|
800
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
435
801
|
}
|
|
436
802
|
catch {
|
|
437
803
|
continue;
|
|
438
804
|
}
|
|
439
|
-
|
|
440
|
-
|
|
805
|
+
// Scoped to THIS phase's own token (#3511) so a stray, cross-phase, or
|
|
806
|
+
// ad-hoc VERIFICATION file cannot surface as this phase's gap.
|
|
807
|
+
for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), target.dir)) {
|
|
808
|
+
const filePath = node_path_1.default.join(target.fullPath, file);
|
|
441
809
|
let safeFilePath;
|
|
442
810
|
try {
|
|
443
811
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
|
@@ -452,47 +820,46 @@ function scanVerificationGaps(planDir) {
|
|
|
452
820
|
const status = (fm.status || 'unknown').toLowerCase();
|
|
453
821
|
if (status !== 'gaps_found' && status !== 'human_needed')
|
|
454
822
|
continue;
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
823
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
824
|
+
acknowledged++;
|
|
825
|
+
continue;
|
|
826
|
+
}
|
|
827
|
+
const item = {
|
|
828
|
+
phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
|
|
829
|
+
file: (0, security_cjs_1.sanitizeLabel)(file),
|
|
458
830
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
459
|
-
}
|
|
831
|
+
};
|
|
832
|
+
if (target.milestone !== undefined)
|
|
833
|
+
item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
834
|
+
results.push(item);
|
|
460
835
|
}
|
|
461
836
|
}
|
|
462
|
-
return results;
|
|
837
|
+
return { items: results, acknowledged };
|
|
463
838
|
}
|
|
464
839
|
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
|
465
840
|
/**
|
|
466
|
-
* Scan .planning/phases
|
|
841
|
+
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
842
|
+
* for CONTEXT files with open_questions.
|
|
467
843
|
*/
|
|
468
|
-
function scanContextQuestions(planDir) {
|
|
469
|
-
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
470
|
-
if (!node_fs_1.default.existsSync(phasesDir))
|
|
471
|
-
return [];
|
|
472
|
-
let dirs;
|
|
473
|
-
try {
|
|
474
|
-
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
475
|
-
.filter(e => e.isDirectory())
|
|
476
|
-
.map(e => e.name)
|
|
477
|
-
.sort();
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
|
481
|
-
}
|
|
844
|
+
function scanContextQuestions(planDir, cwd) {
|
|
482
845
|
const results = [];
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
846
|
+
let acknowledged = 0;
|
|
847
|
+
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
848
|
+
if (activeUnreadable) {
|
|
849
|
+
results.push({ scan_error: true, phase: '', file: '', question_count: 0, questions: [] });
|
|
850
|
+
}
|
|
851
|
+
for (const target of targets) {
|
|
852
|
+
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
853
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
487
854
|
let files;
|
|
488
855
|
try {
|
|
489
|
-
files = node_fs_1.default.readdirSync(
|
|
856
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
490
857
|
}
|
|
491
858
|
catch {
|
|
492
859
|
continue;
|
|
493
860
|
}
|
|
494
861
|
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
|
495
|
-
const filePath = node_path_1.default.join(
|
|
862
|
+
const filePath = node_path_1.default.join(target.fullPath, file);
|
|
496
863
|
let safeFilePath;
|
|
497
864
|
try {
|
|
498
865
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
|
@@ -504,42 +871,36 @@ function scanContextQuestions(planDir) {
|
|
|
504
871
|
if (content === null)
|
|
505
872
|
continue;
|
|
506
873
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
507
|
-
|
|
508
|
-
let questions = [];
|
|
509
|
-
if (fm.open_questions) {
|
|
510
|
-
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
|
511
|
-
questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q).slice(0, 200)));
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
// Also check for ## Open Questions section in body
|
|
515
|
-
if (questions.length === 0) {
|
|
516
|
-
const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
|
|
517
|
-
if (oqSection) {
|
|
518
|
-
const oqBody = oqSection.body.trim();
|
|
519
|
-
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
|
520
|
-
const items = oqBody.split('\n')
|
|
521
|
-
.map((l) => l.trim())
|
|
522
|
-
.filter((l) => l && l !== '-' && l !== '*')
|
|
523
|
-
.filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
|
|
524
|
-
questions = items.slice(0, 3).map((q) => (0, security_cjs_1.sanitizeForDisplay)(q.slice(0, 200)));
|
|
525
|
-
}
|
|
526
|
-
}
|
|
527
|
-
}
|
|
874
|
+
const questions = deriveOpenQuestions(content, fm);
|
|
528
875
|
if (questions.length === 0)
|
|
529
876
|
continue;
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
877
|
+
// WARNING 2 (#3458 follow-up review): snapshot the QUESTIONS
|
|
878
|
+
// THEMSELVES (a content digest), not just their count — a count-only
|
|
879
|
+
// snapshot cannot see the same-count REPLACEMENT of every question
|
|
880
|
+
// with brand-new ones (measured: 2 acknowledged, then both swapped for
|
|
881
|
+
// unrelated new blockers, still suppressed under the old scheme).
|
|
882
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'questions_digest', currentValue: deriveOpenQuestionsDigest(questions) })) {
|
|
883
|
+
acknowledged++;
|
|
884
|
+
continue;
|
|
885
|
+
}
|
|
886
|
+
const item = {
|
|
887
|
+
phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
|
|
888
|
+
file: (0, security_cjs_1.sanitizeLabel)(file),
|
|
533
889
|
question_count: questions.length,
|
|
534
|
-
questions: questions.slice(0, 3),
|
|
535
|
-
}
|
|
890
|
+
questions: questions.slice(0, 3).map(truncateQuestionForDisplay),
|
|
891
|
+
};
|
|
892
|
+
if (target.milestone !== undefined)
|
|
893
|
+
item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
894
|
+
results.push(item);
|
|
536
895
|
}
|
|
537
896
|
}
|
|
538
|
-
return results;
|
|
897
|
+
return { items: results, acknowledged };
|
|
539
898
|
}
|
|
540
899
|
// ─── scanDeferredItems ────────────────────────────────────────────────────────
|
|
541
900
|
/**
|
|
542
|
-
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646)
|
|
901
|
+
* Scan phase directories for UNRESOLVED entries in `deferred-items.md` (#2646),
|
|
902
|
+
* across both .planning/phases (active) and .planning/milestones/vX.Y-phases
|
|
903
|
+
* (archived).
|
|
543
904
|
*
|
|
544
905
|
* The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
|
|
545
906
|
* log an out-of-scope discovery here rather than fix it. #2287 made that file
|
|
@@ -551,36 +912,32 @@ function scanContextQuestions(planDir) {
|
|
|
551
912
|
* and the entry leaves the live tree having never been triaged.
|
|
552
913
|
*
|
|
553
914
|
* The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
|
|
554
|
-
* already exports `
|
|
555
|
-
* under a `## Deferred Items` level-2 heading, else the whole file
|
|
556
|
-
*
|
|
557
|
-
*
|
|
558
|
-
*
|
|
559
|
-
*
|
|
560
|
-
*
|
|
915
|
+
* already exports `parseDeferredItemsWithStatus`, which owns the parsing rule
|
|
916
|
+
* (entries under a `## Deferred Items` level-2 heading, else the whole file
|
|
917
|
+
* fail-safe) and — unlike `parseDeferredItems` — surfaces each entry's raw
|
|
918
|
+
* `status:` field instead of filtering `resolved` internally, so THIS scanner
|
|
919
|
+
* can apply the three-way split (#3458 follow-up): `resolved` (fixed for
|
|
920
|
+
* real — dropped, never counted, matching pre-existing behavior exactly),
|
|
921
|
+
* `acknowledged` (suppressed AND tallied — the new deferred_items marker;
|
|
922
|
+
* see the module doc comment above `isAuditItemAcknowledged`), else open.
|
|
923
|
+
* Duplicating either inequality is how two readers of the same file drift
|
|
924
|
+
* into disagreeing about what "open" means. The require is deliberately
|
|
925
|
+
* LAZY, inside the scan, to preserve `audit-command-router.cts`'s property
|
|
926
|
+
* that a route never loads the module it does not need.
|
|
561
927
|
*/
|
|
562
|
-
function scanDeferredItems(planDir) {
|
|
563
|
-
const
|
|
564
|
-
if (!node_fs_1.default.existsSync(phasesDir))
|
|
565
|
-
return [];
|
|
566
|
-
let dirs;
|
|
567
|
-
try {
|
|
568
|
-
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
569
|
-
.filter(e => e.isDirectory())
|
|
570
|
-
.map(e => e.name)
|
|
571
|
-
.sort();
|
|
572
|
-
}
|
|
573
|
-
catch {
|
|
574
|
-
return [{ scan_error: true, phase: '', file: '', text: '' }];
|
|
575
|
-
}
|
|
928
|
+
function scanDeferredItems(planDir, cwd) {
|
|
929
|
+
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
576
930
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
577
931
|
const uat = require('./uat.cjs');
|
|
578
932
|
const results = [];
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
933
|
+
let acknowledged = 0;
|
|
934
|
+
if (activeUnreadable) {
|
|
935
|
+
results.push({ scan_error: true, phase: '', file: '', text: '' });
|
|
936
|
+
}
|
|
937
|
+
for (const target of targets) {
|
|
938
|
+
const phaseMatch = target.dir.match(new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i'));
|
|
939
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
940
|
+
const filePath = node_path_1.default.join(target.fullPath, DEFERRED_ITEMS_FILENAME);
|
|
584
941
|
if (!node_fs_1.default.existsSync(filePath))
|
|
585
942
|
continue;
|
|
586
943
|
let safeFilePath;
|
|
@@ -593,15 +950,25 @@ function scanDeferredItems(planDir) {
|
|
|
593
950
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
|
|
594
951
|
if (content === null)
|
|
595
952
|
continue;
|
|
596
|
-
for (const item of uat.
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
953
|
+
for (const item of uat.parseDeferredItemsWithStatus(content)) {
|
|
954
|
+
const rawStatus = (item.status || '').toLowerCase();
|
|
955
|
+
if (rawStatus === 'resolved')
|
|
956
|
+
continue; // fixed for real — never counted
|
|
957
|
+
if (rawStatus === 'acknowledged') {
|
|
958
|
+
acknowledged++;
|
|
959
|
+
continue;
|
|
960
|
+
}
|
|
961
|
+
const resultItem = {
|
|
962
|
+
phase: (0, security_cjs_1.sanitizeLabel)(phaseNum),
|
|
963
|
+
file: (0, security_cjs_1.sanitizeLabel)(DEFERRED_ITEMS_FILENAME),
|
|
600
964
|
text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
|
|
601
|
-
}
|
|
965
|
+
};
|
|
966
|
+
if (target.milestone !== undefined)
|
|
967
|
+
resultItem.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
968
|
+
results.push(resultItem);
|
|
602
969
|
}
|
|
603
970
|
}
|
|
604
|
-
return results;
|
|
971
|
+
return { items: results, acknowledged };
|
|
605
972
|
}
|
|
606
973
|
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
|
607
974
|
/**
|
|
@@ -617,7 +984,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
617
984
|
return scanDebugSessions(planDir);
|
|
618
985
|
}
|
|
619
986
|
catch {
|
|
620
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
|
987
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
|
|
621
988
|
}
|
|
622
989
|
})();
|
|
623
990
|
const quickTasks = (() => {
|
|
@@ -625,7 +992,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
625
992
|
return scanQuickTasks(planDir);
|
|
626
993
|
}
|
|
627
994
|
catch {
|
|
628
|
-
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
|
995
|
+
return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
|
|
629
996
|
}
|
|
630
997
|
})();
|
|
631
998
|
const threads = (() => {
|
|
@@ -633,7 +1000,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
633
1000
|
return scanThreads(planDir);
|
|
634
1001
|
}
|
|
635
1002
|
catch {
|
|
636
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
|
1003
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
|
|
637
1004
|
}
|
|
638
1005
|
})();
|
|
639
1006
|
const todos = (() => {
|
|
@@ -641,7 +1008,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
641
1008
|
return scanTodos(planDir);
|
|
642
1009
|
}
|
|
643
1010
|
catch {
|
|
644
|
-
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
|
1011
|
+
return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
|
|
645
1012
|
}
|
|
646
1013
|
})();
|
|
647
1014
|
const seeds = (() => {
|
|
@@ -649,70 +1016,87 @@ function auditOpenArtifacts(cwd) {
|
|
|
649
1016
|
return scanSeeds(planDir);
|
|
650
1017
|
}
|
|
651
1018
|
catch {
|
|
652
|
-
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
|
1019
|
+
return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
|
|
653
1020
|
}
|
|
654
1021
|
})();
|
|
655
1022
|
const uatGaps = (() => {
|
|
656
1023
|
try {
|
|
657
|
-
return scanUatGaps(planDir);
|
|
1024
|
+
return scanUatGaps(planDir, cwd);
|
|
658
1025
|
}
|
|
659
1026
|
catch {
|
|
660
|
-
return [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }];
|
|
1027
|
+
return { items: [{ scan_error: true, phase: '', file: '', status: '', open_scenario_count: 0 }], acknowledged: 0 };
|
|
661
1028
|
}
|
|
662
1029
|
})();
|
|
663
1030
|
const verificationGaps = (() => {
|
|
664
1031
|
try {
|
|
665
|
-
return scanVerificationGaps(planDir);
|
|
1032
|
+
return scanVerificationGaps(planDir, cwd);
|
|
666
1033
|
}
|
|
667
1034
|
catch {
|
|
668
|
-
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
|
1035
|
+
return { items: [{ scan_error: true, phase: '', file: '', status: '' }], acknowledged: 0 };
|
|
669
1036
|
}
|
|
670
1037
|
})();
|
|
671
1038
|
const contextQuestions = (() => {
|
|
672
1039
|
try {
|
|
673
|
-
return scanContextQuestions(planDir);
|
|
1040
|
+
return scanContextQuestions(planDir, cwd);
|
|
674
1041
|
}
|
|
675
1042
|
catch {
|
|
676
|
-
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
|
1043
|
+
return { items: [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }], acknowledged: 0 };
|
|
677
1044
|
}
|
|
678
1045
|
})();
|
|
679
1046
|
const deferredItems = (() => {
|
|
680
1047
|
try {
|
|
681
|
-
return scanDeferredItems(planDir);
|
|
1048
|
+
return scanDeferredItems(planDir, cwd);
|
|
682
1049
|
}
|
|
683
1050
|
catch {
|
|
684
|
-
return [{ scan_error: true, phase: '', file: '', text: '' }];
|
|
1051
|
+
return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 };
|
|
685
1052
|
}
|
|
686
1053
|
})();
|
|
687
1054
|
// Count real items (not scan_error sentinels)
|
|
688
1055
|
const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
|
689
1056
|
const counts = {
|
|
690
|
-
debug_sessions: countReal(debugSessions),
|
|
691
|
-
quick_tasks: countReal(quickTasks),
|
|
692
|
-
threads: countReal(threads),
|
|
693
|
-
todos: countReal(todos),
|
|
694
|
-
seeds: countReal(seeds),
|
|
695
|
-
uat_gaps: countReal(uatGaps),
|
|
696
|
-
verification_gaps: countReal(verificationGaps),
|
|
697
|
-
context_questions: countReal(contextQuestions),
|
|
698
|
-
deferred_items: countReal(deferredItems),
|
|
1057
|
+
debug_sessions: countReal(debugSessions.items),
|
|
1058
|
+
quick_tasks: countReal(quickTasks.items),
|
|
1059
|
+
threads: countReal(threads.items),
|
|
1060
|
+
todos: countReal(todos.items),
|
|
1061
|
+
seeds: countReal(seeds.items),
|
|
1062
|
+
uat_gaps: countReal(uatGaps.items),
|
|
1063
|
+
verification_gaps: countReal(verificationGaps.items),
|
|
1064
|
+
context_questions: countReal(contextQuestions.items),
|
|
1065
|
+
deferred_items: countReal(deferredItems.items),
|
|
699
1066
|
total: 0,
|
|
700
1067
|
};
|
|
701
1068
|
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;
|
|
1069
|
+
// #3458 follow-up (A5): mirrors `counts`'s shape exactly, so a reviewer can
|
|
1070
|
+
// tell "clean because fixed" apart from "clean because silenced" without a
|
|
1071
|
+
// second output contract to learn.
|
|
1072
|
+
const acknowledged = {
|
|
1073
|
+
debug_sessions: debugSessions.acknowledged,
|
|
1074
|
+
quick_tasks: quickTasks.acknowledged,
|
|
1075
|
+
threads: threads.acknowledged,
|
|
1076
|
+
todos: todos.acknowledged,
|
|
1077
|
+
seeds: seeds.acknowledged,
|
|
1078
|
+
uat_gaps: uatGaps.acknowledged,
|
|
1079
|
+
verification_gaps: verificationGaps.acknowledged,
|
|
1080
|
+
context_questions: contextQuestions.acknowledged,
|
|
1081
|
+
deferred_items: deferredItems.acknowledged,
|
|
1082
|
+
total: 0,
|
|
1083
|
+
};
|
|
1084
|
+
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;
|
|
702
1085
|
return {
|
|
703
1086
|
scanned_at: new Date().toISOString(),
|
|
704
1087
|
has_open_items: counts.total > 0,
|
|
705
1088
|
counts,
|
|
1089
|
+
acknowledged,
|
|
706
1090
|
items: {
|
|
707
|
-
debug_sessions: debugSessions,
|
|
708
|
-
quick_tasks: quickTasks,
|
|
709
|
-
threads,
|
|
710
|
-
todos,
|
|
711
|
-
seeds,
|
|
712
|
-
uat_gaps: uatGaps,
|
|
713
|
-
verification_gaps: verificationGaps,
|
|
714
|
-
context_questions: contextQuestions,
|
|
715
|
-
deferred_items: deferredItems,
|
|
1091
|
+
debug_sessions: debugSessions.items,
|
|
1092
|
+
quick_tasks: quickTasks.items,
|
|
1093
|
+
threads: threads.items,
|
|
1094
|
+
todos: todos.items,
|
|
1095
|
+
seeds: seeds.items,
|
|
1096
|
+
uat_gaps: uatGaps.items,
|
|
1097
|
+
verification_gaps: verificationGaps.items,
|
|
1098
|
+
context_questions: contextQuestions.items,
|
|
1099
|
+
deferred_items: deferredItems.items,
|
|
716
1100
|
},
|
|
717
1101
|
};
|
|
718
1102
|
}
|
|
@@ -724,23 +1108,36 @@ function auditOpenArtifacts(cwd) {
|
|
|
724
1108
|
* @returns Formatted report
|
|
725
1109
|
*/
|
|
726
1110
|
function formatAuditReport(auditResult) {
|
|
727
|
-
const { counts, items, has_open_items } = auditResult;
|
|
1111
|
+
const { counts, items, has_open_items, acknowledged } = auditResult;
|
|
728
1112
|
const lines = [];
|
|
729
1113
|
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
|
730
1114
|
lines.push(hr);
|
|
731
1115
|
lines.push(' Milestone Close: Open Artifact Audit');
|
|
732
1116
|
lines.push(hr);
|
|
1117
|
+
// WARNING 3 (#3458 follow-up review): the acknowledged tally previously
|
|
1118
|
+
// existed only in `--json` output — the human report could not tell
|
|
1119
|
+
// "clean because fixed" apart from "clean because silenced", which is the
|
|
1120
|
+
// exact distinction the acknowledged/counts split exists to preserve.
|
|
733
1121
|
if (!has_open_items) {
|
|
734
1122
|
lines.push('');
|
|
735
|
-
|
|
1123
|
+
if (acknowledged.total > 0) {
|
|
1124
|
+
lines.push(` All artifact types clear (${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} still suppressed).`);
|
|
1125
|
+
}
|
|
1126
|
+
else {
|
|
1127
|
+
lines.push(' All artifact types clear. Safe to proceed.');
|
|
1128
|
+
}
|
|
736
1129
|
lines.push('');
|
|
737
1130
|
lines.push(hr);
|
|
738
1131
|
return lines.join('\n');
|
|
739
1132
|
}
|
|
1133
|
+
// WARNING 3: per-category "N previously acknowledged" suffix, so the
|
|
1134
|
+
// human report carries the same "clean vs silenced" signal `--json`
|
|
1135
|
+
// already did via `acknowledged`.
|
|
1136
|
+
const ackSuffix = (n) => (n > 0 ? `, ${n} previously acknowledged` : '');
|
|
740
1137
|
// Debug sessions (blocking quality — red)
|
|
741
1138
|
if (counts.debug_sessions > 0) {
|
|
742
1139
|
lines.push('');
|
|
743
|
-
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
|
|
1140
|
+
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open${ackSuffix(acknowledged.debug_sessions)})`);
|
|
744
1141
|
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
|
|
745
1142
|
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
|
|
746
1143
|
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
|
|
@@ -749,23 +1146,25 @@ function formatAuditReport(auditResult) {
|
|
|
749
1146
|
// UAT gaps (blocking quality — red)
|
|
750
1147
|
if (counts.uat_gaps > 0) {
|
|
751
1148
|
lines.push('');
|
|
752
|
-
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT)`);
|
|
1149
|
+
lines.push(`🔴 UAT Gaps (${counts.uat_gaps} phases with incomplete UAT${ackSuffix(acknowledged.uat_gaps)})`);
|
|
753
1150
|
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
|
|
754
|
-
|
|
1151
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1152
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}] — ${item.open_scenario_count} pending scenarios`);
|
|
755
1153
|
}
|
|
756
1154
|
}
|
|
757
1155
|
// Verification gaps (blocking quality — red)
|
|
758
1156
|
if (counts.verification_gaps > 0) {
|
|
759
1157
|
lines.push('');
|
|
760
|
-
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
|
|
1158
|
+
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved${ackSuffix(acknowledged.verification_gaps)})`);
|
|
761
1159
|
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
|
|
762
|
-
|
|
1160
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1161
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}]`);
|
|
763
1162
|
}
|
|
764
1163
|
}
|
|
765
1164
|
// Quick tasks (incomplete work — yellow)
|
|
766
1165
|
if (counts.quick_tasks > 0) {
|
|
767
1166
|
lines.push('');
|
|
768
|
-
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
|
|
1167
|
+
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete${ackSuffix(acknowledged.quick_tasks)})`);
|
|
769
1168
|
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
|
|
770
1169
|
const d = item.date ? ` (${item.date})` : '';
|
|
771
1170
|
lines.push(` • ${item.slug}${d} [${item.status}]`);
|
|
@@ -776,7 +1175,7 @@ function formatAuditReport(auditResult) {
|
|
|
776
1175
|
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
|
|
777
1176
|
const remainder = items.todos.find(i => i._remainder_count);
|
|
778
1177
|
lines.push('');
|
|
779
|
-
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
|
|
1178
|
+
lines.push(`🟡 Pending Todos (${counts.todos} pending${ackSuffix(acknowledged.todos)})`);
|
|
780
1179
|
for (const item of realTodos) {
|
|
781
1180
|
const area = item.area ? ` [${item.area}]` : '';
|
|
782
1181
|
const pri = item.priority ? ` (${item.priority})` : '';
|
|
@@ -791,7 +1190,7 @@ function formatAuditReport(auditResult) {
|
|
|
791
1190
|
// Threads (deferred decisions — blue)
|
|
792
1191
|
if (counts.threads > 0) {
|
|
793
1192
|
lines.push('');
|
|
794
|
-
lines.push(`🔵 Open Threads (${counts.threads} active)`);
|
|
1193
|
+
lines.push(`🔵 Open Threads (${counts.threads} active${ackSuffix(acknowledged.threads)})`);
|
|
795
1194
|
for (const item of items.threads.filter(i => !i.scan_error)) {
|
|
796
1195
|
const title = item.title ? ` — ${item.title}` : '';
|
|
797
1196
|
lines.push(` • ${item.slug} [${item.status}]${title}`);
|
|
@@ -800,7 +1199,7 @@ function formatAuditReport(auditResult) {
|
|
|
800
1199
|
// Seeds (deferred decisions — blue)
|
|
801
1200
|
if (counts.seeds > 0) {
|
|
802
1201
|
lines.push('');
|
|
803
|
-
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
|
|
1202
|
+
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending${ackSuffix(acknowledged.seeds)})`);
|
|
804
1203
|
for (const item of items.seeds.filter(i => !i.scan_error)) {
|
|
805
1204
|
const title = item.title ? ` — ${item.title}` : '';
|
|
806
1205
|
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
|
|
@@ -809,9 +1208,10 @@ function formatAuditReport(auditResult) {
|
|
|
809
1208
|
// Context questions (deferred decisions — blue)
|
|
810
1209
|
if (counts.context_questions > 0) {
|
|
811
1210
|
lines.push('');
|
|
812
|
-
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions)`);
|
|
1211
|
+
lines.push(`🔵 CONTEXT Open Questions (${counts.context_questions} phases with open questions${ackSuffix(acknowledged.context_questions)})`);
|
|
813
1212
|
for (const item of items.context_questions.filter(i => !i.scan_error)) {
|
|
814
|
-
|
|
1213
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1214
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.file} (${item.question_count} question${item.question_count !== 1 ? 's' : ''})`);
|
|
815
1215
|
for (const q of item.questions) {
|
|
816
1216
|
lines.push(` - ${q}`);
|
|
817
1217
|
}
|
|
@@ -821,15 +1221,251 @@ function formatAuditReport(auditResult) {
|
|
|
821
1221
|
// phase agent recorded rather than fixed, still unresolved at close (#2646).
|
|
822
1222
|
if (counts.deferred_items > 0) {
|
|
823
1223
|
lines.push('');
|
|
824
|
-
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved)`);
|
|
1224
|
+
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved${ackSuffix(acknowledged.deferred_items)})`);
|
|
825
1225
|
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
|
|
826
|
-
|
|
1226
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1227
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.text}`);
|
|
827
1228
|
}
|
|
828
1229
|
}
|
|
829
1230
|
lines.push('');
|
|
830
1231
|
lines.push(hr);
|
|
831
1232
|
lines.push(` ${counts.total} item${counts.total !== 1 ? 's' : ''} require decisions before close.`);
|
|
1233
|
+
if (acknowledged.total > 0) {
|
|
1234
|
+
lines.push(` ${acknowledged.total} previously acknowledged item${acknowledged.total !== 1 ? 's' : ''} also suppressed above the ${counts.total} open item${counts.total !== 1 ? 's' : ''}.`);
|
|
1235
|
+
}
|
|
832
1236
|
lines.push(hr);
|
|
833
1237
|
return lines.join('\n');
|
|
834
1238
|
}
|
|
835
|
-
|
|
1239
|
+
// ─── resolvePhaseTargetDir ─────────────────────────────────────────────────────
|
|
1240
|
+
/**
|
|
1241
|
+
* Resolve ONE phase directory (active or archived) by its phase token, for
|
|
1242
|
+
* `cmdAuditAcknowledge`'s `--phase [--archived-milestone]` identification of
|
|
1243
|
+
* a uat_gaps/verification_gaps/context_questions/deferred_items item. Built
|
|
1244
|
+
* on `listAuditPhaseTargets` — the same enumeration the four phase-scoped
|
|
1245
|
+
* scanners use — so the writer can never resolve a DIFFERENT directory than
|
|
1246
|
+
* the one the audit actually scanned.
|
|
1247
|
+
*
|
|
1248
|
+
* `archivedMilestone` absent → matches the ACTIVE `.planning/phases/<dir>`
|
|
1249
|
+
* (a target with no `milestone`). Present → matches the archived target
|
|
1250
|
+
* whose `milestone` equals it exactly — the same disambiguator the audit
|
|
1251
|
+
* output's `archived_milestone` field carries.
|
|
1252
|
+
*/
|
|
1253
|
+
function resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone) {
|
|
1254
|
+
const { targets } = listAuditPhaseTargets(planDir, cwd);
|
|
1255
|
+
const phaseTokenRe = new RegExp(`^(${PHASE_NUMBER_TOKEN_SOURCE})`, 'i');
|
|
1256
|
+
for (const target of targets) {
|
|
1257
|
+
const phaseMatch = target.dir.match(phaseTokenRe);
|
|
1258
|
+
const phaseNum = phaseMatch ? phaseMatch[1] : target.dir;
|
|
1259
|
+
if (phaseNum !== phase)
|
|
1260
|
+
continue;
|
|
1261
|
+
if (archivedMilestone) {
|
|
1262
|
+
if (target.milestone === archivedMilestone)
|
|
1263
|
+
return target.fullPath;
|
|
1264
|
+
}
|
|
1265
|
+
else if (target.milestone === undefined) {
|
|
1266
|
+
return target.fullPath;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
return null;
|
|
1270
|
+
}
|
|
1271
|
+
// ─── cmdAuditAcknowledge ────────────────────────────────────────────────────────
|
|
1272
|
+
/**
|
|
1273
|
+
* CLI writer for the #3458 follow-up suppression seam (design point A4). Sets
|
|
1274
|
+
* (or refreshes) the `audit_acknowledged` marker on ONE identified artifact,
|
|
1275
|
+
* snapshotting its CURRENT effective state itself so the marker is never
|
|
1276
|
+
* hand-authored and can never drift from what the scanners actually compute.
|
|
1277
|
+
*
|
|
1278
|
+
* `--category` selects which of the nine audit categories is being
|
|
1279
|
+
* acknowledged, and which OTHER flags are required to identify the artifact —
|
|
1280
|
+
* mirroring the fields the audit's OWN JSON output already carries per
|
|
1281
|
+
* category (phase/file/archived_milestone for the four phase-scoped
|
|
1282
|
+
* categories; slug/seed-id/dir/filename for the five flat ones), the same
|
|
1283
|
+
* convention `frontmatter get/set/merge/validate` uses for `--file`/`--field`.
|
|
1284
|
+
*
|
|
1285
|
+
* VERDICT-PRESERVING: this function never writes to the artifact's own
|
|
1286
|
+
* `status:` field (the audit's real verdict) for the 8 frontmatter-marker
|
|
1287
|
+
* categories — only the sibling `audit_acknowledged` map. `deferred_items` is
|
|
1288
|
+
* the sole, deliberate exception (see `uat.cts`'s `acknowledgeDeferredItem`):
|
|
1289
|
+
* there, the marker IS the entry's own `status:` field, because a
|
|
1290
|
+
* deferred-items.md entry carries no OTHER meaning for that field.
|
|
1291
|
+
*
|
|
1292
|
+
* Every path this function writes is routed through `requireSafePath`, so an
|
|
1293
|
+
* artifact identifier that resolves outside the project is refused before
|
|
1294
|
+
* any read or write is attempted.
|
|
1295
|
+
*/
|
|
1296
|
+
function cmdAuditAcknowledge(cwd, args, raw) {
|
|
1297
|
+
const { category, milestone, at: atFlag, phase, file, 'archived-milestone': archivedMilestone, slug, 'seed-id': seedId, dir: quickDir, filename, text, } = (0, command_arg_projection_cjs_1.parseNamedArgs)(args, [
|
|
1298
|
+
'category', 'milestone', 'at',
|
|
1299
|
+
'phase', 'file', 'archived-milestone',
|
|
1300
|
+
'slug', 'seed-id', 'dir', 'filename', 'text',
|
|
1301
|
+
]);
|
|
1302
|
+
if (!category)
|
|
1303
|
+
ioError('--category is required');
|
|
1304
|
+
if (!milestone)
|
|
1305
|
+
ioError('--milestone is required');
|
|
1306
|
+
const at = atFlag || new Date().toISOString().slice(0, 10);
|
|
1307
|
+
const planDir = planningDir(cwd);
|
|
1308
|
+
const markerBase = { milestone: milestone, at };
|
|
1309
|
+
// ── The four phase-scoped categories: --phase --file [--archived-milestone] ──
|
|
1310
|
+
const PHASE_SCOPED = new Set(['uat_gaps', 'verification_gaps', 'context_questions', 'deferred_items']);
|
|
1311
|
+
if (PHASE_SCOPED.has(category)) {
|
|
1312
|
+
if (!phase)
|
|
1313
|
+
ioError('--phase is required for this --category');
|
|
1314
|
+
if (!file)
|
|
1315
|
+
ioError('--file is required for this --category');
|
|
1316
|
+
const targetDir = resolvePhaseTargetDir(planDir, cwd, phase, archivedMilestone);
|
|
1317
|
+
if (!targetDir) {
|
|
1318
|
+
ioError(`no phase directory found for phase "${phase}"${archivedMilestone ? ` (archived-milestone "${archivedMilestone}")` : ''}`);
|
|
1319
|
+
}
|
|
1320
|
+
const filePath = node_path_1.default.join(targetDir, file);
|
|
1321
|
+
const safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1322
|
+
if (!node_fs_1.default.existsSync(safeFilePath))
|
|
1323
|
+
ioError(`file not found: ${file}`);
|
|
1324
|
+
if (category === 'deferred_items') {
|
|
1325
|
+
if (!text)
|
|
1326
|
+
ioError('--text is required for --category deferred_items');
|
|
1327
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
1328
|
+
const uat = require('./uat.cjs');
|
|
1329
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1330
|
+
const result = uat.acknowledgeDeferredItem(content, text);
|
|
1331
|
+
if (result.status === 'not_found')
|
|
1332
|
+
ioError(`no deferred item matched --text "${text}"`);
|
|
1333
|
+
if (result.status === 'ambiguous')
|
|
1334
|
+
ioError(`--text "${text}" matches more than one deferred item — text must be unique`);
|
|
1335
|
+
if (result.status === 'already_resolved')
|
|
1336
|
+
ioError(`deferred item is already "status: resolved" — acknowledging a resolved item is a no-op`);
|
|
1337
|
+
if (result.status === 'unsupported_heading_shape') {
|
|
1338
|
+
ioError('this deferred-items.md uses the heading-delimited (#3457) entry shape, which the CLI writer does not yet support — edit the file directly');
|
|
1339
|
+
}
|
|
1340
|
+
if (result.status === 'match_verification_failed') {
|
|
1341
|
+
ioError(`internal error: matched span for --text "${text}" did not re-verify before write — refused rather than risk writing the wrong entry`);
|
|
1342
|
+
}
|
|
1343
|
+
(0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, result.content);
|
|
1344
|
+
output({ acknowledged: true, category, phase, file, text }, raw, 'true');
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1348
|
+
const fm = extractFrontmatter(content, safeFilePath);
|
|
1349
|
+
let snapshotKey;
|
|
1350
|
+
let currentValue;
|
|
1351
|
+
if (category === 'uat_gaps') {
|
|
1352
|
+
// WARNING 2 (#3458 follow-up review): status alone can't see MORE
|
|
1353
|
+
// pending scenarios added under the same status — snapshot the
|
|
1354
|
+
// composite `deriveUatGapSnapshotValue` instead (see its doc comment).
|
|
1355
|
+
snapshotKey = 'gap_snapshot';
|
|
1356
|
+
currentValue = deriveUatGapSnapshotValue((fm.status || 'unknown').toLowerCase(), content);
|
|
1357
|
+
}
|
|
1358
|
+
else if (category === 'verification_gaps') {
|
|
1359
|
+
snapshotKey = 'status';
|
|
1360
|
+
currentValue = (fm.status || 'unknown').toLowerCase();
|
|
1361
|
+
}
|
|
1362
|
+
else {
|
|
1363
|
+
// context_questions — WARNING 2: snapshot a content digest of the
|
|
1364
|
+
// question set, not just its count (see `deriveOpenQuestionsDigest`'s
|
|
1365
|
+
// doc comment).
|
|
1366
|
+
snapshotKey = 'questions_digest';
|
|
1367
|
+
currentValue = deriveOpenQuestionsDigest(deriveOpenQuestions(content, fm));
|
|
1368
|
+
}
|
|
1369
|
+
fm.audit_acknowledged = { ...markerBase, [snapshotKey]: currentValue };
|
|
1370
|
+
const newContent = spliceFrontmatter(content, fm);
|
|
1371
|
+
(0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
|
|
1372
|
+
output({ acknowledged: true, category, phase, file, [snapshotKey]: currentValue }, raw, 'true');
|
|
1373
|
+
return;
|
|
1374
|
+
}
|
|
1375
|
+
// ── The five flat categories: category-specific identifier flag ──
|
|
1376
|
+
// `status` for all five per the architecture's per-category table (`todos`
|
|
1377
|
+
// is presence-only and never reads `snapshotKey`, so it stays a constant).
|
|
1378
|
+
const snapshotKey = 'status';
|
|
1379
|
+
let safeFilePath;
|
|
1380
|
+
let currentValue;
|
|
1381
|
+
let createIfMissing = false;
|
|
1382
|
+
// Same value shape `Frontmatter`/`extractFrontmatter` use (frontmatter.cts
|
|
1383
|
+
// does not export the `Frontmatter` type name itself, so it is spelled out
|
|
1384
|
+
// structurally here) — keeps this and `extractFrontmatter`'s return type
|
|
1385
|
+
// unifying to the SAME type below instead of a lossy `Record<string,
|
|
1386
|
+
// unknown>` that `spliceFrontmatter`'s `Frontmatter` parameter would reject.
|
|
1387
|
+
let fmForCreate = {};
|
|
1388
|
+
if (category === 'debug_sessions') {
|
|
1389
|
+
if (!slug)
|
|
1390
|
+
ioError('--slug is required for --category debug_sessions');
|
|
1391
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'debug', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1392
|
+
if (!node_fs_1.default.existsSync(safeFilePath))
|
|
1393
|
+
ioError(`file not found: debug/${slug}.md`);
|
|
1394
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1395
|
+
currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
|
|
1396
|
+
}
|
|
1397
|
+
else if (category === 'threads') {
|
|
1398
|
+
if (!slug)
|
|
1399
|
+
ioError('--slug is required for --category threads');
|
|
1400
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'threads', `${slug}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1401
|
+
if (!node_fs_1.default.existsSync(safeFilePath))
|
|
1402
|
+
ioError(`file not found: threads/${slug}.md`);
|
|
1403
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1404
|
+
currentValue = deriveThreadStatus(extractFrontmatter(content, safeFilePath), content);
|
|
1405
|
+
}
|
|
1406
|
+
else if (category === 'seeds') {
|
|
1407
|
+
if (!seedId)
|
|
1408
|
+
ioError('--seed-id is required for --category seeds');
|
|
1409
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'seeds', `${seedId}.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1410
|
+
if (!node_fs_1.default.existsSync(safeFilePath))
|
|
1411
|
+
ioError(`file not found: seeds/${seedId}.md`);
|
|
1412
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1413
|
+
currentValue = (extractFrontmatter(content, safeFilePath).status || 'dormant').toLowerCase();
|
|
1414
|
+
}
|
|
1415
|
+
else if (category === 'todos') {
|
|
1416
|
+
if (!filename)
|
|
1417
|
+
ioError('--filename is required for --category todos');
|
|
1418
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'todos', 'pending', filename), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1419
|
+
if (!node_fs_1.default.existsSync(safeFilePath))
|
|
1420
|
+
ioError(`file not found: todos/pending/${filename}`);
|
|
1421
|
+
currentValue = ''; // presence-only — see scanTodos
|
|
1422
|
+
}
|
|
1423
|
+
else if (category === 'quick_tasks') {
|
|
1424
|
+
if (!quickDir)
|
|
1425
|
+
ioError('--dir is required for --category quick_tasks');
|
|
1426
|
+
const taskDir = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(planDir, 'quick', quickDir), planDir, 'audit acknowledge target dir', { allowAbsolute: true });
|
|
1427
|
+
if (!node_fs_1.default.existsSync(taskDir))
|
|
1428
|
+
ioError(`directory not found: quick/${quickDir}`);
|
|
1429
|
+
// Shared with scanQuickTasks (#3458 follow-up) so the reader and this
|
|
1430
|
+
// writer can never disagree about which file is the task's record.
|
|
1431
|
+
const resolvedSummaryPath = resolveQuickTaskSummaryFile(taskDir, quickDir);
|
|
1432
|
+
if (resolvedSummaryPath) {
|
|
1433
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(resolvedSummaryPath, planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1434
|
+
const content = node_fs_1.default.readFileSync(safeFilePath, 'utf-8');
|
|
1435
|
+
currentValue = (extractFrontmatter(content, safeFilePath).status || 'unknown').toLowerCase();
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
// No SUMMARY.md at all — the audit's own observed status is 'missing'.
|
|
1439
|
+
// There is nowhere to carry the marker, so create the canonical
|
|
1440
|
+
// `${dir}-SUMMARY.md` with ONLY `status: missing` + the marker — the
|
|
1441
|
+
// acknowledgment's own snapshot of "no summary exists yet", which
|
|
1442
|
+
// self-invalidates the moment a real SUMMARY.md is written (the
|
|
1443
|
+
// scanner then reads THAT file's own status instead).
|
|
1444
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(taskDir, `${quickDir}-SUMMARY.md`), planDir, 'audit acknowledge target', { allowAbsolute: true });
|
|
1445
|
+
currentValue = 'missing';
|
|
1446
|
+
createIfMissing = true;
|
|
1447
|
+
fmForCreate = { status: 'missing' };
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
else {
|
|
1451
|
+
ioError(`unknown --category "${category}". Available: debug_sessions, quick_tasks, threads, todos, seeds, uat_gaps, verification_gaps, context_questions, deferred_items`);
|
|
1452
|
+
return; // unreachable — ioError throws — satisfies TS control-flow analysis
|
|
1453
|
+
}
|
|
1454
|
+
const presenceOnly = category === 'todos';
|
|
1455
|
+
const fm = createIfMissing ? fmForCreate : extractFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), safeFilePath);
|
|
1456
|
+
fm.audit_acknowledged = presenceOnly ? { ...markerBase } : { ...markerBase, [snapshotKey]: currentValue };
|
|
1457
|
+
const newContent = createIfMissing
|
|
1458
|
+
? spliceFrontmatter('', fm)
|
|
1459
|
+
: spliceFrontmatter(node_fs_1.default.readFileSync(safeFilePath, 'utf-8'), fm);
|
|
1460
|
+
(0, shell_command_projection_cjs_2.platformWriteSync)(safeFilePath, newContent);
|
|
1461
|
+
output({ acknowledged: true, category, ...(presenceOnly ? {} : { [snapshotKey]: currentValue }) }, raw, 'true');
|
|
1462
|
+
}
|
|
1463
|
+
module.exports = {
|
|
1464
|
+
auditOpenArtifacts,
|
|
1465
|
+
formatAuditReport,
|
|
1466
|
+
listAuditPhaseTargets,
|
|
1467
|
+
cmdAuditAcknowledge,
|
|
1468
|
+
// #2142: exported so src/milestone.cts's archiveQuickTaskDirectories README
|
|
1469
|
+
// index generator shares this ONE discovery rule rather than re-deriving it.
|
|
1470
|
+
resolveQuickTaskSummaryFile,
|
|
1471
|
+
};
|