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