@opengsd/gsd-core 1.9.1 → 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 +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +27 -3
- package/agents/gsd-debug-session-manager.md +11 -0
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-executor.md +12 -10
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-mempalace-curator.md +5 -2
- package/agents/gsd-phase-researcher.md +20 -1
- package/agents/gsd-plan-checker.md +46 -0
- package/agents/gsd-planner.md +49 -54
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +26 -73
- package/bin/install.js +1272 -1238
- 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 +2 -2
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +2 -2
- package/commands/gsd/plan-phase.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 +1009 -115
- package/gsd-core/bin/lib/active-workstream-store.cjs +153 -12
- package/gsd-core/bin/lib/agent-install-check.cjs +268 -38
- package/gsd-core/bin/lib/api-coverage.cjs +123 -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 +926 -202
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- 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 +608 -148
- 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 +507 -24
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +114 -38
- 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 +94 -0
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/commands.cjs +665 -99
- package/gsd-core/bin/lib/commonjs-marker.cjs +142 -0
- package/gsd-core/bin/lib/complexity-trigger.cjs +1172 -0
- package/gsd-core/bin/lib/config-loader.cjs +76 -0
- package/gsd-core/bin/lib/config.cjs +22 -2
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +217 -40
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +239 -32
- package/gsd-core/bin/lib/gap-checker.cjs +68 -7
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +160 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +37 -5
- 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-integration.cjs +13 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1325 -169
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +805 -264
- 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 +160 -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-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +206 -13
- package/gsd-core/bin/lib/io.cjs +38 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +8 -1
- package/gsd-core/bin/lib/markdown-table.cjs +133 -20
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +821 -109
- 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 +507 -36
- package/gsd-core/bin/lib/phase-lifecycle.cjs +28 -3
- package/gsd-core/bin/lib/phase-locator.cjs +258 -58
- package/gsd-core/bin/lib/phase.cjs +891 -156
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +303 -0
- 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 +60 -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/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +740 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +85 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +108 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +447 -68
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +76 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +1035 -194
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +405 -84
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +795 -100
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +440 -57
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +220 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +220 -44
- 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/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +388 -30
- package/gsd-core/bin/lib/smart-entry.cjs +154 -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 +424 -105
- package/gsd-core/bin/lib/state.cjs +1927 -401
- package/gsd-core/bin/lib/surface.cjs +35 -10
- 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 +20 -4
- package/gsd-core/bin/lib/uat.cjs +706 -64
- 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 +33 -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 +287 -20
- package/gsd-core/bin/lib/verify.cjs +368 -880
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +203 -19
- package/gsd-core/bin/lib/workstream-inventory.cjs +576 -31
- package/gsd-core/bin/lib/workstream.cjs +8 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +450 -125
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -1
- package/gsd-core/references/agent-contracts.md +43 -26
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/checkpoints.md +2 -2
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/debugger-techniques.md +255 -0
- 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/research-documentation-lookup.md +5 -3
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +8 -7
- package/gsd-core/references/universal-anti-patterns.md +3 -3
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +22 -6
- package/gsd-core/references/worktree-branch-check.md +2 -2
- 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/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/ai-integration-phase.md +9 -11
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +33 -70
- package/gsd-core/workflows/cleanup.md +62 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +93 -0
- package/gsd-core/workflows/code-review-fix.md +37 -10
- package/gsd-core/workflows/code-review.md +74 -166
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +160 -95
- package/gsd-core/workflows/debug.md +16 -17
- package/gsd-core/workflows/diagnose-issues.md +56 -8
- 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/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +7 -17
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- 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 +64 -7
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- 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 +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +103 -187
- 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/help/modes/full.md +6 -1
- package/gsd-core/workflows/import.md +4 -4
- package/gsd-core/workflows/ingest-docs.md +7 -6
- package/gsd-core/workflows/mvp-phase.md +6 -3
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +35 -47
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +27 -240
- package/gsd-core/workflows/next.md +12 -0
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +89 -209
- package/gsd-core/workflows/plan-review-convergence.md +50 -2
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +45 -159
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +67 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +55 -405
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +41 -13
- package/gsd-core/workflows/section-manifest.json +219 -0
- 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/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +41 -20
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +106 -13
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +53 -31
- package/gsd-core/workflows/ui-phase.md +13 -12
- package/gsd-core/workflows/ui-review.md +2 -2
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +19 -8
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +17 -65
- package/hooks/dist/gsd-agent-isolation-guard.js +517 -0
- package/hooks/dist/gsd-check-update-worker.js +64 -12
- 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 +607 -26
- 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 +45 -24
- package/hooks/dist/gsd-statusline.js +90 -6
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-workflow-guard.js +134 -36
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- 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 +277 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +517 -0
- package/hooks/gsd-check-update-worker.js +64 -12
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-cursor-pre-tool.js +0 -3
- package/hooks/gsd-cursor-subagent-start.js +607 -26
- package/hooks/gsd-cursor-subagent-stop.js +3 -2
- package/hooks/gsd-prompt-guard.js +21 -20
- package/hooks/gsd-read-injection-scanner.js +45 -24
- package/hooks/gsd-statusline.js +90 -6
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-workflow-guard.js +134 -36
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- 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 +277 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +31 -10
- package/pi/gsd.cjs +71 -12
- 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/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +68 -6
- package/scripts/changeset/serialize.cjs +5 -1
- 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-matrix.cjs +1 -1
- package/scripts/gen-capability-registry.cjs +3 -15
- package/scripts/gen-context-index.cjs +439 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-inventory-manifest.cjs +150 -4
- package/scripts/gen-loop-host-contract.cjs +4 -24
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- 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 +15 -54
- 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-compiled-artifact-sync.cjs +6 -1
- 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-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-eslint-glob-coverage.allowlist.json +34 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/lint-example-parser-parity.cjs +395 -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 +40 -3
- package/scripts/lint-unreachable-guard-drift.cjs +843 -0
- package/scripts/lint-vendored-deps.cjs +124 -0
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +52 -6
- 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 +2 -2
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +2 -2
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +1 -2
- 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 -577
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/gen-emitted-baseline.cjs +0 -145
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1050
|
@@ -16,22 +16,237 @@ 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");
|
|
41
|
+
// The SCOPE BOUNDARY convention's filename (`agents/gsd-executor.md`), shared
|
|
42
|
+
// verbatim with the #2287 phase-boundary reader in `uat.cts`.
|
|
43
|
+
const DEFERRED_ITEMS_FILENAME = 'deferred-items.md';
|
|
31
44
|
// Terminal UAT states: `complete` (legacy) and `resolved` (post-gap-closure
|
|
32
45
|
// per workflows/execute-phase.md). Hoisted outside scanUatGaps so the Set is
|
|
33
46
|
// not recreated on each loop iteration.
|
|
34
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
|
+
}
|
|
35
250
|
// ─── scanDebugSessions ────────────────────────────────────────────────────────
|
|
36
251
|
/**
|
|
37
252
|
* Scan .planning/debug/ for open sessions.
|
|
@@ -41,14 +256,15 @@ const TERMINAL_UAT_STATUSES = new Set(['complete', 'resolved']);
|
|
|
41
256
|
function scanDebugSessions(planDir) {
|
|
42
257
|
const debugDir = node_path_1.default.join(planDir, 'debug');
|
|
43
258
|
if (!node_fs_1.default.existsSync(debugDir))
|
|
44
|
-
return [];
|
|
259
|
+
return { items: [], acknowledged: 0 };
|
|
45
260
|
const results = [];
|
|
261
|
+
let acknowledged = 0;
|
|
46
262
|
let files;
|
|
47
263
|
try {
|
|
48
264
|
files = node_fs_1.default.readdirSync(debugDir, { withFileTypes: true });
|
|
49
265
|
}
|
|
50
266
|
catch {
|
|
51
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
|
267
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
|
|
52
268
|
}
|
|
53
269
|
for (const entry of files) {
|
|
54
270
|
if (!entry.isFile())
|
|
@@ -70,6 +286,10 @@ function scanDebugSessions(planDir) {
|
|
|
70
286
|
const status = (fm.status || 'unknown').toLowerCase();
|
|
71
287
|
if (status === 'resolved' || status === 'complete')
|
|
72
288
|
continue;
|
|
289
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
290
|
+
acknowledged++;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
73
293
|
// Extract hypothesis from "Current Focus" block if parseable
|
|
74
294
|
let hypothesis = '';
|
|
75
295
|
const focusSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('current focus'), { levelBounded: true });
|
|
@@ -79,13 +299,55 @@ function scanDebugSessions(planDir) {
|
|
|
79
299
|
}
|
|
80
300
|
const slug = node_path_1.default.basename(entry.name, '.md');
|
|
81
301
|
results.push({
|
|
82
|
-
slug: (0, security_cjs_1.
|
|
302
|
+
slug: (0, security_cjs_1.sanitizeLabel)(slug),
|
|
83
303
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
84
304
|
updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
|
|
85
305
|
hypothesis,
|
|
86
306
|
});
|
|
87
307
|
}
|
|
88
|
-
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);
|
|
89
351
|
}
|
|
90
352
|
// ─── scanQuickTasks ───────────────────────────────────────────────────────────
|
|
91
353
|
/**
|
|
@@ -93,17 +355,20 @@ function scanDebugSessions(planDir) {
|
|
|
93
355
|
* Incomplete if SUMMARY.md missing or status !== 'complete'.
|
|
94
356
|
*/
|
|
95
357
|
function scanQuickTasks(planDir) {
|
|
96
|
-
|
|
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);
|
|
97
361
|
if (!node_fs_1.default.existsSync(quickDir))
|
|
98
|
-
return [];
|
|
362
|
+
return { items: [], acknowledged: 0 };
|
|
99
363
|
let entries;
|
|
100
364
|
try {
|
|
101
365
|
entries = node_fs_1.default.readdirSync(quickDir, { withFileTypes: true });
|
|
102
366
|
}
|
|
103
367
|
catch {
|
|
104
|
-
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
|
368
|
+
return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
|
|
105
369
|
}
|
|
106
370
|
const results = [];
|
|
371
|
+
let acknowledged = 0;
|
|
107
372
|
for (const entry of entries) {
|
|
108
373
|
if (!entry.isDirectory())
|
|
109
374
|
continue;
|
|
@@ -116,25 +381,10 @@ function scanQuickTasks(planDir) {
|
|
|
116
381
|
catch {
|
|
117
382
|
continue;
|
|
118
383
|
}
|
|
119
|
-
|
|
120
|
-
// bare `SUMMARY.md`. Accept either to avoid false-positive "missing".
|
|
121
|
-
let summaryPath = null;
|
|
122
|
-
try {
|
|
123
|
-
const summaryFiles = node_fs_1.default.readdirSync(safeTaskDir, { withFileTypes: true })
|
|
124
|
-
.filter(e => e.isFile() && (e.name === 'SUMMARY.md' || e.name.endsWith('-SUMMARY.md')));
|
|
125
|
-
if (summaryFiles.length > 0) {
|
|
126
|
-
// Prefer the per-task `${quick_id}-SUMMARY.md` form when present.
|
|
127
|
-
const preferred = summaryFiles.find(e => e.name === `${dirName}-SUMMARY.md`)
|
|
128
|
-
|| summaryFiles.find(e => e.name.endsWith('-SUMMARY.md'))
|
|
129
|
-
|| summaryFiles[0];
|
|
130
|
-
summaryPath = node_path_1.default.join(safeTaskDir, preferred.name);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
catch {
|
|
134
|
-
// fall through with summaryPath = null → status: missing
|
|
135
|
-
}
|
|
384
|
+
const summaryPath = resolveQuickTaskSummaryFile(safeTaskDir, dirName);
|
|
136
385
|
let status = 'missing';
|
|
137
386
|
const description = '';
|
|
387
|
+
let fm = null;
|
|
138
388
|
if (summaryPath && node_fs_1.default.existsSync(summaryPath)) {
|
|
139
389
|
let safeSum;
|
|
140
390
|
try {
|
|
@@ -148,19 +398,31 @@ function scanQuickTasks(planDir) {
|
|
|
148
398
|
status = 'unreadable';
|
|
149
399
|
}
|
|
150
400
|
else {
|
|
151
|
-
|
|
401
|
+
fm = extractFrontmatter(content, safeSum);
|
|
152
402
|
status = (fm.status || 'unknown').toLowerCase();
|
|
153
403
|
}
|
|
154
404
|
}
|
|
155
405
|
if (status === 'complete')
|
|
156
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
|
+
}
|
|
157
414
|
// Parse date and slug from directory name: YYYYMMDD-slug or YYYY-MM-DD-slug
|
|
158
415
|
let date = '';
|
|
159
|
-
let slug = (0, security_cjs_1.
|
|
416
|
+
let slug = (0, security_cjs_1.sanitizeLabel)(dirName);
|
|
160
417
|
const dateMatch = dirName.match(/^(\d{4}-?\d{2}-?\d{2})-(.+)$/);
|
|
161
418
|
if (dateMatch) {
|
|
162
|
-
|
|
163
|
-
|
|
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]);
|
|
164
426
|
}
|
|
165
427
|
results.push({
|
|
166
428
|
slug,
|
|
@@ -169,7 +431,7 @@ function scanQuickTasks(planDir) {
|
|
|
169
431
|
description,
|
|
170
432
|
});
|
|
171
433
|
}
|
|
172
|
-
return results;
|
|
434
|
+
return { items: results, acknowledged };
|
|
173
435
|
}
|
|
174
436
|
// ─── scanThreads ──────────────────────────────────────────────────────────────
|
|
175
437
|
/**
|
|
@@ -179,16 +441,17 @@ function scanQuickTasks(planDir) {
|
|
|
179
441
|
function scanThreads(planDir) {
|
|
180
442
|
const threadsDir = node_path_1.default.join(planDir, 'threads');
|
|
181
443
|
if (!node_fs_1.default.existsSync(threadsDir))
|
|
182
|
-
return [];
|
|
444
|
+
return { items: [], acknowledged: 0 };
|
|
183
445
|
let files;
|
|
184
446
|
try {
|
|
185
447
|
files = node_fs_1.default.readdirSync(threadsDir, { withFileTypes: true });
|
|
186
448
|
}
|
|
187
449
|
catch {
|
|
188
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
|
450
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
|
|
189
451
|
}
|
|
190
452
|
const openStatuses = new Set(['open', 'in_progress', 'in progress']);
|
|
191
453
|
const results = [];
|
|
454
|
+
let acknowledged = 0;
|
|
192
455
|
for (const entry of files) {
|
|
193
456
|
if (!entry.isFile())
|
|
194
457
|
continue;
|
|
@@ -206,16 +469,13 @@ function scanThreads(planDir) {
|
|
|
206
469
|
if (content === null)
|
|
207
470
|
continue;
|
|
208
471
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
209
|
-
|
|
210
|
-
// Fall back to scanning body for ## Status: OPEN / IN PROGRESS
|
|
211
|
-
if (!status) {
|
|
212
|
-
const bodyStatusMatch = content.match(/##\s*Status:\s*(OPEN|IN PROGRESS|IN_PROGRESS)/i);
|
|
213
|
-
if (bodyStatusMatch) {
|
|
214
|
-
status = bodyStatusMatch[1].toLowerCase().replace(/ /g, '_');
|
|
215
|
-
}
|
|
216
|
-
}
|
|
472
|
+
const status = deriveThreadStatus(fm, content);
|
|
217
473
|
if (!openStatuses.has(status))
|
|
218
474
|
continue;
|
|
475
|
+
if (isAuditItemAcknowledged(fm, { snapshotKey: 'status', currentValue: status })) {
|
|
476
|
+
acknowledged++;
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
219
479
|
// Extract title from # Thread: heading or frontmatter title
|
|
220
480
|
let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
|
|
221
481
|
if (!title) {
|
|
@@ -226,13 +486,13 @@ function scanThreads(planDir) {
|
|
|
226
486
|
}
|
|
227
487
|
const slug = node_path_1.default.basename(entry.name, '.md');
|
|
228
488
|
results.push({
|
|
229
|
-
slug: (0, security_cjs_1.
|
|
489
|
+
slug: (0, security_cjs_1.sanitizeLabel)(slug),
|
|
230
490
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
231
491
|
updated: (0, security_cjs_1.sanitizeForDisplay)(fm.updated || fm.date || ''),
|
|
232
492
|
title,
|
|
233
493
|
});
|
|
234
494
|
}
|
|
235
|
-
return results;
|
|
495
|
+
return { items: results, acknowledged };
|
|
236
496
|
}
|
|
237
497
|
// ─── scanTodos ────────────────────────────────────────────────────────────────
|
|
238
498
|
/**
|
|
@@ -243,18 +503,27 @@ function scanThreads(planDir) {
|
|
|
243
503
|
function scanTodos(planDir) {
|
|
244
504
|
const pendingDir = node_path_1.default.join(planDir, 'todos', 'pending');
|
|
245
505
|
if (!node_fs_1.default.existsSync(pendingDir))
|
|
246
|
-
return [];
|
|
506
|
+
return { items: [], acknowledged: 0 };
|
|
247
507
|
let files;
|
|
248
508
|
try {
|
|
249
509
|
files = node_fs_1.default.readdirSync(pendingDir, { withFileTypes: true });
|
|
250
510
|
}
|
|
251
511
|
catch {
|
|
252
|
-
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
|
512
|
+
return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
|
|
253
513
|
}
|
|
254
514
|
const mdFiles = files.filter(e => e.isFile() && e.name.endsWith('.md'));
|
|
255
515
|
const results = [];
|
|
256
|
-
|
|
257
|
-
|
|
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) {
|
|
258
527
|
const filePath = node_path_1.default.join(pendingDir, entry.name);
|
|
259
528
|
let safeFilePath;
|
|
260
529
|
try {
|
|
@@ -267,21 +536,33 @@ function scanTodos(planDir) {
|
|
|
267
536
|
if (content === null)
|
|
268
537
|
continue;
|
|
269
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) {
|
|
270
551
|
// Extract first line of body after frontmatter
|
|
271
|
-
const bodyMatch = content.replace(/^---[\s\S]*?---\n?/, '');
|
|
272
|
-
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] || '';
|
|
273
554
|
const summary = (0, security_cjs_1.sanitizeForDisplay)(firstLine.slice(0, 100));
|
|
274
555
|
results.push({
|
|
275
|
-
filename: (0, security_cjs_1.
|
|
556
|
+
filename: (0, security_cjs_1.sanitizeLabel)(entry.name),
|
|
276
557
|
priority: (0, security_cjs_1.sanitizeForDisplay)(fm.priority || ''),
|
|
277
558
|
area: (0, security_cjs_1.sanitizeForDisplay)(fm.area || ''),
|
|
278
559
|
summary,
|
|
279
560
|
});
|
|
280
561
|
}
|
|
281
|
-
if (
|
|
282
|
-
results.push({ _remainder_count:
|
|
562
|
+
if (openFiles.length > 5) {
|
|
563
|
+
results.push({ _remainder_count: openFiles.length - 5, filename: '', priority: '', area: '', summary: '' });
|
|
283
564
|
}
|
|
284
|
-
return results;
|
|
565
|
+
return { items: results, acknowledged };
|
|
285
566
|
}
|
|
286
567
|
// ─── scanSeeds ────────────────────────────────────────────────────────────────
|
|
287
568
|
/**
|
|
@@ -291,16 +572,17 @@ function scanTodos(planDir) {
|
|
|
291
572
|
function scanSeeds(planDir) {
|
|
292
573
|
const seedsDir = node_path_1.default.join(planDir, 'seeds');
|
|
293
574
|
if (!node_fs_1.default.existsSync(seedsDir))
|
|
294
|
-
return [];
|
|
575
|
+
return { items: [], acknowledged: 0 };
|
|
295
576
|
let files;
|
|
296
577
|
try {
|
|
297
578
|
files = node_fs_1.default.readdirSync(seedsDir, { withFileTypes: true });
|
|
298
579
|
}
|
|
299
580
|
catch {
|
|
300
|
-
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
|
581
|
+
return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
|
|
301
582
|
}
|
|
302
583
|
const unimplementedStatuses = new Set(['dormant', 'active', 'triggered']);
|
|
303
584
|
const results = [];
|
|
585
|
+
let acknowledged = 0;
|
|
304
586
|
for (const entry of files) {
|
|
305
587
|
if (!entry.isFile())
|
|
306
588
|
continue;
|
|
@@ -321,10 +603,20 @@ function scanSeeds(planDir) {
|
|
|
321
603
|
const status = (fm.status || 'dormant').toLowerCase();
|
|
322
604
|
if (!unimplementedStatuses.has(status))
|
|
323
605
|
continue;
|
|
324
|
-
|
|
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.
|
|
325
617
|
const seedIdMatch = entry.name.match(/^(SEED-[\w-]+)\.md$/);
|
|
326
618
|
const seed_id = seedIdMatch ? seedIdMatch[1] : node_path_1.default.basename(entry.name, '.md');
|
|
327
|
-
const slug = (0, security_cjs_1.
|
|
619
|
+
const slug = (0, security_cjs_1.sanitizeLabel)(seed_id.replace(/^SEED-/, ''));
|
|
328
620
|
let title = (0, security_cjs_1.sanitizeForDisplay)(fm.title || '');
|
|
329
621
|
if (!title) {
|
|
330
622
|
const headingMatch = content.match(/^#\s*(.+)$/m);
|
|
@@ -332,46 +624,123 @@ function scanSeeds(planDir) {
|
|
|
332
624
|
title = (0, security_cjs_1.sanitizeForDisplay)(headingMatch[1].trim().slice(0, 100));
|
|
333
625
|
}
|
|
334
626
|
results.push({
|
|
335
|
-
seed_id: (0, security_cjs_1.
|
|
627
|
+
seed_id: (0, security_cjs_1.sanitizeLabel)(seed_id),
|
|
336
628
|
slug,
|
|
337
629
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
338
630
|
title,
|
|
339
631
|
});
|
|
340
632
|
}
|
|
341
|
-
return results;
|
|
633
|
+
return { items: results, acknowledged };
|
|
342
634
|
}
|
|
343
|
-
// ─── scanUatGaps ──────────────────────────────────────────────────────────────
|
|
344
635
|
/**
|
|
345
|
-
*
|
|
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.
|
|
346
685
|
*/
|
|
347
|
-
function
|
|
686
|
+
function listAuditPhaseTargets(planDir, cwd) {
|
|
687
|
+
const targets = [];
|
|
688
|
+
let activeUnreadable = false;
|
|
348
689
|
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
349
|
-
if (
|
|
350
|
-
|
|
351
|
-
|
|
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
|
+
}
|
|
352
706
|
try {
|
|
353
|
-
|
|
354
|
-
.
|
|
355
|
-
|
|
356
|
-
.sort();
|
|
707
|
+
for (const archived of getArchivedPhaseDirs(cwd)) {
|
|
708
|
+
targets.push({ dir: archived.name, fullPath: archived.fullPath, milestone: archived.milestone });
|
|
709
|
+
}
|
|
357
710
|
}
|
|
358
711
|
catch {
|
|
359
|
-
|
|
712
|
+
// Unreadable/unresolvable archive root: skip it, keep whatever active
|
|
713
|
+
// targets were already collected. No sentinel — see docstring above.
|
|
360
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) {
|
|
361
723
|
const results = [];
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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;
|
|
366
732
|
let files;
|
|
367
733
|
try {
|
|
368
|
-
files = node_fs_1.default.readdirSync(
|
|
734
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
369
735
|
}
|
|
370
736
|
catch {
|
|
371
737
|
continue;
|
|
372
738
|
}
|
|
373
|
-
|
|
374
|
-
|
|
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);
|
|
375
744
|
let safeFilePath;
|
|
376
745
|
try {
|
|
377
746
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'UAT file', { allowAbsolute: true });
|
|
@@ -391,50 +760,52 @@ function scanUatGaps(planDir) {
|
|
|
391
760
|
continue;
|
|
392
761
|
if (status === 'unknown' && result === 'all_pass')
|
|
393
762
|
continue;
|
|
394
|
-
// Count open scenarios
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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),
|
|
399
773
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
400
774
|
open_scenario_count: pendingMatches,
|
|
401
|
-
}
|
|
775
|
+
};
|
|
776
|
+
if (target.milestone !== undefined)
|
|
777
|
+
item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
778
|
+
results.push(item);
|
|
402
779
|
}
|
|
403
780
|
}
|
|
404
|
-
return results;
|
|
781
|
+
return { items: results, acknowledged };
|
|
405
782
|
}
|
|
406
783
|
// ─── scanVerificationGaps ─────────────────────────────────────────────────────
|
|
407
784
|
/**
|
|
408
|
-
* Scan .planning/phases
|
|
785
|
+
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
786
|
+
* for VERIFICATION gaps.
|
|
409
787
|
*/
|
|
410
|
-
function scanVerificationGaps(planDir) {
|
|
411
|
-
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
412
|
-
if (!node_fs_1.default.existsSync(phasesDir))
|
|
413
|
-
return [];
|
|
414
|
-
let dirs;
|
|
415
|
-
try {
|
|
416
|
-
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
417
|
-
.filter(e => e.isDirectory())
|
|
418
|
-
.map(e => e.name)
|
|
419
|
-
.sort();
|
|
420
|
-
}
|
|
421
|
-
catch {
|
|
422
|
-
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
|
423
|
-
}
|
|
788
|
+
function scanVerificationGaps(planDir, cwd) {
|
|
424
789
|
const results = [];
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
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;
|
|
429
798
|
let files;
|
|
430
799
|
try {
|
|
431
|
-
files = node_fs_1.default.readdirSync(
|
|
800
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
432
801
|
}
|
|
433
802
|
catch {
|
|
434
803
|
continue;
|
|
435
804
|
}
|
|
436
|
-
|
|
437
|
-
|
|
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);
|
|
438
809
|
let safeFilePath;
|
|
439
810
|
try {
|
|
440
811
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'VERIFICATION file', { allowAbsolute: true });
|
|
@@ -449,47 +820,46 @@ function scanVerificationGaps(planDir) {
|
|
|
449
820
|
const status = (fm.status || 'unknown').toLowerCase();
|
|
450
821
|
if (status !== 'gaps_found' && status !== 'human_needed')
|
|
451
822
|
continue;
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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),
|
|
455
830
|
status: (0, security_cjs_1.sanitizeForDisplay)(status),
|
|
456
|
-
}
|
|
831
|
+
};
|
|
832
|
+
if (target.milestone !== undefined)
|
|
833
|
+
item.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
834
|
+
results.push(item);
|
|
457
835
|
}
|
|
458
836
|
}
|
|
459
|
-
return results;
|
|
837
|
+
return { items: results, acknowledged };
|
|
460
838
|
}
|
|
461
839
|
// ─── scanContextQuestions ─────────────────────────────────────────────────────
|
|
462
840
|
/**
|
|
463
|
-
* Scan .planning/phases
|
|
841
|
+
* Scan .planning/phases (active) and .planning/milestones/vX.Y-phases (archived)
|
|
842
|
+
* for CONTEXT files with open_questions.
|
|
464
843
|
*/
|
|
465
|
-
function scanContextQuestions(planDir) {
|
|
466
|
-
const phasesDir = node_path_1.default.join(planDir, 'phases');
|
|
467
|
-
if (!node_fs_1.default.existsSync(phasesDir))
|
|
468
|
-
return [];
|
|
469
|
-
let dirs;
|
|
470
|
-
try {
|
|
471
|
-
dirs = node_fs_1.default.readdirSync(phasesDir, { withFileTypes: true })
|
|
472
|
-
.filter(e => e.isDirectory())
|
|
473
|
-
.map(e => e.name)
|
|
474
|
-
.sort();
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
return [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }];
|
|
478
|
-
}
|
|
844
|
+
function scanContextQuestions(planDir, cwd) {
|
|
479
845
|
const results = [];
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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;
|
|
484
854
|
let files;
|
|
485
855
|
try {
|
|
486
|
-
files = node_fs_1.default.readdirSync(
|
|
856
|
+
files = node_fs_1.default.readdirSync(target.fullPath);
|
|
487
857
|
}
|
|
488
858
|
catch {
|
|
489
859
|
continue;
|
|
490
860
|
}
|
|
491
861
|
for (const file of files.filter(f => f.includes('-CONTEXT') && f.endsWith('.md'))) {
|
|
492
|
-
const filePath = node_path_1.default.join(
|
|
862
|
+
const filePath = node_path_1.default.join(target.fullPath, file);
|
|
493
863
|
let safeFilePath;
|
|
494
864
|
try {
|
|
495
865
|
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'CONTEXT file', { allowAbsolute: true });
|
|
@@ -501,38 +871,104 @@ function scanContextQuestions(planDir) {
|
|
|
501
871
|
if (content === null)
|
|
502
872
|
continue;
|
|
503
873
|
const fm = extractFrontmatter(content, safeFilePath);
|
|
504
|
-
|
|
505
|
-
let questions = [];
|
|
506
|
-
if (fm.open_questions) {
|
|
507
|
-
if (Array.isArray(fm.open_questions) && fm.open_questions.length > 0) {
|
|
508
|
-
questions = fm.open_questions.map(q => (0, security_cjs_1.sanitizeForDisplay)(String(q).slice(0, 200)));
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
// Also check for ## Open Questions section in body
|
|
512
|
-
if (questions.length === 0) {
|
|
513
|
-
const oqSection = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.level === 2 && h.text.trim().toLowerCase().startsWith('open questions'), { levelBounded: true });
|
|
514
|
-
if (oqSection) {
|
|
515
|
-
const oqBody = oqSection.body.trim();
|
|
516
|
-
if (oqBody && oqBody.length > 0 && !/^\s*none\s*$/i.test(oqBody)) {
|
|
517
|
-
const items = oqBody.split('\n')
|
|
518
|
-
.map((l) => l.trim())
|
|
519
|
-
.filter((l) => l && l !== '-' && l !== '*')
|
|
520
|
-
.filter((l) => /^[-*\d]/.test(l) || l.includes('?'));
|
|
521
|
-
questions = items.slice(0, 3).map((q) => (0, security_cjs_1.sanitizeForDisplay)(q.slice(0, 200)));
|
|
522
|
-
}
|
|
523
|
-
}
|
|
524
|
-
}
|
|
874
|
+
const questions = deriveOpenQuestions(content, fm);
|
|
525
875
|
if (questions.length === 0)
|
|
526
876
|
continue;
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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),
|
|
530
889
|
question_count: questions.length,
|
|
531
|
-
questions: questions.slice(0, 3),
|
|
532
|
-
}
|
|
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);
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
return { items: results, acknowledged };
|
|
898
|
+
}
|
|
899
|
+
// ─── scanDeferredItems ────────────────────────────────────────────────────────
|
|
900
|
+
/**
|
|
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).
|
|
904
|
+
*
|
|
905
|
+
* The SCOPE BOUNDARY convention (`agents/gsd-executor.md`) has a phase agent
|
|
906
|
+
* log an out-of-scope discovery here rather than fix it. #2287 made that file
|
|
907
|
+
* readable at the PHASE boundary (`/gsd-progress` check 7, `audit-uat`); this
|
|
908
|
+
* scanner closes the remaining reader gap one boundary up, so an entry still
|
|
909
|
+
* unresolved at MILESTONE close surfaces in the pre-close audit alongside the
|
|
910
|
+
* other eight categories and the existing `[R]/[A]/[C]` prompt applies to it.
|
|
911
|
+
* Without this, phase directories archive to `milestones/vX.Y-phases/` (#1871)
|
|
912
|
+
* and the entry leaves the live tree having never been triaged.
|
|
913
|
+
*
|
|
914
|
+
* The resolved/unresolved predicate is NOT reimplemented here: `uat.cjs`
|
|
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.
|
|
927
|
+
*/
|
|
928
|
+
function scanDeferredItems(planDir, cwd) {
|
|
929
|
+
const { targets, activeUnreadable } = listAuditPhaseTargets(planDir, cwd);
|
|
930
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-unsafe-assignment
|
|
931
|
+
const uat = require('./uat.cjs');
|
|
932
|
+
const results = [];
|
|
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);
|
|
941
|
+
if (!node_fs_1.default.existsSync(filePath))
|
|
942
|
+
continue;
|
|
943
|
+
let safeFilePath;
|
|
944
|
+
try {
|
|
945
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(filePath, planDir, 'deferred items file', { allowAbsolute: true });
|
|
946
|
+
}
|
|
947
|
+
catch {
|
|
948
|
+
continue;
|
|
949
|
+
}
|
|
950
|
+
const content = (0, shell_command_projection_cjs_1.platformReadSync)(safeFilePath);
|
|
951
|
+
if (content === null)
|
|
952
|
+
continue;
|
|
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),
|
|
964
|
+
text: (0, security_cjs_1.sanitizeForDisplay)(item.name),
|
|
965
|
+
};
|
|
966
|
+
if (target.milestone !== undefined)
|
|
967
|
+
resultItem.archived_milestone = (0, security_cjs_1.sanitizeLabel)(target.milestone);
|
|
968
|
+
results.push(resultItem);
|
|
533
969
|
}
|
|
534
970
|
}
|
|
535
|
-
return results;
|
|
971
|
+
return { items: results, acknowledged };
|
|
536
972
|
}
|
|
537
973
|
// ─── auditOpenArtifacts ───────────────────────────────────────────────────────
|
|
538
974
|
/**
|
|
@@ -548,7 +984,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
548
984
|
return scanDebugSessions(planDir);
|
|
549
985
|
}
|
|
550
986
|
catch {
|
|
551
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }];
|
|
987
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', hypothesis: '' }], acknowledged: 0 };
|
|
552
988
|
}
|
|
553
989
|
})();
|
|
554
990
|
const quickTasks = (() => {
|
|
@@ -556,7 +992,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
556
992
|
return scanQuickTasks(planDir);
|
|
557
993
|
}
|
|
558
994
|
catch {
|
|
559
|
-
return [{ scan_error: true, slug: '', date: '', status: '', description: '' }];
|
|
995
|
+
return { items: [{ scan_error: true, slug: '', date: '', status: '', description: '' }], acknowledged: 0 };
|
|
560
996
|
}
|
|
561
997
|
})();
|
|
562
998
|
const threads = (() => {
|
|
@@ -564,7 +1000,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
564
1000
|
return scanThreads(planDir);
|
|
565
1001
|
}
|
|
566
1002
|
catch {
|
|
567
|
-
return [{ scan_error: true, slug: '', status: '', updated: '', title: '' }];
|
|
1003
|
+
return { items: [{ scan_error: true, slug: '', status: '', updated: '', title: '' }], acknowledged: 0 };
|
|
568
1004
|
}
|
|
569
1005
|
})();
|
|
570
1006
|
const todos = (() => {
|
|
@@ -572,7 +1008,7 @@ function auditOpenArtifacts(cwd) {
|
|
|
572
1008
|
return scanTodos(planDir);
|
|
573
1009
|
}
|
|
574
1010
|
catch {
|
|
575
|
-
return [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }];
|
|
1011
|
+
return { items: [{ scan_error: true, filename: '', priority: '', area: '', summary: '' }], acknowledged: 0 };
|
|
576
1012
|
}
|
|
577
1013
|
})();
|
|
578
1014
|
const seeds = (() => {
|
|
@@ -580,60 +1016,87 @@ function auditOpenArtifacts(cwd) {
|
|
|
580
1016
|
return scanSeeds(planDir);
|
|
581
1017
|
}
|
|
582
1018
|
catch {
|
|
583
|
-
return [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }];
|
|
1019
|
+
return { items: [{ scan_error: true, seed_id: '', slug: '', status: '', title: '' }], acknowledged: 0 };
|
|
584
1020
|
}
|
|
585
1021
|
})();
|
|
586
1022
|
const uatGaps = (() => {
|
|
587
1023
|
try {
|
|
588
|
-
return scanUatGaps(planDir);
|
|
1024
|
+
return scanUatGaps(planDir, cwd);
|
|
589
1025
|
}
|
|
590
1026
|
catch {
|
|
591
|
-
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 };
|
|
592
1028
|
}
|
|
593
1029
|
})();
|
|
594
1030
|
const verificationGaps = (() => {
|
|
595
1031
|
try {
|
|
596
|
-
return scanVerificationGaps(planDir);
|
|
1032
|
+
return scanVerificationGaps(planDir, cwd);
|
|
597
1033
|
}
|
|
598
1034
|
catch {
|
|
599
|
-
return [{ scan_error: true, phase: '', file: '', status: '' }];
|
|
1035
|
+
return { items: [{ scan_error: true, phase: '', file: '', status: '' }], acknowledged: 0 };
|
|
600
1036
|
}
|
|
601
1037
|
})();
|
|
602
1038
|
const contextQuestions = (() => {
|
|
603
1039
|
try {
|
|
604
|
-
return scanContextQuestions(planDir);
|
|
1040
|
+
return scanContextQuestions(planDir, cwd);
|
|
1041
|
+
}
|
|
1042
|
+
catch {
|
|
1043
|
+
return { items: [{ scan_error: true, phase: '', file: '', question_count: 0, questions: [] }], acknowledged: 0 };
|
|
1044
|
+
}
|
|
1045
|
+
})();
|
|
1046
|
+
const deferredItems = (() => {
|
|
1047
|
+
try {
|
|
1048
|
+
return scanDeferredItems(planDir, cwd);
|
|
605
1049
|
}
|
|
606
1050
|
catch {
|
|
607
|
-
return [{ scan_error: true, phase: '', file: '',
|
|
1051
|
+
return { items: [{ scan_error: true, phase: '', file: '', text: '' }], acknowledged: 0 };
|
|
608
1052
|
}
|
|
609
1053
|
})();
|
|
610
1054
|
// Count real items (not scan_error sentinels)
|
|
611
1055
|
const countReal = (arr) => arr.filter(i => !i.scan_error && !i._remainder_count).length;
|
|
612
1056
|
const counts = {
|
|
613
|
-
debug_sessions: countReal(debugSessions),
|
|
614
|
-
quick_tasks: countReal(quickTasks),
|
|
615
|
-
threads: countReal(threads),
|
|
616
|
-
todos: countReal(todos),
|
|
617
|
-
seeds: countReal(seeds),
|
|
618
|
-
uat_gaps: countReal(uatGaps),
|
|
619
|
-
verification_gaps: countReal(verificationGaps),
|
|
620
|
-
context_questions: countReal(contextQuestions),
|
|
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),
|
|
621
1066
|
total: 0,
|
|
622
1067
|
};
|
|
623
|
-
counts.total = counts.debug_sessions + counts.quick_tasks + counts.threads + counts.todos + counts.seeds + counts.uat_gaps + counts.verification_gaps + counts.context_questions;
|
|
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;
|
|
624
1085
|
return {
|
|
625
1086
|
scanned_at: new Date().toISOString(),
|
|
626
1087
|
has_open_items: counts.total > 0,
|
|
627
1088
|
counts,
|
|
1089
|
+
acknowledged,
|
|
628
1090
|
items: {
|
|
629
|
-
debug_sessions: debugSessions,
|
|
630
|
-
quick_tasks: quickTasks,
|
|
631
|
-
threads,
|
|
632
|
-
todos,
|
|
633
|
-
seeds,
|
|
634
|
-
uat_gaps: uatGaps,
|
|
635
|
-
verification_gaps: verificationGaps,
|
|
636
|
-
context_questions: contextQuestions,
|
|
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,
|
|
637
1100
|
},
|
|
638
1101
|
};
|
|
639
1102
|
}
|
|
@@ -645,23 +1108,36 @@ function auditOpenArtifacts(cwd) {
|
|
|
645
1108
|
* @returns Formatted report
|
|
646
1109
|
*/
|
|
647
1110
|
function formatAuditReport(auditResult) {
|
|
648
|
-
const { counts, items, has_open_items } = auditResult;
|
|
1111
|
+
const { counts, items, has_open_items, acknowledged } = auditResult;
|
|
649
1112
|
const lines = [];
|
|
650
1113
|
const hr = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
|
|
651
1114
|
lines.push(hr);
|
|
652
1115
|
lines.push(' Milestone Close: Open Artifact Audit');
|
|
653
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.
|
|
654
1121
|
if (!has_open_items) {
|
|
655
1122
|
lines.push('');
|
|
656
|
-
|
|
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
|
+
}
|
|
657
1129
|
lines.push('');
|
|
658
1130
|
lines.push(hr);
|
|
659
1131
|
return lines.join('\n');
|
|
660
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` : '');
|
|
661
1137
|
// Debug sessions (blocking quality — red)
|
|
662
1138
|
if (counts.debug_sessions > 0) {
|
|
663
1139
|
lines.push('');
|
|
664
|
-
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open)`);
|
|
1140
|
+
lines.push(`🔴 Debug Sessions (${counts.debug_sessions} open${ackSuffix(acknowledged.debug_sessions)})`);
|
|
665
1141
|
for (const item of items.debug_sessions.filter(i => !i.scan_error)) {
|
|
666
1142
|
const hyp = item.hypothesis ? ` — ${item.hypothesis}` : '';
|
|
667
1143
|
lines.push(` • ${item.slug} [${item.status}]${hyp}`);
|
|
@@ -670,23 +1146,25 @@ function formatAuditReport(auditResult) {
|
|
|
670
1146
|
// UAT gaps (blocking quality — red)
|
|
671
1147
|
if (counts.uat_gaps > 0) {
|
|
672
1148
|
lines.push('');
|
|
673
|
-
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)})`);
|
|
674
1150
|
for (const item of items.uat_gaps.filter(i => !i.scan_error)) {
|
|
675
|
-
|
|
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`);
|
|
676
1153
|
}
|
|
677
1154
|
}
|
|
678
1155
|
// Verification gaps (blocking quality — red)
|
|
679
1156
|
if (counts.verification_gaps > 0) {
|
|
680
1157
|
lines.push('');
|
|
681
|
-
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved)`);
|
|
1158
|
+
lines.push(`🔴 Verification Gaps (${counts.verification_gaps} unresolved${ackSuffix(acknowledged.verification_gaps)})`);
|
|
682
1159
|
for (const item of items.verification_gaps.filter(i => !i.scan_error)) {
|
|
683
|
-
|
|
1160
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1161
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.file} [${item.status}]`);
|
|
684
1162
|
}
|
|
685
1163
|
}
|
|
686
1164
|
// Quick tasks (incomplete work — yellow)
|
|
687
1165
|
if (counts.quick_tasks > 0) {
|
|
688
1166
|
lines.push('');
|
|
689
|
-
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete)`);
|
|
1167
|
+
lines.push(`🟡 Quick Tasks (${counts.quick_tasks} incomplete${ackSuffix(acknowledged.quick_tasks)})`);
|
|
690
1168
|
for (const item of items.quick_tasks.filter(i => !i.scan_error)) {
|
|
691
1169
|
const d = item.date ? ` (${item.date})` : '';
|
|
692
1170
|
lines.push(` • ${item.slug}${d} [${item.status}]`);
|
|
@@ -697,7 +1175,7 @@ function formatAuditReport(auditResult) {
|
|
|
697
1175
|
const realTodos = items.todos.filter(i => !i.scan_error && !i._remainder_count);
|
|
698
1176
|
const remainder = items.todos.find(i => i._remainder_count);
|
|
699
1177
|
lines.push('');
|
|
700
|
-
lines.push(`🟡 Pending Todos (${counts.todos} pending)`);
|
|
1178
|
+
lines.push(`🟡 Pending Todos (${counts.todos} pending${ackSuffix(acknowledged.todos)})`);
|
|
701
1179
|
for (const item of realTodos) {
|
|
702
1180
|
const area = item.area ? ` [${item.area}]` : '';
|
|
703
1181
|
const pri = item.priority ? ` (${item.priority})` : '';
|
|
@@ -712,7 +1190,7 @@ function formatAuditReport(auditResult) {
|
|
|
712
1190
|
// Threads (deferred decisions — blue)
|
|
713
1191
|
if (counts.threads > 0) {
|
|
714
1192
|
lines.push('');
|
|
715
|
-
lines.push(`🔵 Open Threads (${counts.threads} active)`);
|
|
1193
|
+
lines.push(`🔵 Open Threads (${counts.threads} active${ackSuffix(acknowledged.threads)})`);
|
|
716
1194
|
for (const item of items.threads.filter(i => !i.scan_error)) {
|
|
717
1195
|
const title = item.title ? ` — ${item.title}` : '';
|
|
718
1196
|
lines.push(` • ${item.slug} [${item.status}]${title}`);
|
|
@@ -721,7 +1199,7 @@ function formatAuditReport(auditResult) {
|
|
|
721
1199
|
// Seeds (deferred decisions — blue)
|
|
722
1200
|
if (counts.seeds > 0) {
|
|
723
1201
|
lines.push('');
|
|
724
|
-
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending)`);
|
|
1202
|
+
lines.push(`🔵 Unimplemented Seeds (${counts.seeds} pending${ackSuffix(acknowledged.seeds)})`);
|
|
725
1203
|
for (const item of items.seeds.filter(i => !i.scan_error)) {
|
|
726
1204
|
const title = item.title ? ` — ${item.title}` : '';
|
|
727
1205
|
lines.push(` • ${item.seed_id} [${item.status}]${title}`);
|
|
@@ -730,18 +1208,264 @@ function formatAuditReport(auditResult) {
|
|
|
730
1208
|
// Context questions (deferred decisions — blue)
|
|
731
1209
|
if (counts.context_questions > 0) {
|
|
732
1210
|
lines.push('');
|
|
733
|
-
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)})`);
|
|
734
1212
|
for (const item of items.context_questions.filter(i => !i.scan_error)) {
|
|
735
|
-
|
|
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' : ''})`);
|
|
736
1215
|
for (const q of item.questions) {
|
|
737
1216
|
lines.push(` - ${q}`);
|
|
738
1217
|
}
|
|
739
1218
|
}
|
|
740
1219
|
}
|
|
1220
|
+
// Deferred items (deferred decisions — blue). Out-of-scope discoveries a
|
|
1221
|
+
// phase agent recorded rather than fixed, still unresolved at close (#2646).
|
|
1222
|
+
if (counts.deferred_items > 0) {
|
|
1223
|
+
lines.push('');
|
|
1224
|
+
lines.push(`🔵 Deferred Items (${counts.deferred_items} unresolved${ackSuffix(acknowledged.deferred_items)})`);
|
|
1225
|
+
for (const item of items.deferred_items.filter(i => !i.scan_error)) {
|
|
1226
|
+
const archived = item.archived_milestone ? ` (archived ${item.archived_milestone})` : '';
|
|
1227
|
+
lines.push(` • Phase ${item.phase}${archived}: ${item.text}`);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
741
1230
|
lines.push('');
|
|
742
1231
|
lines.push(hr);
|
|
743
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
|
+
}
|
|
744
1236
|
lines.push(hr);
|
|
745
1237
|
return lines.join('\n');
|
|
746
1238
|
}
|
|
747
|
-
|
|
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
|
+
};
|