@opengsd/gsd-core 1.15.0 → 1.16.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/.opencode/plugins/gsd-core.js +85 -5
- package/agents/gsd-code-fixer.compact.md +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-code-reviewer.compact.md +5 -3
- package/agents/gsd-code-reviewer.md +8 -6
- package/agents/gsd-debug-session-manager.compact.md +1 -1
- package/agents/gsd-debug-session-manager.md +1 -1
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +4 -4
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +2 -2
- package/agents/gsd-phase-researcher.md +1 -1
- package/agents/gsd-plan-checker.md +7 -2
- package/agents/gsd-planner.md +3 -3
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.compact.md +21 -30
- package/agents/gsd-ui-auditor.md +42 -51
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +31 -9
- package/bin/install.js +126 -196
- package/commands/gsd/add-tests.md +6 -1
- package/commands/gsd/ai-integration-phase.md +6 -1
- package/commands/gsd/audit-fix.md +5 -0
- package/commands/gsd/audit-milestone.md +6 -1
- package/commands/gsd/autonomous.md +5 -0
- package/commands/gsd/capture.md +8 -4
- package/commands/gsd/code-review.md +7 -2
- package/commands/gsd/complete-milestone.md +4 -0
- package/commands/gsd/config.md +7 -3
- package/commands/gsd/debug.md +11 -7
- package/commands/gsd/discuss-phase.md +7 -3
- package/commands/gsd/docs-update.md +12 -7
- package/commands/gsd/eval-review.md +6 -1
- package/commands/gsd/execute-phase.md +12 -7
- package/commands/gsd/extract-learnings.md +5 -0
- package/commands/gsd/fast.md +4 -0
- package/commands/gsd/forensics.md +5 -1
- package/commands/gsd/graphify.md +10 -6
- package/commands/gsd/health.md +5 -0
- package/commands/gsd/help.md +7 -2
- package/commands/gsd/import.md +7 -3
- package/commands/gsd/inbox.md +5 -0
- package/commands/gsd/ingest-docs.md +5 -1
- package/commands/gsd/manager.md +6 -1
- package/commands/gsd/map-codebase.md +7 -3
- package/commands/gsd/mempalace-capture.md +5 -1
- package/commands/gsd/mempalace-recall.md +5 -1
- package/commands/gsd/milestone-summary.md +5 -1
- package/commands/gsd/mvp-phase.md +8 -3
- package/commands/gsd/new-milestone.md +6 -1
- package/commands/gsd/new-project.md +5 -0
- package/commands/gsd/next.md +6 -1
- package/commands/gsd/ns-context.md +4 -0
- package/commands/gsd/ns-ideate.md +4 -0
- package/commands/gsd/ns-manage.md +4 -0
- package/commands/gsd/ns-project.md +4 -0
- package/commands/gsd/ns-review.md +4 -0
- package/commands/gsd/ns-workflow.md +4 -0
- package/commands/gsd/onboard.md +6 -1
- package/commands/gsd/pause-work.md +5 -1
- package/commands/gsd/phase.md +8 -4
- package/commands/gsd/plan-phase.md +6 -1
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/commands/gsd/pr-branch.md +4 -0
- package/commands/gsd/profile-user.md +5 -1
- package/commands/gsd/progress.md +6 -1
- package/commands/gsd/quick-batch.md +20 -8
- package/commands/gsd/quick.md +12 -7
- package/commands/gsd/review.md +5 -1
- package/commands/gsd/secure-phase.md +6 -1
- package/commands/gsd/ship.md +5 -0
- package/commands/gsd/sketch.md +7 -2
- package/commands/gsd/spec-phase.md +5 -1
- package/commands/gsd/spike.md +8 -3
- package/commands/gsd/surface.md +5 -1
- package/commands/gsd/thread.md +4 -0
- package/commands/gsd/ui-phase.md +6 -1
- package/commands/gsd/ui-review.md +6 -1
- package/commands/gsd/ultraplan-phase.md +5 -1
- package/commands/gsd/undo.md +5 -1
- package/commands/gsd/update.md +6 -2
- package/commands/gsd/validate-phase.md +6 -1
- package/commands/gsd/verify-work.md +6 -1
- package/commands/gsd/workspace.md +7 -3
- package/gsd-core/bin/gsd-tools.cjs +142 -56
- package/gsd-core/bin/lib/active-workstream-store.cjs +15 -0
- package/gsd-core/bin/lib/agent-install-check.cjs +4 -1
- package/gsd-core/bin/lib/audit.cjs +78 -44
- package/gsd-core/bin/lib/broken-windows.cjs +13 -13
- package/gsd-core/bin/lib/capability-activation.cjs +9 -4
- package/gsd-core/bin/lib/capability-registry.cjs +183 -103
- package/gsd-core/bin/lib/capability-validator.cjs +16 -0
- package/gsd-core/bin/lib/check-auto-mode.cjs +35 -0
- package/gsd-core/bin/lib/check-command-router.cjs +164 -1712
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +13 -2
- package/gsd-core/bin/lib/cli-exit.cjs +12 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +11 -8
- package/gsd-core/bin/lib/command-aliases.cjs +7 -0
- package/gsd-core/bin/lib/command-routing-hub.cjs +48 -1
- package/gsd-core/bin/lib/commands.cjs +173 -170
- package/gsd-core/bin/lib/complexity-trigger.cjs +8 -7
- package/gsd-core/bin/lib/config.cjs +43 -12
- package/gsd-core/bin/lib/core-utils.cjs +6 -1
- package/gsd-core/bin/lib/coverage.cjs +4 -8
- package/gsd-core/bin/lib/decision-coverage-support.cjs +259 -0
- package/gsd-core/bin/lib/drift.cjs +177 -42
- package/gsd-core/bin/lib/frontmatter-fence.cjs +90 -0
- package/gsd-core/bin/lib/frontmatter-splice.cjs +494 -0
- package/gsd-core/bin/lib/frontmatter.cjs +413 -234
- package/gsd-core/bin/lib/gap-checker.cjs +72 -29
- package/gsd-core/bin/lib/gate-api-coverage-verify-pre.cjs +381 -0
- package/gsd-core/bin/lib/gate-args.cjs +53 -0
- package/gsd-core/bin/lib/gate-codebase-drift.cjs +285 -0
- package/gsd-core/bin/lib/gate-config.cjs +46 -0
- package/gsd-core/bin/lib/gate-context-drift.cjs +141 -0
- package/gsd-core/bin/lib/gate-decision-coverage-plan.cjs +169 -0
- package/gsd-core/bin/lib/gate-decision-coverage-verify.cjs +126 -0
- package/gsd-core/bin/lib/gate-evaluation-scope.cjs +555 -0
- package/gsd-core/bin/lib/gate-evidence.cjs +138 -0
- package/gsd-core/bin/lib/gate-exit.cjs +27 -0
- package/gsd-core/bin/lib/gate-gap-analysis-plan-post.cjs +61 -0
- package/gsd-core/bin/lib/gate-phase-context.cjs +170 -0
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +1 -1
- package/gsd-core/bin/lib/gate-predicate.cjs +165 -0
- package/gsd-core/bin/lib/gate-prohibition-enforcement.cjs +94 -0
- package/gsd-core/bin/lib/gate-schema-drift.cjs +165 -0
- package/gsd-core/bin/lib/gate-tdd-red-evidence.cjs +100 -0
- package/gsd-core/bin/lib/gate-tdd-review-checkpoint.cjs +182 -0
- package/gsd-core/bin/lib/gate-ui-plan.cjs +86 -0
- package/gsd-core/bin/lib/gate-ui-safety.cjs +80 -0
- package/gsd-core/bin/lib/gate-verdict.cjs +64 -0
- package/gsd-core/bin/lib/gate-verify-command-paths.cjs +78 -0
- package/gsd-core/bin/lib/gate-verify-failure-directions.cjs +41 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +43 -47
- package/gsd-core/bin/lib/health-diagnostic.cjs +45 -8
- package/gsd-core/bin/lib/init.cjs +162 -96
- package/gsd-core/bin/lib/install-engine.cjs +19 -35
- package/gsd-core/bin/lib/install-profiles.cjs +7 -4
- package/gsd-core/bin/lib/io.cjs +122 -3
- package/gsd-core/bin/lib/loop-resolver.cjs +95 -0
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +75 -1
- package/gsd-core/bin/lib/milestone.cjs +19 -1
- package/gsd-core/bin/lib/model-resolver.cjs +18 -18
- package/gsd-core/bin/lib/observability/event.cjs +1 -1
- package/gsd-core/bin/lib/observability/logger.cjs +46 -1
- package/gsd-core/bin/lib/pattern.cjs +10 -0
- package/gsd-core/bin/lib/phase-command-router.cjs +11 -4
- package/gsd-core/bin/lib/phase-estimation.cjs +5 -4
- package/gsd-core/bin/lib/phase-id.cjs +1 -1
- package/gsd-core/bin/lib/phase-lifecycle.cjs +9 -2
- package/gsd-core/bin/lib/phase-status.cjs +360 -0
- package/gsd-core/bin/lib/phase.cjs +280 -80
- package/gsd-core/bin/lib/plan-document.cjs +93 -19
- package/gsd-core/bin/lib/plan-drift-guard.cjs +5 -0
- package/gsd-core/bin/lib/planning-document.cjs +273 -40
- package/gsd-core/bin/lib/planning-inspect.cjs +42 -8
- package/gsd-core/bin/lib/planning-snapshot.cjs +18 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +76 -53
- package/gsd-core/bin/lib/pristine-baseline.cjs +10 -0
- package/gsd-core/bin/lib/profile-output.cjs +6 -3
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +0 -55
- package/gsd-core/bin/lib/quick-batch-command-router.cjs +35 -9
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +11 -8
- package/gsd-core/bin/lib/real-home-guard.cjs +9 -1
- package/gsd-core/bin/lib/report-parser.cjs +269 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +13 -15
- package/gsd-core/bin/lib/roadmap-parser.cjs +82 -15
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +127 -65
- package/gsd-core/bin/lib/roadmap.cjs +169 -72
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +91 -157
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +2 -1
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +57 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +13 -10
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +307 -591
- package/gsd-core/bin/lib/runtime-name-policy.cjs +142 -27
- package/gsd-core/bin/lib/runtime-slash.cjs +47 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +37 -2
- package/gsd-core/bin/lib/smart-entry.cjs +19 -3
- package/gsd-core/bin/lib/stale-bake-guard.cjs +32 -48
- package/gsd-core/bin/lib/state-contract.cjs +15 -18
- package/gsd-core/bin/lib/state-document.cjs +100 -22
- package/gsd-core/bin/lib/state.cjs +214 -105
- package/gsd-core/bin/lib/surface.cjs +2 -1
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +48 -152
- package/gsd-core/bin/lib/uat-predicate.cjs +313 -35
- package/gsd-core/bin/lib/uat.cjs +424 -7
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +94 -58
- package/gsd-core/bin/lib/vendor/README.md +31 -9
- package/gsd-core/bin/lib/vendor/saxes.cjs +1934 -0
- package/gsd-core/bin/lib/vendor/saxes.cjs.LICENSE.txt +92 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs +8927 -0
- package/gsd-core/bin/lib/vendor/tap-parser.cjs.LICENSE.txt +152 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +1165 -314
- package/gsd-core/bin/lib/verify-command-router.cjs +18 -7
- package/gsd-core/bin/lib/verify.cjs +286 -567
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +14 -6
- package/gsd-core/bin/lib/workstream-inventory.cjs +31 -21
- package/gsd-core/bin/lib/workstream-name-policy.cjs +31 -1
- package/gsd-core/bin/lib/workstream.cjs +11 -2
- package/gsd-core/bin/shared/config-defaults.manifest.json +5 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +4 -0
- package/gsd-core/references/autonomous-smart-discuss.md +2 -1
- package/gsd-core/references/autonomous-ui-design-contract.md +3 -3
- package/gsd-core/references/execute-mvp-tdd.md +5 -10
- package/gsd-core/references/execute-phase-between-wave-reset.md +3 -0
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +7 -1
- package/gsd-core/references/offer-next.md +1 -1
- package/gsd-core/references/planning-config.md +1 -1
- package/gsd-core/references/spidr-splitting.md +1 -1
- package/gsd-core/references/tdd.md +10 -4
- package/gsd-core/references/verifier-phase-gates.md +5 -2
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -3
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/UAT.md +17 -1
- package/gsd-core/templates/config.json +2 -11
- package/gsd-core/templates/verification-report.md +1 -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 +8 -7
- package/gsd-core/workflows/add-tests.md +3 -2
- package/gsd-core/workflows/add-todo.md +4 -3
- package/gsd-core/workflows/ai-integration-phase.md +3 -2
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +4 -3
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous.md +28 -18
- package/gsd-core/workflows/check-todos.md +6 -5
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +20 -13
- package/gsd-core/workflows/code-review-fix.md +5 -4
- package/gsd-core/workflows/code-review.md +84 -90
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +4 -3
- package/gsd-core/workflows/complete-milestone.md +12 -7
- package/gsd-core/workflows/debug.md +30 -5
- package/gsd-core/workflows/diagnose-issues.md +3 -2
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +4 -3
- package/gsd-core/workflows/do.md +1 -1
- package/gsd-core/workflows/docs-update.md +4 -3
- package/gsd-core/workflows/edit-phase.md +4 -3
- package/gsd-core/workflows/eval-review.md +5 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +4 -2
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +15 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +10 -7
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/verify-phase-goal.md +187 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +2 -3
- package/gsd-core/workflows/execute-phase.md +78 -170
- package/gsd-core/workflows/execute-plan.md +13 -19
- package/gsd-core/workflows/explore.md +2 -2
- package/gsd-core/workflows/extract-learnings.md +3 -2
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +2 -1
- package/gsd-core/workflows/import.md +3 -2
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +4 -3
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +5 -3
- package/gsd-core/workflows/map-codebase.md +4 -3
- package/gsd-core/workflows/milestone-summary.md +3 -2
- package/gsd-core/workflows/mvp-phase.md +14 -14
- package/gsd-core/workflows/new-milestone.md +10 -10
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +1 -1
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/pause-work.md +2 -2
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +1 -1
- package/gsd-core/workflows/plan-phase.md +27 -18
- package/gsd-core/workflows/plan-review-convergence.md +1 -1
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +1 -1
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress.md +19 -49
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +4 -2
- package/gsd-core/workflows/quick/steps/quick-verification.md +4 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +30 -11
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +9 -3
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +15 -10
- package/gsd-core/workflows/quick.md +42 -31
- package/gsd-core/workflows/remove-phase.md +3 -2
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +3 -2
- package/gsd-core/workflows/review.md +3 -2
- package/gsd-core/workflows/scan.md +3 -2
- package/gsd-core/workflows/secure-phase.md +11 -12
- package/gsd-core/workflows/settings-advanced.md +1 -1
- package/gsd-core/workflows/settings-integrations.md +1 -1
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +12 -12
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +1 -1
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +1 -1
- package/gsd-core/workflows/thread.md +2 -2
- package/gsd-core/workflows/transition.md +13 -23
- package/gsd-core/workflows/ui-phase.md +5 -4
- package/gsd-core/workflows/ui-review.md +4 -3
- package/gsd-core/workflows/ultraplan-phase.md +3 -2
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/validate-phase.md +10 -12
- package/gsd-core/workflows/verify-work/detail/elaboration.md +43 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +5 -3
- package/gsd-core/workflows/verify-work.md +68 -59
- package/hooks/dist/gsd-agent-isolation-guard.js +8 -0
- package/hooks/dist/gsd-check-update-worker.js +8 -0
- package/hooks/dist/gsd-check-update.js +8 -0
- package/hooks/dist/gsd-context-monitor.js +31 -8
- package/hooks/dist/gsd-cursor-subagent-start.js +8 -0
- package/hooks/dist/gsd-secret-read-guard.js +147 -16
- package/hooks/dist/gsd-statusline.js +15 -7
- package/hooks/dist/gsd-update-banner.js +8 -0
- package/hooks/dist/gsd-windsurf-pre-write.js +11 -2
- package/hooks/dist/gsd-workflow-guard.js +5 -4
- package/hooks/dist/gsd-worktree-path-guard.js +6 -2
- package/hooks/dist/lib/cli-exit.js +12 -0
- package/hooks/dist/lib/git-probe.js +17 -1
- package/hooks/dist/lib/isolation-sentinel.js +2 -2
- package/hooks/gsd-agent-isolation-guard.js +8 -0
- package/hooks/gsd-check-update-worker.js +8 -0
- package/hooks/gsd-check-update.js +8 -0
- package/hooks/gsd-context-monitor.js +31 -8
- package/hooks/gsd-cursor-subagent-start.js +8 -0
- package/hooks/gsd-secret-read-guard.js +147 -16
- package/hooks/gsd-statusline.js +15 -7
- package/hooks/gsd-update-banner.js +8 -0
- package/hooks/gsd-windsurf-pre-write.js +11 -2
- package/hooks/gsd-workflow-guard.js +5 -4
- package/hooks/gsd-worktree-path-guard.js +6 -2
- package/hooks/hooks.json +5 -5
- package/hooks/lib/cli-exit.js +12 -0
- package/hooks/lib/git-probe.js +17 -1
- package/hooks/lib/isolation-sentinel.js +2 -2
- package/package.json +21 -4
- package/scripts/changeset/parse.cjs +52 -4
- package/scripts/ci-timeout-report.cjs +770 -4
- package/scripts/command-contract-helpers.cjs +12 -8
- package/scripts/docs-guard-registry.cjs +6 -0
- package/scripts/gen-features.cjs +13 -8
- package/scripts/gen-hooks-cli-exit.cjs +12 -28
- package/scripts/gen-loop-host-contract.cjs +10 -1
- package/scripts/gen-platform-conformance-tier.cjs +187 -1
- package/scripts/gen-plugin-skills.cjs +87 -1
- package/scripts/gen-research-agents.cjs +24 -31
- package/scripts/gen-scripts-cli-exit.cjs +30 -3
- package/scripts/gen-test-timings.cjs +32 -7
- package/scripts/lib/cli-exit.cjs +12 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +20 -2
- package/scripts/lib/ndjson-reporter.cjs +28 -3
- package/scripts/lib/platform-conformance-tier.generated.cjs +34 -5
- package/scripts/lib/registration-ledger-preload.cjs +155 -0
- package/scripts/lib/vendor-bundle.cjs +59 -0
- package/scripts/lib/vendor-licenses/saxes-6.0.0.txt +64 -0
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- package/scripts/lint-completion-predicate-drift.cjs +18 -19
- package/scripts/lint-descriptions.cjs +7 -3
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +10 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +20 -0
- package/scripts/lint-frontmatter-fence-drift.cjs +313 -0
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +122 -16
- package/scripts/lint-phase-enumeration-drift.cjs +12 -8
- package/scripts/lint-phase-id-drift.cjs +29 -0
- package/scripts/lint-planning-document-positive-control.cjs +329 -0
- package/scripts/lint-response-language-coverage.cjs +3 -0
- package/scripts/lint-skill-deps.cjs +7 -3
- package/scripts/lint-test-file-count.allowlist.json +11 -1
- package/scripts/lint-test-file-count.cjs +34 -1
- package/scripts/lint-vendored-deps.cjs +41 -5
- package/scripts/lint-workflow-shellcheck-baseline.json +10 -10
- package/scripts/mutation-matrix.cjs +50 -5
- package/scripts/require-issue-link-policy.cjs +6 -2
- package/scripts/sync-runtime-launcher.cjs +184 -2
- package/scripts/verify-npm-publish.cjs +76 -20
- package/skills/gsd-add-tests/SKILL.md +6 -1
- package/skills/gsd-ai-integration-phase/SKILL.md +6 -1
- package/skills/gsd-audit-fix/SKILL.md +5 -0
- package/skills/gsd-audit-milestone/SKILL.md +6 -1
- package/skills/gsd-autonomous/SKILL.md +5 -0
- package/skills/gsd-capture/SKILL.md +8 -4
- package/skills/gsd-code-review/SKILL.md +7 -2
- package/skills/gsd-complete-milestone/SKILL.md +4 -0
- package/skills/gsd-config/SKILL.md +8 -4
- package/skills/gsd-debug/SKILL.md +11 -7
- package/skills/gsd-discuss-phase/SKILL.md +7 -3
- package/skills/gsd-docs-update/SKILL.md +12 -7
- package/skills/gsd-eval-review/SKILL.md +6 -1
- package/skills/gsd-execute-phase/SKILL.md +12 -7
- package/skills/gsd-extract-learnings/SKILL.md +5 -0
- package/skills/gsd-fast/SKILL.md +4 -0
- package/skills/gsd-forensics/SKILL.md +5 -1
- package/skills/gsd-graphify/SKILL.md +10 -6
- package/skills/gsd-health/SKILL.md +5 -0
- package/skills/gsd-help/SKILL.md +7 -2
- package/skills/gsd-import/SKILL.md +7 -3
- package/skills/gsd-inbox/SKILL.md +5 -0
- package/skills/gsd-ingest-docs/SKILL.md +5 -1
- package/skills/gsd-manager/SKILL.md +6 -1
- package/skills/gsd-map-codebase/SKILL.md +7 -3
- package/skills/gsd-mempalace-capture/SKILL.md +5 -1
- package/skills/gsd-mempalace-recall/SKILL.md +5 -1
- package/skills/gsd-milestone-summary/SKILL.md +5 -1
- package/skills/gsd-mvp-phase/SKILL.md +8 -3
- package/skills/gsd-new-milestone/SKILL.md +6 -1
- package/skills/gsd-new-project/SKILL.md +5 -0
- package/skills/gsd-next/SKILL.md +6 -1
- package/skills/gsd-ns-context/SKILL.md +4 -0
- package/skills/gsd-ns-ideate/SKILL.md +4 -0
- package/skills/gsd-ns-manage/SKILL.md +4 -0
- package/skills/gsd-ns-project/SKILL.md +4 -0
- package/skills/gsd-ns-review/SKILL.md +4 -0
- package/skills/gsd-ns-workflow/SKILL.md +4 -0
- package/skills/gsd-onboard/SKILL.md +6 -1
- package/skills/gsd-pause-work/SKILL.md +5 -1
- package/skills/gsd-phase/SKILL.md +8 -4
- package/skills/gsd-plan-phase/SKILL.md +6 -1
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/skills/gsd-pr-branch/SKILL.md +4 -0
- package/skills/gsd-profile-user/SKILL.md +5 -1
- package/skills/gsd-progress/SKILL.md +6 -1
- package/skills/gsd-quick/SKILL.md +16 -10
- package/skills/gsd-quick-batch/SKILL.md +20 -8
- package/skills/gsd-review/SKILL.md +5 -1
- package/skills/gsd-review-backlog/SKILL.md +3 -2
- package/skills/gsd-secure-phase/SKILL.md +6 -1
- package/skills/gsd-ship/SKILL.md +5 -0
- package/skills/gsd-sketch/SKILL.md +7 -2
- package/skills/gsd-spec-phase/SKILL.md +5 -1
- package/skills/gsd-spike/SKILL.md +8 -3
- package/skills/gsd-surface/SKILL.md +5 -1
- package/skills/gsd-thread/SKILL.md +4 -0
- package/skills/gsd-ui-phase/SKILL.md +6 -1
- package/skills/gsd-ui-review/SKILL.md +6 -1
- package/skills/gsd-ultraplan-phase/SKILL.md +5 -1
- package/skills/gsd-undo/SKILL.md +5 -1
- package/skills/gsd-update/SKILL.md +6 -2
- package/skills/gsd-validate-phase/SKILL.md +6 -1
- package/skills/gsd-verify-work/SKILL.md +6 -1
- package/skills/gsd-workspace/SKILL.md +7 -3
- package/skills/gsd-workstreams/SKILL.md +6 -6
- package/vscode/package.json +1 -1
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +0 -24
|
@@ -49,92 +49,263 @@ const planningScopeMod = require("./planning-scope.cjs");
|
|
|
49
49
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
50
50
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
51
51
|
const security_cjs_1 = require("./security.cjs");
|
|
52
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
53
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
54
|
+
const markdown_table_cjs_1 = require("./markdown-table.cjs");
|
|
55
|
+
const command_arg_projection_cjs_1 = require("./command-arg-projection.cjs");
|
|
52
56
|
const { output, error } = io;
|
|
53
57
|
const { extractPhaseToken, scopeToPhase } = phaseId;
|
|
54
58
|
const { extractFrontmatter, FRONTMATTER_UNPARSEABLE } = frontmatterMod;
|
|
55
59
|
const { normalizeLineEndings } = coreUtilsMod;
|
|
56
60
|
const { SCOPE } = planningScopeMod;
|
|
57
|
-
// ───
|
|
58
|
-
/** The set of status values that the gsd-verifier agent emits. */
|
|
59
|
-
const VERIFIER_STATUSES = ['passed', 'gaps_found', 'human_needed'];
|
|
61
|
+
// ─── The closed VerificationStatus enum (#5118, ADR-5057 §1 / Phase 4) ────────
|
|
60
62
|
/**
|
|
61
|
-
*
|
|
63
|
+
* The closed verification-status vocabulary. This module is its ONE owner:
|
|
64
|
+
* every other `src/` site imports `VERIFICATION_STATUS` / `VerificationStatus`
|
|
65
|
+
* (the ESLint rule `local/no-verification-status-literal` keeps a spelled
|
|
66
|
+
* literal out of `src/`), every workflow reads the owner's query fields, and
|
|
67
|
+
* the prose writer contract (`agents/gsd-verifier.md`,
|
|
68
|
+
* `templates/verification-report.md`) is parity-locked to `VERIFIER_STATUSES`.
|
|
62
69
|
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
70
|
+
* `unknown` is not a member (#5118): it existed only to pass an out-of-set
|
|
71
|
+
* report value along (#4817's `status: verified` → `/gsd-execute-phase`). An
|
|
72
|
+
* out-of-set report value is now a `VerificationStatusError`, thrown where it
|
|
73
|
+
* is read.
|
|
65
74
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
75
|
+
* Reader-only members — never valid in a report's frontmatter:
|
|
76
|
+
* - `stale` the covered inputs moved after the verifier ran
|
|
77
|
+
* - `missing` the phase directory exists and holds no report, or
|
|
78
|
+
* the report has no `status` (the verify step never ran)
|
|
79
|
+
* - `unparseable` the report exists but its frontmatter is not YAML
|
|
80
|
+
* (#4806) — this one meaning only
|
|
81
|
+
* - `phase_dir_not_found` there was no phase directory to look in (ADR-5057
|
|
82
|
+
* amendment 2, #4987) — a usage error, never
|
|
83
|
+
* `execute-phase`
|
|
71
84
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
85
|
+
* An out-of-set report status is NOT a member either: the reader throws, and
|
|
86
|
+
* `isPhaseComplete`'s no-throw projection of it is `status: null` with scope
|
|
87
|
+
* UNREADABLE and `statusError` set (never `unparseable`).
|
|
88
|
+
*/
|
|
89
|
+
const VERIFICATION_STATUS = Object.freeze({
|
|
90
|
+
PASSED: 'passed',
|
|
91
|
+
GAPS_FOUND: 'gaps_found',
|
|
92
|
+
HUMAN_NEEDED: 'human_needed',
|
|
93
|
+
STALE: 'stale',
|
|
94
|
+
MISSING: 'missing',
|
|
95
|
+
UNPARSEABLE: 'unparseable',
|
|
96
|
+
PHASE_DIR_NOT_FOUND: 'phase_dir_not_found',
|
|
97
|
+
});
|
|
98
|
+
/** The set of status values the gsd-verifier agent writes — a frozen subset of the enum. */
|
|
99
|
+
const VERIFIER_STATUSES = Object.freeze(new Set([
|
|
100
|
+
VERIFICATION_STATUS.PASSED,
|
|
101
|
+
VERIFICATION_STATUS.GAPS_FOUND,
|
|
102
|
+
VERIFICATION_STATUS.HUMAN_NEEDED,
|
|
103
|
+
]));
|
|
104
|
+
const VERIFICATION_STATUS_VALUES = new Set(Object.values(VERIFICATION_STATUS));
|
|
105
|
+
/** True exactly when `v` is a member of the closed enum (exact match, no case folding). */
|
|
106
|
+
function isVerificationStatus(v) {
|
|
107
|
+
return typeof v === 'string' && VERIFICATION_STATUS_VALUES.has(v);
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* Fail where the value is produced (the same rule as phase-status.cts's
|
|
111
|
+
* `assertPhaseStatus`): a non-member is a `TypeError` naming the call site.
|
|
112
|
+
*/
|
|
113
|
+
function assertVerificationStatus(v, where) {
|
|
114
|
+
if (!isVerificationStatus(v)) {
|
|
115
|
+
throw new TypeError(`${where}: ${describeRawStatus(v)} is not a VerificationStatus (expected one of ${[...VERIFICATION_STATUS_VALUES].join(', ')})`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
/** The rendered raw-status token is cut to this many characters (#5118 security review). */
|
|
119
|
+
const RAW_STATUS_TOKEN_LIMIT = 120;
|
|
120
|
+
/**
|
|
121
|
+
* Render an untrusted status value as one quoted, control-free token
|
|
122
|
+
* (io.formatDiagnosticToken escapes C0/C1 controls, line/paragraph
|
|
123
|
+
* separators, zero-width and bidi-override characters and the BOM as
|
|
124
|
+
* `\uXXXX`), truncated to RAW_STATUS_TOKEN_LIMIT characters plus
|
|
125
|
+
* `…(N more)` — a report is agent-written text, and its echo reaches every
|
|
126
|
+
* workflow's LLM context through the error message.
|
|
127
|
+
*/
|
|
128
|
+
function describeRawStatus(raw) {
|
|
129
|
+
let text;
|
|
130
|
+
if (typeof raw === 'string') {
|
|
131
|
+
text = raw;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
let json;
|
|
135
|
+
try {
|
|
136
|
+
json = JSON.stringify(raw);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
json = undefined;
|
|
140
|
+
}
|
|
141
|
+
text = json ?? String(raw);
|
|
142
|
+
}
|
|
143
|
+
const rendered = io.formatDiagnosticToken(text);
|
|
144
|
+
if (rendered.length <= RAW_STATUS_TOKEN_LIMIT)
|
|
145
|
+
return rendered;
|
|
146
|
+
// The cut must land on a boundary: never inside a `\uXXXX` escape the
|
|
147
|
+
// formatter emitted (a fragment such as `\u00` would read as a different
|
|
148
|
+
// character) and never between the halves of a surrogate pair (a lone
|
|
149
|
+
// surrogate is not valid text). Back off to the start of either.
|
|
150
|
+
let cut = RAW_STATUS_TOKEN_LIMIT;
|
|
151
|
+
const partialEscape = /\\u[0-9a-fA-F]{0,3}$/.exec(rendered.slice(0, cut));
|
|
152
|
+
if (partialEscape) {
|
|
153
|
+
cut -= partialEscape[0].length;
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
const last = rendered.charCodeAt(cut - 1);
|
|
157
|
+
if (last >= 0xd800 && last <= 0xdbff)
|
|
158
|
+
cut -= 1;
|
|
159
|
+
}
|
|
160
|
+
// JSON also emits two-character escapes (`\n`, `\t`, `\"`, `\\`): an odd
|
|
161
|
+
// trailing run of backslashes is the first half of one, so drop it too.
|
|
162
|
+
const trailingBackslashes = /\\+$/.exec(rendered.slice(0, cut));
|
|
163
|
+
if (trailingBackslashes && trailingBackslashes[0].length % 2 === 1)
|
|
164
|
+
cut -= 1;
|
|
165
|
+
return `${rendered.slice(0, cut)}…(${rendered.length - cut} more)`;
|
|
166
|
+
}
|
|
167
|
+
/** `VerificationStatusError.code` — import this constant wherever the code is matched. */
|
|
168
|
+
const VERIFICATION_STATUS_ERROR_CODE = 'ERR_VERIFICATION_STATUS_OUT_OF_SET';
|
|
169
|
+
/**
|
|
170
|
+
* A report whose frontmatter `status` is outside the writer set
|
|
171
|
+
* (`VERIFIER_STATUSES`) — a string that is not a member (`verified`, `Passed`,
|
|
172
|
+
* a reader-only member such as `stale`), or a non-string value (`5`, `true`,
|
|
173
|
+
* a list). Thrown by the reader, never folded into another status (#4817).
|
|
174
|
+
*
|
|
175
|
+
* It carries its own ERROR_REASON (`reason`, `verification_status_invalid`) —
|
|
176
|
+
* the one owner of that mapping: a CLI surface fails with `error(err.message,
|
|
177
|
+
* err.reason)`, and the command-routing hub returns it as a pure Result whose
|
|
178
|
+
* `kind` is that reason. `isPhaseComplete` maps it to its UNREADABLE scope
|
|
179
|
+
* (`status: null`, `statusError`) so its no-throw contract holds.
|
|
180
|
+
*/
|
|
181
|
+
class VerificationStatusError extends Error {
|
|
182
|
+
code = VERIFICATION_STATUS_ERROR_CODE;
|
|
183
|
+
reason = io.ERROR_REASON.VERIFICATION_STATUS_INVALID;
|
|
184
|
+
rawStatus;
|
|
185
|
+
file;
|
|
186
|
+
accepted;
|
|
187
|
+
constructor(rawStatus, file) {
|
|
188
|
+
const accepted = [...VERIFIER_STATUSES];
|
|
189
|
+
super(`Verification report ${io.formatDiagnosticToken(file)} has status ${describeRawStatus(rawStatus)}, ` +
|
|
190
|
+
`which is outside the closed set — accepted values: ${accepted.join(' | ')}. ` +
|
|
191
|
+
`Recovery: set the report's frontmatter \`status:\` to one of ${accepted.join(' | ')}, ` +
|
|
192
|
+
"or delete the report and re-run the phase's verification.");
|
|
193
|
+
this.name = 'VerificationStatusError';
|
|
194
|
+
this.rawStatus = rawStatus;
|
|
195
|
+
this.file = file;
|
|
196
|
+
this.accepted = accepted;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/**
|
|
200
|
+
* The CLI projection of a VerificationStatusError — its own message and its
|
|
201
|
+
* own `.reason` (the one owner of the reason mapping). Every CLI surface that
|
|
202
|
+
* refuses a report (a thrown error at the entry seam, or an aggregate
|
|
203
|
+
* carrying one in its result) fails through here; nothing restates the reason.
|
|
204
|
+
*/
|
|
205
|
+
function failOnVerificationStatusError(err) {
|
|
206
|
+
return error(err.message, err.reason);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* The carry rule every aggregate shares: the FIRST refused report is the one
|
|
210
|
+
* an aggregate fails with. Keeps `carried` once set; otherwise takes
|
|
211
|
+
* `candidate` (a later report never displaces an earlier one).
|
|
212
|
+
*/
|
|
213
|
+
function firstStatusError(carried, candidate) {
|
|
214
|
+
return carried ?? candidate ?? undefined;
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* The owner's frontmatter-only judgement of a report's `status` — the one
|
|
218
|
+
* place a VERIFICATION report's `status` scalar is read (#5118: phase.cts,
|
|
219
|
+
* audit.cts and uat-predicate.cts used to read it themselves). Returns the
|
|
220
|
+
* writer-set member, or `null` when the report carries no status (absent key,
|
|
221
|
+
* `null`, an empty string, or an unparseable block — the caller routes
|
|
222
|
+
* those). Throws `VerificationStatusError` for anything else, including a
|
|
223
|
+
* non-string value.
|
|
224
|
+
*
|
|
225
|
+
* `fm` is `extractFrontmatter`'s result for the report at `filePath`.
|
|
226
|
+
*/
|
|
227
|
+
function reportStatusOf(fm, filePath) {
|
|
228
|
+
if (fm[FRONTMATTER_UNPARSEABLE] === true)
|
|
229
|
+
return null;
|
|
230
|
+
const raw = fm['status'];
|
|
231
|
+
if (raw === undefined || raw === null)
|
|
232
|
+
return null;
|
|
233
|
+
if (typeof raw === 'string') {
|
|
234
|
+
const trimmed = raw.trim();
|
|
235
|
+
if (trimmed.length === 0)
|
|
236
|
+
return null;
|
|
237
|
+
if (VERIFIER_STATUSES.has(trimmed))
|
|
238
|
+
return trimmed;
|
|
239
|
+
throw new VerificationStatusError(trimmed, filePath);
|
|
240
|
+
}
|
|
241
|
+
throw new VerificationStatusError(raw, filePath);
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* VERIFICATION_ROUTES — the one routing table (#5118). Keyed by exactly the
|
|
245
|
+
* enum (`Record<VerificationStatus, …>`: a missing or extra key is a compile
|
|
246
|
+
* error); the key IS the status, so entries carry no `status` field. Every
|
|
247
|
+
* result `readVerificationStatus` returns is projected from this table by
|
|
248
|
+
* `routeResult` — no return site hard-codes a command.
|
|
249
|
+
*
|
|
250
|
+
* #2617: `command` holds a BARE command name (`execute-phase`), never a
|
|
251
|
+
* prefixed one; `routeResult` projects it through `formatGsdSlash` with the
|
|
252
|
+
* caller's runtime, so Codex sees `$gsd-execute-phase` and slash-hyphen
|
|
253
|
+
* runtimes see `/gsd-execute-phase`.
|
|
74
254
|
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
255
|
+
* `stale` has ONE route: `execute-phase`. Its regenerating action is the
|
|
256
|
+
* shared step `gsd-core/workflows/execute-phase/steps/verify-phase-goal.md`
|
|
257
|
+
* (execute-phase's verify_phase_goal, which verify-work's stale arm includes
|
|
258
|
+
* too): it re-runs the verifier, which regenerates VERIFICATION.md and its
|
|
259
|
+
* digest (#4682, #4887).
|
|
80
260
|
*/
|
|
81
|
-
const
|
|
82
|
-
passed: {
|
|
83
|
-
status: 'passed',
|
|
261
|
+
const VERIFICATION_ROUTES = Object.freeze({
|
|
262
|
+
passed: Object.freeze({
|
|
84
263
|
next_action: 'Verification passed — continue.',
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
264
|
+
command: '',
|
|
265
|
+
tail: '',
|
|
266
|
+
}),
|
|
267
|
+
gaps_found: Object.freeze({
|
|
89
268
|
next_action: 'Gaps found. Plan the fixes, then re-run execute-phase before shipping.',
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
},
|
|
93
|
-
human_needed: {
|
|
94
|
-
status: 'human_needed',
|
|
269
|
+
command: 'plan-phase',
|
|
270
|
+
tail: ' --gaps',
|
|
271
|
+
}),
|
|
272
|
+
human_needed: Object.freeze({
|
|
95
273
|
next_action: "Human verification required. Complete the manual tests in the phase's *-UAT.md, then re-run the verify step until status is passed.",
|
|
96
|
-
// #2617:
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
status: 'stale',
|
|
105
|
-
// #4682: staleness means covered source files changed after the verifier
|
|
106
|
-
// last ran — the only remedy is re-running the verifier.
|
|
107
|
-
// /gsd-verify-work never rewrites VERIFICATION.md, so advising it from
|
|
108
|
-
// here was an advice loop. execute-phase resumes at the verification
|
|
109
|
-
// gates and re-runs the verifier (its resume tree routes a stale report
|
|
110
|
-
// to re-verification), which regenerates VERIFICATION.md and its digest.
|
|
274
|
+
// #2617: init's projector and this table used to disagree on whether a
|
|
275
|
+
// next command existed at all; `verify-work <N>` is the useful answer.
|
|
276
|
+
command: 'verify-work',
|
|
277
|
+
tail: '',
|
|
278
|
+
}),
|
|
279
|
+
stale: Object.freeze({
|
|
280
|
+
// #4682 / #5118: the only remedy for a stale report is re-running the
|
|
281
|
+
// verifier; /gsd-verify-work on its own never rewrites VERIFICATION.md.
|
|
111
282
|
next_action: 'Verification is stale — covered source files changed after the verifier last ran. Re-run execute-phase for this phase: it resumes at the verification gates and re-runs the verifier, regenerating VERIFICATION.md and its digest. verify-work alone cannot refresh a stale report.',
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
283
|
+
command: 'execute-phase',
|
|
284
|
+
tail: '',
|
|
285
|
+
}),
|
|
286
|
+
// The phase directory exists and holds no report, or the report has no
|
|
287
|
+
// `status`: the verify step never completed (#2868).
|
|
288
|
+
missing: Object.freeze({
|
|
118
289
|
next_action: 'No verification report found — the verify step never completed. Running execute-phase is safe here: it resumes at the verification gates and does not re-run plans that already have a SUMMARY.md (see #2868).',
|
|
119
|
-
|
|
120
|
-
|
|
290
|
+
command: 'execute-phase',
|
|
291
|
+
tail: '',
|
|
292
|
+
}),
|
|
121
293
|
// #4806: the report EXISTS but its frontmatter is not parseable YAML —
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
// status as blocking, so this value fails safe while telling the truth.
|
|
125
|
-
unparseable: {
|
|
126
|
-
status: 'unparseable',
|
|
294
|
+
// re-running execute-phase cannot fix a YAML typo in an existing report.
|
|
295
|
+
unparseable: Object.freeze({
|
|
127
296
|
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
297
|
+
command: '',
|
|
298
|
+
tail: '',
|
|
299
|
+
}),
|
|
300
|
+
// ADR-5057 amendment 2 / #4987: there was nothing to look in — a usage
|
|
301
|
+
// error, never execute-phase (which would re-run a phase that may already be
|
|
302
|
+
// archived under .planning/milestones/).
|
|
303
|
+
phase_dir_not_found: Object.freeze({
|
|
304
|
+
next_action: 'Usage error: the phase directory does not exist — pass an existing phase directory (resolve it with find-phase; phases archived by complete-milestone live under .planning/milestones/v<X.Y>-phases/).',
|
|
305
|
+
command: '',
|
|
306
|
+
tail: '',
|
|
307
|
+
}),
|
|
308
|
+
});
|
|
138
309
|
/**
|
|
139
310
|
* Project a BARE command name (plus optional argument tail) into the surface the
|
|
140
311
|
* given runtime actually installs (#2617).
|
|
@@ -178,11 +349,231 @@ function toPosix(p) {
|
|
|
178
349
|
function normalizeRel(p) {
|
|
179
350
|
return node_path_1.default.posix.normalize(toPosix(p));
|
|
180
351
|
}
|
|
181
|
-
/**
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
|
|
185
|
-
|
|
352
|
+
/**
|
|
353
|
+
* Canonicalize a covered-files list: normalize, de-duplicate, sort — the SAME
|
|
354
|
+
* transform computeCoveredDigest and cmdVerificationFingerprint both need
|
|
355
|
+
* (the digest's own key order; the CLI's own `covered_files` JSON output).
|
|
356
|
+
*
|
|
357
|
+
* #5095 (ADR-5057 Phase 2): `opts.version >= 3` additionally drops any
|
|
358
|
+
* report-shaped path (`isVerificationReportPath`) — a report is never an
|
|
359
|
+
* input to its own digest (#4857). Versionless callers (the default) keep
|
|
360
|
+
* the pre-#5095 behaviour: normalize/dedupe/sort only, no filtering.
|
|
361
|
+
*/
|
|
362
|
+
function canonicalizeCoveredFiles(files, opts = {}) {
|
|
363
|
+
const normalized = files.map(normalizeRel);
|
|
364
|
+
const filtered = opts.version !== undefined && opts.version >= 3
|
|
365
|
+
? normalized.filter((f) => !isVerificationReportPath(f))
|
|
366
|
+
: normalized;
|
|
367
|
+
return Array.from(new Set(filtered)).sort();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* #5095 (ADR-5057 Phase 2, #4857): a covered-input path names a verification
|
|
371
|
+
* REPORT — basename `VERIFICATION.md` or ending `-VERIFICATION.md`
|
|
372
|
+
* (case-sensitive, as `resolveVerificationFile` is) — as opposed to any other
|
|
373
|
+
* evidence. `computeCoveredDigest(projectRoot, coveredFiles)` never receives
|
|
374
|
+
* the report's OWN path as a distinguished value — the report writes its own
|
|
375
|
+
* digest into its own frontmatter, so a comparison inside that function is
|
|
376
|
+
* structurally impossible (it would need to know, while computing a value,
|
|
377
|
+
* what that value is about to become). The filter therefore lives here, one
|
|
378
|
+
* function up from the digest, as a PATTERN match on shape, not a path
|
|
379
|
+
* identity check. Matches `docs/VERIFICATION.md` (same basename) but not
|
|
380
|
+
* `VERIFICATION-NOTES.md` or `07-VERIFICATION.md.bak` (different basename).
|
|
381
|
+
*/
|
|
382
|
+
function isVerificationReportPath(rel) {
|
|
383
|
+
const basename = node_path_1.default.posix.basename(toPosix(rel));
|
|
384
|
+
return basename === 'VERIFICATION.md' || basename.endsWith('-VERIFICATION.md');
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* #5095 (R7): enumerate every anchored planning scope the Planning Workspace
|
|
388
|
+
* Module's layout admits, by walking the REAL directory tree rooted at
|
|
389
|
+
* `<projectRoot>/.planning` — never the caller's `phaseDir` (the pre-R7 bug:
|
|
390
|
+
* admitting a scope root on `basename(phaseDir's parent) === 'phases'` alone
|
|
391
|
+
* made any directory named `phases` anywhere an admissible root). Only
|
|
392
|
+
* directories are admitted, and a project/workstream segment failing
|
|
393
|
+
* `planningDir`'s own `BAD_SEGMENT` rule (a path separator or `..`) is
|
|
394
|
+
* skipped — the identical validation `planningDir` itself enforces, so every
|
|
395
|
+
* scope this function admits is one `planningDir` could also resolve to.
|
|
396
|
+
* `workstreams` is reserved as a layout segment (never itself a project
|
|
397
|
+
* name), so the walk cannot derive the same scope twice under two labels.
|
|
398
|
+
* Each planning BASE contributes two scopes — the base itself, and its own
|
|
399
|
+
* `phases/` subdirectory — since `phases/` may be independently symlinked to
|
|
400
|
+
* a per-project/per-workstream store while the base directory stays real
|
|
401
|
+
* (the #4894 layout, generalized to every base shape); the longest-`lexRel`
|
|
402
|
+
* -wins rule in `mapRealPathToRootRelative` / `bestPlanningScopeForRel` then
|
|
403
|
+
* prefers the `phases` scope for anything living under it.
|
|
404
|
+
*
|
|
405
|
+
* Security: a scope whose realpath is the filesystem root, or an ANCESTOR of
|
|
406
|
+
* `realRoot` itself, is refused outright (`real: null`) — an
|
|
407
|
+
* attacker-controlled `.planning -> /` (or `-> ..`) symlink must never become
|
|
408
|
+
* an admissible containment root.
|
|
409
|
+
*/
|
|
410
|
+
function enumeratePlanningScopes(projectRoot, realRoot) {
|
|
411
|
+
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
412
|
+
const listDirs = (dirAbs) => {
|
|
413
|
+
try {
|
|
414
|
+
return node_fs_1.default
|
|
415
|
+
.readdirSync(dirAbs, { withFileTypes: true })
|
|
416
|
+
.filter((e) => e.isDirectory() && !BAD_SEGMENT.test(e.name))
|
|
417
|
+
.map((e) => e.name);
|
|
418
|
+
}
|
|
419
|
+
catch {
|
|
420
|
+
return [];
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const realOf = (p) => {
|
|
424
|
+
let real;
|
|
425
|
+
try {
|
|
426
|
+
real = node_fs_1.default.realpathSync(p);
|
|
427
|
+
}
|
|
428
|
+
catch {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
if (node_path_1.default.dirname(real) === real)
|
|
432
|
+
return null; // filesystem root
|
|
433
|
+
if (realRoot !== null && real !== realRoot && (0, security_cjs_1.isContainedIn)(realRoot, real))
|
|
434
|
+
return null; // ancestor of realRoot
|
|
435
|
+
return real;
|
|
436
|
+
};
|
|
437
|
+
// Every planning BASE (a directory `planningDir` itself would resolve to)
|
|
438
|
+
// contributes TWO scopes: the base itself (for a direct child like
|
|
439
|
+
// `ROADMAP.md`/`config.json`) and its OWN `phases/` subdirectory (for phase
|
|
440
|
+
// artifacts) — kept separate because `phases/` may be independently
|
|
441
|
+
// symlinked to a per-project/per-workstream store while the base directory
|
|
442
|
+
// stays real (the #4894 layout, generalized to every base shape). The
|
|
443
|
+
// longest-`lexRel`-wins rule elsewhere then prefers the `phases` scope for
|
|
444
|
+
// anything under it.
|
|
445
|
+
const addBase = (lexRel, baseAbs) => {
|
|
446
|
+
scopes.push({ lexRel, real: realOf(baseAbs) });
|
|
447
|
+
scopes.push({ lexRel: `${lexRel}/phases`, real: realOf(node_path_1.default.join(baseAbs, 'phases')) });
|
|
448
|
+
};
|
|
449
|
+
const scopes = [];
|
|
450
|
+
const planningAbs = node_path_1.default.join(projectRoot, '.planning');
|
|
451
|
+
const planningBaseReal = realOf(planningAbs);
|
|
452
|
+
scopes.push({ lexRel: '.planning', real: planningBaseReal });
|
|
453
|
+
scopes.push({ lexRel: '.planning/phases', real: realOf(node_path_1.default.join(planningAbs, 'phases')) });
|
|
454
|
+
// #5095 (R7(c) follow-up, security): when `.planning` itself was refused as
|
|
455
|
+
// a dangerous root (filesystem root, or an ancestor of `realRoot` — see
|
|
456
|
+
// `realOf` above), NEVER walk its `workstreams`/project subdirectories to
|
|
457
|
+
// mint further scopes. `listDirs`/`path.join` operate on the LEXICAL,
|
|
458
|
+
// still-symlinked `planningAbs`, so with `.planning -> /` every entry of
|
|
459
|
+
// the real filesystem root (`/etc`, `/usr`, ...) would otherwise surface as
|
|
460
|
+
// an admissible `.planning/<project>` scope — legitimizing an attacker- or
|
|
461
|
+
// container-controlled root-filesystem directory as a containment root the
|
|
462
|
+
// instant it happens to share a name with something reachable from `/`.
|
|
463
|
+
// Each such child is realpath-resolved on its OWN merits (not an ancestor
|
|
464
|
+
// of `realRoot`, not the filesystem root itself), so it silently passes the
|
|
465
|
+
// per-scope refusal check even though its only claim to legitimacy is
|
|
466
|
+
// having been discovered by listing a root the outer check already
|
|
467
|
+
// rejected. Refusing to enumerate here is what makes that refusal actually
|
|
468
|
+
// stick.
|
|
469
|
+
if (planningBaseReal === null)
|
|
470
|
+
return scopes;
|
|
471
|
+
for (const ws of listDirs(node_path_1.default.join(planningAbs, 'workstreams'))) {
|
|
472
|
+
addBase(`.planning/workstreams/${ws}`, node_path_1.default.join(planningAbs, 'workstreams', ws));
|
|
473
|
+
}
|
|
474
|
+
for (const project of listDirs(planningAbs)) {
|
|
475
|
+
if (project === 'workstreams')
|
|
476
|
+
continue;
|
|
477
|
+
const projectAbs = node_path_1.default.join(planningAbs, project);
|
|
478
|
+
addBase(`.planning/${project}`, projectAbs);
|
|
479
|
+
for (const ws of listDirs(node_path_1.default.join(projectAbs, 'workstreams'))) {
|
|
480
|
+
addBase(`.planning/${project}/workstreams/${ws}`, node_path_1.default.join(projectAbs, 'workstreams', ws));
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return scopes;
|
|
484
|
+
}
|
|
485
|
+
function resolveContainmentRoots(projectRoot) {
|
|
486
|
+
let realRoot;
|
|
487
|
+
try {
|
|
488
|
+
realRoot = node_fs_1.default.realpathSync(projectRoot);
|
|
489
|
+
}
|
|
490
|
+
catch {
|
|
491
|
+
realRoot = null;
|
|
492
|
+
}
|
|
493
|
+
return { realRoot, planningScopes: enumeratePlanningScopes(projectRoot, realRoot) };
|
|
494
|
+
}
|
|
495
|
+
function bestPlanningScopeForRel(rel, planningScopes) {
|
|
496
|
+
let best = null;
|
|
497
|
+
for (const scope of planningScopes) {
|
|
498
|
+
if ((rel === scope.lexRel || rel.startsWith(`${scope.lexRel}/`)) && // allow-handrolled-containment: lexical PREFIX selection among candidate scope spellings (posix segment-boundary), not a resolved-path containment decision — the realpath check happens separately via isContainedIn on scope.real
|
|
499
|
+
(best === null || scope.lexRel.length > best.lexRel.length)) {
|
|
500
|
+
best = scope;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
return best;
|
|
504
|
+
}
|
|
505
|
+
/**
|
|
506
|
+
* #5095 (R7): map an already-`realpathSync`-resolved target back to a
|
|
507
|
+
* root-relative, posix-normalized spelling. The ANCHORED scope with the
|
|
508
|
+
* LONGEST `lexRel` whose `real` contains the target wins — so a nested scope
|
|
509
|
+
* (e.g. `.planning/workstreams/ws1`) is preferred over the top-level
|
|
510
|
+
* `.planning` scope for a target reachable through both, and a
|
|
511
|
+
* same-named-but-different-store phase under the nested scope is never
|
|
512
|
+
* mapped onto the root scope's spelling. Falls back to a bare
|
|
513
|
+
* project-root-relative path when the target lives inside `realRoot` but no
|
|
514
|
+
* scope's real claims it; else `null` (fail closed — the caller must not
|
|
515
|
+
* guess a spelling for a path it cannot place).
|
|
516
|
+
*/
|
|
517
|
+
function mapRealPathToRootRelative(real, realRoot, planningScopes) {
|
|
518
|
+
let best = null;
|
|
519
|
+
for (const scope of planningScopes) {
|
|
520
|
+
if (scope.real !== null &&
|
|
521
|
+
(0, security_cjs_1.isContainedIn)(real, scope.real) &&
|
|
522
|
+
(best === null || scope.lexRel.length > best.lexRel.length)) {
|
|
523
|
+
best = scope;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
if (best !== null) {
|
|
527
|
+
const rel = normalizeRel(node_path_1.default.relative(best.real, real));
|
|
528
|
+
return rel === '' || rel === '.' ? best.lexRel : `${best.lexRel}/${rel}`;
|
|
529
|
+
}
|
|
530
|
+
if ((0, security_cjs_1.isContainedIn)(real, realRoot)) {
|
|
531
|
+
return normalizeRel(node_path_1.default.relative(realRoot, real));
|
|
532
|
+
}
|
|
533
|
+
return null;
|
|
534
|
+
}
|
|
535
|
+
function phaseArtifactPaths(phaseDir, projectRoot) {
|
|
536
|
+
const roots = resolveContainmentRoots(projectRoot);
|
|
537
|
+
if (roots.realRoot === null) {
|
|
538
|
+
return { ok: false, reason: `project root is unreadable: ${projectRoot}` };
|
|
539
|
+
}
|
|
540
|
+
const scan = scanPhasePlans(phaseDir);
|
|
541
|
+
// Fail closed on a non-COMPLETE scan (unreadable phase dir, or an
|
|
542
|
+
// unreadable nested plans/ dir) — a partial scan's invisible contents can
|
|
543
|
+
// never be proven covered (mirrors allCurrentArtifactsCovered's own
|
|
544
|
+
// fail-closed contract).
|
|
545
|
+
if (scan.scope !== SCOPE.COMPLETE) {
|
|
546
|
+
return { ok: false, reason: `phase directory scan is incomplete (scope: ${scan.scope}): ${phaseDir}` };
|
|
547
|
+
}
|
|
548
|
+
const candidates = [...scan.allPlanFiles, ...scan.summaryFiles].filter((f) => !isVerificationReportPath(f));
|
|
549
|
+
const paths = [];
|
|
550
|
+
for (const f of candidates) {
|
|
551
|
+
let real;
|
|
552
|
+
try {
|
|
553
|
+
real = node_fs_1.default.realpathSync(node_path_1.default.join(phaseDir, f));
|
|
554
|
+
}
|
|
555
|
+
catch {
|
|
556
|
+
return { ok: false, reason: `phase artifact is unreadable: ${f}` };
|
|
557
|
+
}
|
|
558
|
+
const mapped = mapRealPathToRootRelative(real, roots.realRoot, roots.planningScopes);
|
|
559
|
+
if (mapped === null) {
|
|
560
|
+
return { ok: false, reason: `phase artifact escapes the project root: ${f}` };
|
|
561
|
+
}
|
|
562
|
+
paths.push(mapped);
|
|
563
|
+
}
|
|
564
|
+
// #5095 (R2): the phase dir's own mapped spelling, fed to `sharedRootsFor`
|
|
565
|
+
// so a workstream/project ROADMAP under an out-of-repo store is still
|
|
566
|
+
// recognized as a shared planning document even when `phaseDir` itself was
|
|
567
|
+
// addressed by its real (non-`.planning`-spelled) path.
|
|
568
|
+
let mappedPhaseDir = null;
|
|
569
|
+
try {
|
|
570
|
+
const realPhaseDir = node_fs_1.default.realpathSync(phaseDir);
|
|
571
|
+
mappedPhaseDir = mapRealPathToRootRelative(realPhaseDir, roots.realRoot, roots.planningScopes);
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
mappedPhaseDir = null;
|
|
575
|
+
}
|
|
576
|
+
return { ok: true, paths, scope: scan.scope, mappedPhaseDir };
|
|
186
577
|
}
|
|
187
578
|
// ─── #4155: covered-input fingerprint ──────────────────────────────────────────
|
|
188
579
|
/**
|
|
@@ -194,16 +585,34 @@ function canonicalizeCoveredFiles(files) {
|
|
|
194
585
|
* v1 (#4155) — every covered path's whole bytes, uniformly.
|
|
195
586
|
* v2 (#4623) — repo-wide planning documents (`isSharedPlanningDoc`) are
|
|
196
587
|
* excluded from the hash by construction.
|
|
588
|
+
* v3 (#5095, ADR-5057 Phase 2) — v2 PLUS: (a) report-shaped declared paths
|
|
589
|
+
* (`isVerificationReportPath`) are filtered before hashing — a
|
|
590
|
+
* report is never an input to its own digest (#4857); (b) the
|
|
591
|
+
* hashed set is the declared list UNIONED with the phase's own
|
|
592
|
+
* live `*-PLAN.md`/`*-SUMMARY.md` artifacts
|
|
593
|
+
* (`phaseArtifactPaths`), computed identically on both the
|
|
594
|
+
* emit (`cmdVerificationFingerprint`) and check
|
|
595
|
+
* (`readVerificationStatus`) sides — a plan/summary added
|
|
596
|
+
* after fingerprinting moves the digest itself, with no
|
|
597
|
+
* separate live-directory rescan needed (#4817); (c)
|
|
598
|
+
* containment additionally admits a `.planning`-spelled path
|
|
599
|
+
* whose realpath sits in one of the anchored planning scopes
|
|
600
|
+
* `resolveContainmentRoots` enumerates (`.planning`,
|
|
601
|
+
* `.planning/<project>`, `.planning/workstreams/<ws>`,
|
|
602
|
+
* `.planning/<project>/workstreams/<ws>`), covering a
|
|
603
|
+
* per-project/per-workstream store symlink (amendment 1,
|
|
604
|
+
* revised R7).
|
|
197
605
|
*
|
|
198
606
|
* A stored digest names its own version (`v<N>:sha256:…`), and
|
|
199
607
|
* `readVerificationStatus` recomputes under the STORED version rather than
|
|
200
608
|
* this constant — so bumping it does not flip every already-verified phase
|
|
201
|
-
* to `stale` on upgrade. A legacy v1 report keeps
|
|
202
|
-
* documents included
|
|
609
|
+
* to `stale` on upgrade. A legacy v1/v2 report keeps its own semantics
|
|
610
|
+
* (shared documents included for v1; no report filter or artifact union for
|
|
611
|
+
* either) until it is re-fingerprinted; only a version outside
|
|
203
612
|
* `KNOWN_FINGERPRINT_VERSIONS` is unrecomputable and fails closed.
|
|
204
613
|
*/
|
|
205
|
-
const FINGERPRINT_VERSION =
|
|
206
|
-
const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2]);
|
|
614
|
+
const FINGERPRINT_VERSION = 3;
|
|
615
|
+
const KNOWN_FINGERPRINT_VERSIONS = new Set([1, 2, 3]);
|
|
207
616
|
/**
|
|
208
617
|
* #4623: the planning roots whose DIRECT children are repo-wide planning
|
|
209
618
|
* documents, as project-root-relative posix paths. Always `.planning`; plus
|
|
@@ -265,6 +674,31 @@ function isSharedPlanningDoc(rel, roots = ['.planning']) {
|
|
|
265
674
|
return false;
|
|
266
675
|
return roots.includes(node_path_1.default.posix.dirname(rel));
|
|
267
676
|
}
|
|
677
|
+
/**
|
|
678
|
+
* #5095 (R2): `sharedPlanningRoots` extended with the phase's own MAPPED
|
|
679
|
+
* (root-relative, posix) directory when one is available —
|
|
680
|
+
* `phaseArtifactPaths`'s `mappedPhaseDir` — so a workstream/project ROADMAP
|
|
681
|
+
* under an out-of-repo store is still recognized as shared even when
|
|
682
|
+
* `phaseDir` was addressed by its real, non-`.planning`-spelled path (the
|
|
683
|
+
* #4894 `--project-dir` shape). Falls back to the existing absolute-path
|
|
684
|
+
* derivation (`sharedPlanningRoots(projectRoot, phaseDir)`) when no mapped
|
|
685
|
+
* spelling is available — byte-for-behaviour identical to the pre-#5095
|
|
686
|
+
* seam for every caller that has no artifact-derivation step of its own.
|
|
687
|
+
*/
|
|
688
|
+
function sharedRootsFor(projectRoot, phaseDir, mappedPhaseDir) {
|
|
689
|
+
const roots = sharedPlanningRoots(projectRoot, phaseDir);
|
|
690
|
+
if (mappedPhaseDir) {
|
|
691
|
+
const phasesDir = node_path_1.default.posix.dirname(mappedPhaseDir);
|
|
692
|
+
const planningDir = node_path_1.default.posix.dirname(phasesDir);
|
|
693
|
+
if (node_path_1.default.posix.basename(phasesDir) === 'phases' &&
|
|
694
|
+
(planningDir === '.planning' || planningDir.startsWith('.planning/')) &&
|
|
695
|
+
!planningDir.includes('/../') &&
|
|
696
|
+
!roots.includes(planningDir)) {
|
|
697
|
+
roots.push(planningDir);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return roots;
|
|
701
|
+
}
|
|
268
702
|
/**
|
|
269
703
|
* #4623: the fingerprint version a stored `covered_digest` was computed
|
|
270
704
|
* under, or `null` when the prefix is absent, malformed, or names a version
|
|
@@ -278,101 +712,90 @@ function parseFingerprintVersion(digest) {
|
|
|
278
712
|
const version = Number(m[1]);
|
|
279
713
|
return KNOWN_FINGERPRINT_VERSIONS.has(version) ? version : null;
|
|
280
714
|
}
|
|
281
|
-
|
|
282
|
-
* #4155: recompute the deterministic content fingerprint over a verifier's
|
|
283
|
-
* declared covered-input set (phase PLAN/SUMMARY, mapped requirements,
|
|
284
|
-
* implementation files in the change set) and return the versioned digest
|
|
285
|
-
* string, or `null` if the set cannot be resolved.
|
|
286
|
-
*
|
|
287
|
-
* Determinism: paths are de-duplicated and SORTED before hashing (directory
|
|
288
|
-
* enumeration order is irrelevant), each path is resolved relative to
|
|
289
|
-
* `projectRoot` (the absolute checkout path never enters the digest), and
|
|
290
|
-
* file BYTES are hashed (mtime never enters the digest).
|
|
291
|
-
*
|
|
292
|
-
* NOT normalized: line endings. Unlike the report-frontmatter read (which
|
|
293
|
-
* runs every VERIFICATION.md through `normalizeLineEndings`), covered-file
|
|
294
|
-
* bytes are hashed exactly as they sit on disk. A covered text file checked
|
|
295
|
-
* out with CRLF line endings (e.g. a Windows checkout without a `.gitattributes
|
|
296
|
-
* eol=lf` rule pinning it to LF) hashes differently than the same file on an
|
|
297
|
-
* LF checkout — a real cross-platform digest mismatch, not a bug, since GSD
|
|
298
|
-
* installs into arbitrary user projects with no guaranteed line-ending policy.
|
|
299
|
-
*
|
|
300
|
-
|
|
301
|
-
* Fail closed: a covered path that is empty, absolute, escapes
|
|
302
|
-
* `projectRoot` (`..` traversal), or cannot be read (missing, unreadable,
|
|
303
|
-
* not a regular file) makes the WHOLE fingerprint unresolvable — returns
|
|
304
|
-
* `null` — rather than silently hashing a partial set. Callers treat `null`
|
|
305
|
-
* as stale (#4155), the same fail-closed shape #3057 B3 established for the
|
|
306
|
-
* legacy mtime staleness check.
|
|
307
|
-
*
|
|
308
|
-
* Always reads through the REAL `node:fs`, never a caller-injected `FsLike`
|
|
309
|
-
* seam — same reasoning as the root canonicalization below, extended to
|
|
310
|
-
* every covered file: `covered_files` is expected to span the whole
|
|
311
|
-
* `projectRoot` (implementation files under `src/`, not just `.planning/`
|
|
312
|
-
* artifacts), so a caller-scoped containment wrapper narrower than
|
|
313
|
-
* `projectRoot` (e.g. `planning-inspect.cts`'s `containmentEnforcingVerificationFs`,
|
|
314
|
-
* confined to `.planning/`) would reject every implementation-file read and
|
|
315
|
-
* report EVERY fingerprinted phase permanently `stale` regardless of actual
|
|
316
|
-
* drift — the bug this comment now documents against regressing. The
|
|
317
|
-
* `realRel`-vs-`realRoot` re-check a few lines below already does the real
|
|
318
|
-
* confinement work (against `projectRoot`, the correct boundary for this
|
|
319
|
-
* data), so no security property is lost by bypassing a narrower seam here.
|
|
320
|
-
*/
|
|
321
|
-
function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
715
|
+
function deriveCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
322
716
|
// #4623: `version` selects the input shape to hash under — the CURRENT
|
|
323
717
|
// one for a fresh fingerprint (the CLI verb), or the STORED one when
|
|
324
718
|
// `readVerificationStatus` recomputes against a report's own digest.
|
|
325
|
-
// `opts.phaseDir` lets v2 recognise the phase's own planning root
|
|
719
|
+
// `opts.phaseDir` lets v2+ recognise the phase's own planning root
|
|
326
720
|
// (`sharedPlanningRoots`); without it only `.planning/` itself is shared.
|
|
327
721
|
if (!KNOWN_FINGERPRINT_VERSIONS.has(version))
|
|
328
|
-
return null;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
722
|
+
return { ok: true, digest: null, files: [], mappedPhaseDir: null };
|
|
723
|
+
// #5095 (R1, ADR-5057 Phase 2): for v3+ with a known phaseDir, the hashed
|
|
724
|
+
// set is the declared list UNIONED with the phase's own live artifacts —
|
|
725
|
+
// computed here, ONCE, on BOTH the emit and check sides, rather than
|
|
726
|
+
// separately in the CLI emitter and again in here (#5095 R7: the prior
|
|
727
|
+
// shape ran `phaseArtifactPaths` twice on the emit path — once in
|
|
728
|
+
// `cmdVerificationFingerprint` to build the emitted `covered_files`, again
|
|
729
|
+
// in here to hash — a race window where a plan/summary added between the
|
|
730
|
+
// two calls could make the emitted list and the hashed set disagree). A
|
|
731
|
+
// plan/summary added after fingerprinting therefore moves the digest
|
|
732
|
+
// directly; `--raw` / MCP callers that write their own declared list
|
|
733
|
+
// (without enumerating plans/summaries) still match, because the checker
|
|
734
|
+
// adds the identical union. An artifact-resolution failure makes the WHOLE
|
|
735
|
+
// fingerprint unresolvable (fail closed).
|
|
736
|
+
let declared = canonicalizeCoveredFiles(coveredFiles, { version });
|
|
737
|
+
let artifactMappedPhaseDir = null;
|
|
738
|
+
if (version >= 3 && opts.phaseDir) {
|
|
739
|
+
const artifacts = phaseArtifactPaths(opts.phaseDir, projectRoot);
|
|
740
|
+
if (!artifacts.ok)
|
|
741
|
+
return { ok: false, reason: artifacts.reason };
|
|
742
|
+
artifactMappedPhaseDir = artifacts.mappedPhaseDir;
|
|
743
|
+
declared = canonicalizeCoveredFiles([...declared, ...artifacts.paths], { version });
|
|
744
|
+
}
|
|
745
|
+
if (declared.length === 0)
|
|
746
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
747
|
+
const sharedRoots = version >= 2 ? sharedRootsFor(projectRoot, opts.phaseDir, artifactMappedPhaseDir) : [];
|
|
333
748
|
let hashed = 0;
|
|
334
|
-
// Canonicalize the
|
|
335
|
-
//
|
|
336
|
-
// the REAL fs, never fsImpl: `projectRoot`
|
|
337
|
-
// derived (resolveProjectRoot), not
|
|
338
|
-
// routing it through a
|
|
749
|
+
// Canonicalize the roots ONCE — every candidate's realpath is checked
|
|
750
|
+
// against these, not the possibly-symlinked `projectRoot`/`.planning`
|
|
751
|
+
// arguments themselves. Always via the REAL fs, never fsImpl: `projectRoot`
|
|
752
|
+
// is a trusted anchor the CALLER derived (resolveProjectRoot), not
|
|
753
|
+
// attacker-influenced covered-input data — routing it through a
|
|
754
|
+
// caller-scoped containment seam (e.g. #4155's
|
|
339
755
|
// containmentEnforcingVerificationFs, confined to `.planning/`, a proper
|
|
340
756
|
// SUBSET of `projectRoot`) would reject the root itself and fail every
|
|
341
757
|
// lookup regardless of whether the covered files are legitimate.
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
}
|
|
346
|
-
catch {
|
|
347
|
-
return null;
|
|
348
|
-
}
|
|
758
|
+
const roots = resolveContainmentRoots(projectRoot);
|
|
759
|
+
if (roots.realRoot === null)
|
|
760
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
349
761
|
const parts = [];
|
|
350
|
-
for (const rel of
|
|
762
|
+
for (const rel of declared) {
|
|
351
763
|
// `normalizeRel` (already applied by `canonicalizeCoveredFiles` above)
|
|
352
764
|
// collapses internal `..` segments before `rel` ever reaches here
|
|
353
765
|
// (`a/../../b` → `../b`), so this start-of-string check is already the
|
|
354
766
|
// full lexical confinement test — no separate post-`path.resolve`
|
|
355
767
|
// re-check can observe a different answer.
|
|
356
|
-
if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel))
|
|
357
|
-
return null;
|
|
768
|
+
if (rel === '' || rel === '..' || rel.startsWith('../') || node_path_1.default.isAbsolute(rel)) {
|
|
769
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
770
|
+
}
|
|
771
|
+
// #5095 (R7): a `.planning`-spelled path may resolve inside the checkout
|
|
772
|
+
// root OR the LONGEST-prefix anchored planning scope that claims it
|
|
773
|
+
// (`bestPlanningScopeForRel`) — every other path is confined to the
|
|
774
|
+
// checkout root alone, exactly as before.
|
|
775
|
+
const firstSegment = rel.split('/')[0];
|
|
776
|
+
const admissibleRoots = firstSegment === '.planning'
|
|
777
|
+
? [roots.realRoot, bestPlanningScopeForRel(rel, roots.planningScopes)?.real ?? null].filter((r) => r !== null)
|
|
778
|
+
: [roots.realRoot];
|
|
358
779
|
const resolved = node_path_1.default.resolve(projectRoot, rel);
|
|
359
780
|
let bytes;
|
|
360
781
|
try {
|
|
361
|
-
// A regular file INSIDE
|
|
362
|
-
// escapes
|
|
363
|
-
// confinement check above is not enough.
|
|
364
|
-
// actual target; re-confining against
|
|
782
|
+
// A regular file INSIDE a known root can still be a symlink whose
|
|
783
|
+
// TARGET escapes every known root — statSync/readFileSync follow
|
|
784
|
+
// symlinks, so the lexical confinement check above is not enough.
|
|
785
|
+
// realpathSync resolves the actual target; re-confining against the
|
|
786
|
+
// admissible roots closes that gap.
|
|
365
787
|
const real = node_fs_1.default.realpathSync(resolved);
|
|
366
|
-
//
|
|
367
|
-
// above), so the shared containment comparison
|
|
368
|
-
// no re-resolution through
|
|
369
|
-
//
|
|
370
|
-
|
|
371
|
-
|
|
788
|
+
// Every operand is already realpath-resolved (this fn's own
|
|
789
|
+
// realpathSync calls above), so the shared containment comparison
|
|
790
|
+
// applies directly (ADR-4650) — no re-resolution through
|
|
791
|
+
// assertWithinRoot/tryWithinRoot, which would redo work this function
|
|
792
|
+
// already owns for its exists-vs-escaped tri-state.
|
|
793
|
+
if (!admissibleRoots.some((root) => (0, security_cjs_1.isContainedIn)(real, root))) {
|
|
794
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
372
795
|
}
|
|
373
796
|
const st = node_fs_1.default.statSync(real);
|
|
374
797
|
if (!st.isFile())
|
|
375
|
-
return null;
|
|
798
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
376
799
|
// #4623 (v2+): a repo-wide planning document is VALIDATED exactly as
|
|
377
800
|
// every other covered path — confined, present, a regular file; the
|
|
378
801
|
// fail-closed contract above is unchanged — but its bytes contribute
|
|
@@ -385,7 +808,7 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
385
808
|
bytes = node_fs_1.default.readFileSync(real);
|
|
386
809
|
}
|
|
387
810
|
catch {
|
|
388
|
-
return null;
|
|
811
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
389
812
|
}
|
|
390
813
|
const fileHash = node_crypto_1.default.createHash('sha256').update(bytes).digest('hex');
|
|
391
814
|
parts.push(`${rel}\n${fileHash}\n`);
|
|
@@ -395,13 +818,26 @@ function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_V
|
|
|
395
818
|
// evidence in it at all — a constant digest over the header would satisfy
|
|
396
819
|
// the fingerprint pair while grounding the verification in nothing. Fail
|
|
397
820
|
// closed, the same way an empty declaration does.
|
|
398
|
-
if (version >= 2 && hashed === 0)
|
|
399
|
-
return null;
|
|
821
|
+
if (version >= 2 && hashed === 0) {
|
|
822
|
+
return { ok: true, digest: null, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
823
|
+
}
|
|
400
824
|
const aggregate = node_crypto_1.default
|
|
401
825
|
.createHash('sha256')
|
|
402
826
|
.update(`v${version}\n${parts.join('')}`, 'utf-8')
|
|
403
827
|
.digest('hex');
|
|
404
|
-
return `v${version}:sha256:${aggregate}
|
|
828
|
+
return { ok: true, digest: `v${version}:sha256:${aggregate}`, files: declared, mappedPhaseDir: artifactMappedPhaseDir };
|
|
829
|
+
}
|
|
830
|
+
/**
|
|
831
|
+
* #4155/#5095: recompute the deterministic content fingerprint over a
|
|
832
|
+
* verifier's declared covered-input set, returning JUST the digest string
|
|
833
|
+
* (or `null` if unresolvable) — the signature every existing caller
|
|
834
|
+
* (`readVerificationStatus`, tests) already depends on. Thin wrapper over
|
|
835
|
+
* `deriveCoveredDigest`, collapsing its `{ ok: false, reason }` arm to `null`
|
|
836
|
+
* exactly as the pre-#5095 single-function shape did.
|
|
837
|
+
*/
|
|
838
|
+
function computeCoveredDigest(projectRoot, coveredFiles, version = FINGERPRINT_VERSION, opts = {}) {
|
|
839
|
+
const result = deriveCoveredDigest(projectRoot, coveredFiles, version, opts);
|
|
840
|
+
return result.ok ? result.digest : null;
|
|
405
841
|
}
|
|
406
842
|
/**
|
|
407
843
|
* #4155: the content fingerprint only recomputes digests for paths the
|
|
@@ -508,18 +944,159 @@ function defaultPhaseCleanCommitTimesMs(phaseDir, files, execGitFn = shell_comma
|
|
|
508
944
|
return commitTimes;
|
|
509
945
|
}
|
|
510
946
|
/**
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
947
|
+
* The one result builder (#5118): every `VerificationStatusResult` is
|
|
948
|
+
* projected from `VERIFICATION_ROUTES[status]` here — `route` (the bare
|
|
949
|
+
* command) and `next_command` (its runtime projection, #2617) come from the
|
|
950
|
+
* same table entry, so they cannot disagree.
|
|
514
951
|
*/
|
|
515
|
-
function
|
|
516
|
-
const
|
|
952
|
+
function routeResult(status, ctx) {
|
|
953
|
+
const entry = VERIFICATION_ROUTES[status];
|
|
517
954
|
return {
|
|
518
|
-
status
|
|
519
|
-
next_action:
|
|
520
|
-
next_command: projectNextCommand(
|
|
955
|
+
status,
|
|
956
|
+
next_action: entry.next_action,
|
|
957
|
+
next_command: projectNextCommand(entry.command, ctx.runtime, `${ctx.phaseArg}${entry.tail}`),
|
|
958
|
+
route: entry.command,
|
|
959
|
+
...(ctx.message !== undefined ? { message: ctx.message } : {}),
|
|
960
|
+
...(ctx.staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
521
961
|
};
|
|
522
962
|
}
|
|
963
|
+
/**
|
|
964
|
+
* ADR-5057 amendment 2 (#4987): true only when there is no phase DIRECTORY at
|
|
965
|
+
* `phaseDir` — `stat` fails with ENOENT / ENOTDIR (a dangling symlink stats as
|
|
966
|
+
* ENOENT), or it succeeds on something that is not a directory (a regular
|
|
967
|
+
* file). Any other stat failure — EACCES, or a code-less containment error
|
|
968
|
+
* from an injected FsLike (planning-inspect's seam) — is NOT "not found": the
|
|
969
|
+
* caller falls through to the existing readdir path (`missing`).
|
|
970
|
+
*/
|
|
971
|
+
function isPhaseDirNotFound(fsImpl, phaseDir) {
|
|
972
|
+
let st;
|
|
973
|
+
try {
|
|
974
|
+
st = fsImpl.statSync(phaseDir);
|
|
975
|
+
}
|
|
976
|
+
catch (err) {
|
|
977
|
+
const code = err?.code;
|
|
978
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
979
|
+
}
|
|
980
|
+
return typeof st?.isDirectory === 'function' && !st.isDirectory();
|
|
981
|
+
}
|
|
982
|
+
function phaseDirNotFoundMessage(phaseDir) {
|
|
983
|
+
return `Usage error: phase directory not found at ${io.formatDiagnosticToken(phaseDir)}`;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* The PHASES ROOT a phase directory must live under: the parent of `phaseDir`
|
|
987
|
+
* in its own (unresolved) spelling. It is the fixed anchor of the containment
|
|
988
|
+
* check — a phase directory symlinked outside the project resolves outside, so
|
|
989
|
+
* containing a report against that directory's OWN realpath alone would admit
|
|
990
|
+
* the escape (both resolve outside). A phases root that is itself a symlinked
|
|
991
|
+
* per-scope store resolves consistently on both sides of the comparison.
|
|
992
|
+
*/
|
|
993
|
+
function planningContainmentRoot(phaseDir) {
|
|
994
|
+
return node_path_1.default.dirname(node_path_1.default.resolve(phaseDir));
|
|
995
|
+
}
|
|
996
|
+
/**
|
|
997
|
+
* True when the phase directory really lives under its phases root AND
|
|
998
|
+
* `filePath` really lives inside that phase directory (symlinks followed on
|
|
999
|
+
* both). Either escape reads `missing`. Unresolvable → false.
|
|
1000
|
+
*/
|
|
1001
|
+
function isReportContained(phaseDir, filePath) {
|
|
1002
|
+
try {
|
|
1003
|
+
const realDir = node_fs_1.default.realpathSync(phaseDir);
|
|
1004
|
+
return (0, security_cjs_1.isContainedIn)(realDir, node_fs_1.default.realpathSync(planningContainmentRoot(phaseDir)))
|
|
1005
|
+
&& (0, security_cjs_1.isContainedIn)(node_fs_1.default.realpathSync(filePath), realDir);
|
|
1006
|
+
}
|
|
1007
|
+
catch {
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
/**
|
|
1012
|
+
* #5118: steps 0-2 of `readVerificationStatus` — no phase directory
|
|
1013
|
+
* (`phase_dir_not_found`), no report or no `status` (`missing`), a report
|
|
1014
|
+
* whose frontmatter is not YAML (`unparseable`), or the report's writer-set
|
|
1015
|
+
* status. Reads only the report's frontmatter (no staleness check, no git).
|
|
1016
|
+
* THROWS `VerificationStatusError` for a status outside the writer set — the
|
|
1017
|
+
* one judgement (`reportStatusOf`) every reader shares.
|
|
1018
|
+
*/
|
|
1019
|
+
function locatePhaseReport(phaseDir, fsImpl, convention) {
|
|
1020
|
+
if (isPhaseDirNotFound(fsImpl, phaseDir))
|
|
1021
|
+
return { kind: 'phase_dir_not_found' };
|
|
1022
|
+
const baseName = node_path_1.default.basename(phaseDir);
|
|
1023
|
+
let verificationFile = null;
|
|
1024
|
+
try {
|
|
1025
|
+
const entries = fsImpl.readdirSync(phaseDir);
|
|
1026
|
+
// #3492: pin selection to THIS phase's own token so a stray cross-phase
|
|
1027
|
+
// or sentinel-numbered canonically-shaped file cannot outrank this phase's
|
|
1028
|
+
// own report. #612: bracket directories resolve with the convention-aware
|
|
1029
|
+
// token. #4187: keep the bare report tier aligned with every other reader.
|
|
1030
|
+
const resolutionToken = convention === 'bracket'
|
|
1031
|
+
? extractPhaseToken(baseName, convention)
|
|
1032
|
+
: extractPhaseToken(baseName);
|
|
1033
|
+
verificationFile = resolveVerificationFile(entries, {
|
|
1034
|
+
allowBare: true,
|
|
1035
|
+
phaseToken: resolutionToken,
|
|
1036
|
+
phaseDirName: baseName,
|
|
1037
|
+
convention,
|
|
1038
|
+
});
|
|
1039
|
+
}
|
|
1040
|
+
catch {
|
|
1041
|
+
// Directory unreadable → treat as missing
|
|
1042
|
+
verificationFile = null;
|
|
1043
|
+
}
|
|
1044
|
+
if (!verificationFile)
|
|
1045
|
+
return { kind: 'missing' };
|
|
1046
|
+
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
1047
|
+
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
1048
|
+
// #5118 security review (containment): a report whose real path escapes its
|
|
1049
|
+
// own phase directory (a symlink out of the project) is refused BEFORE a
|
|
1050
|
+
// byte of it is read — it reads `missing`, and no value from it can ever
|
|
1051
|
+
// reach a message (the out-of-set error echoes the report's `status`). An
|
|
1052
|
+
// injected FsLike (planning-inspect's containment seam) enforces its own
|
|
1053
|
+
// containment by throwing, which the read below folds to `missing` too.
|
|
1054
|
+
if (fsImpl === defaultFsImpl && !isReportContained(phaseDir, filePath))
|
|
1055
|
+
return { kind: 'missing' };
|
|
1056
|
+
let fm = {};
|
|
1057
|
+
try {
|
|
1058
|
+
// #3707-CR: normalize line endings at this read boundary so a lone-CR
|
|
1059
|
+
// report's `---\r…\r---` fence still matches extractFrontmatter's check.
|
|
1060
|
+
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
|
|
1061
|
+
fm = extractFrontmatter(content, filePath);
|
|
1062
|
+
// #4806: an unparseable frontmatter block is NOT "missing" — the file
|
|
1063
|
+
// exists and verification ran; the caller is sent to fix the YAML.
|
|
1064
|
+
if (fm[FRONTMATTER_UNPARSEABLE] === true) {
|
|
1065
|
+
return { kind: 'unparseable' };
|
|
1066
|
+
}
|
|
1067
|
+
}
|
|
1068
|
+
catch {
|
|
1069
|
+
// An unreadable report reads as carrying no status (`missing`).
|
|
1070
|
+
fm = {};
|
|
1071
|
+
}
|
|
1072
|
+
// #5118: judged OUTSIDE the parse `try`, so an out-of-set value can never be
|
|
1073
|
+
// swallowed into `missing`.
|
|
1074
|
+
const status = reportStatusOf(fm, filePath);
|
|
1075
|
+
if (status === null)
|
|
1076
|
+
return { kind: 'missing' };
|
|
1077
|
+
return { kind: 'status', status, filePath, fm };
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* #5118 (ADR-5057 Phase 4, "no write before the error"): the first report
|
|
1081
|
+
* among `phaseDirs` whose `status` is outside the closed set, or `null`.
|
|
1082
|
+
* Frontmatter-only (the same locator `readVerificationStatus` runs), so a
|
|
1083
|
+
* command that WRITES validates every report it will read BEFORE its first
|
|
1084
|
+
* write and fails having written nothing.
|
|
1085
|
+
*/
|
|
1086
|
+
function findVerificationStatusError(phaseDirs, deps = {}) {
|
|
1087
|
+
const fsImpl = deps.fs ?? defaultFsImpl;
|
|
1088
|
+
for (const phaseDir of phaseDirs) {
|
|
1089
|
+
try {
|
|
1090
|
+
locatePhaseReport(phaseDir, fsImpl, deps.convention);
|
|
1091
|
+
}
|
|
1092
|
+
catch (err) {
|
|
1093
|
+
if (err instanceof VerificationStatusError)
|
|
1094
|
+
return err;
|
|
1095
|
+
throw err;
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
return null;
|
|
1099
|
+
}
|
|
523
1100
|
/**
|
|
524
1101
|
* #3518: the shared phase-pinned artifact-selection core BOTH single-pick
|
|
525
1102
|
* resolvers (`resolveVerificationFile` for `*-VERIFICATION.md`,
|
|
@@ -735,7 +1312,16 @@ function findStaleVerificationSummary(phaseDir, fsImpl = defaultFsImpl, phaseCle
|
|
|
735
1312
|
* 2. Extract `status` from FRONTMATTER ONLY via the shared extractFrontmatter
|
|
736
1313
|
* parser (DEFECT.FRONTMATTER-SCALAR-BROAD-GREP fix — parser anchors at byte 0).
|
|
737
1314
|
* If no frontmatter block or no `status` key → status 'missing'.
|
|
738
|
-
* 3.
|
|
1315
|
+
* 3. Route through VERIFICATION_ROUTES (`routeResult`). A status outside the
|
|
1316
|
+
* writer set (`VERIFIER_STATUSES`) — any other string, case variant,
|
|
1317
|
+
* reader-only member, or non-string value — THROWS `VerificationStatusError`
|
|
1318
|
+
* (#5118). The throw sits outside the parse `try`, and before the
|
|
1319
|
+
* `gaps_found` short-circuit and the staleness check, so it can neither be
|
|
1320
|
+
* folded into `missing` nor masked by `stale` (#4817 Part 2).
|
|
1321
|
+
*
|
|
1322
|
+
* #5118 / ADR-5057 amendment 2: a path with no phase DIRECTORY behind it
|
|
1323
|
+
* (ENOENT, ENOTDIR, a non-directory) reads `phase_dir_not_found` — a usage
|
|
1324
|
+
* error with no next command — not `missing` (#4987).
|
|
739
1325
|
*
|
|
740
1326
|
* The internal staleness check can itself fail (fs / scanPhasePlans / clock
|
|
741
1327
|
* error); when it does, `status` is routed as if nothing were stale (the
|
|
@@ -769,84 +1355,26 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
769
1355
|
// that already know the number (init) pass it explicitly and always get it.
|
|
770
1356
|
const phaseArgSource = opts.phaseNumber ?? (/^\d+(\.\d+)*$/.test(derivedPhaseNumber) ? derivedPhaseNumber : '');
|
|
771
1357
|
const phaseArg = phaseArgSource ? ` ${phaseArgSource}` : '';
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
const resolutionToken = opts.convention === 'bracket'
|
|
783
|
-
? extractPhaseToken(baseName, opts.convention)
|
|
784
|
-
: phaseToken;
|
|
785
|
-
verificationFile = resolveVerificationFile(entries, {
|
|
786
|
-
allowBare: true,
|
|
787
|
-
phaseToken: resolutionToken,
|
|
788
|
-
phaseDirName: baseName,
|
|
789
|
-
convention: opts.convention,
|
|
790
|
-
});
|
|
791
|
-
}
|
|
792
|
-
catch {
|
|
793
|
-
// Directory unreadable → treat as missing
|
|
794
|
-
verificationFile = null;
|
|
795
|
-
}
|
|
796
|
-
if (!verificationFile) {
|
|
797
|
-
return missingResult(runtime, phaseArg);
|
|
798
|
-
}
|
|
799
|
-
// 2. Read and parse frontmatter using the shared parser.
|
|
800
|
-
// extractFrontmatter anchors at byte 0, so body `status:` lines are ignored.
|
|
801
|
-
const filePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
802
|
-
let rawStatus = null;
|
|
803
|
-
let fm = {};
|
|
804
|
-
try {
|
|
805
|
-
// #3707-CR follow-up MINOR 1: normalize line endings at this read
|
|
806
|
-
// boundary — this function's own `readFileSync` is the equivalent seam
|
|
807
|
-
// `planning.inspect`'s `buildUatRows`/`readDocument` route through for
|
|
808
|
-
// UAT/REQUIREMENTS documents, but `readVerificationStatus` had no such
|
|
809
|
-
// normalization of its own. A lone-CR VERIFICATION.md's `---\r...\r---`
|
|
810
|
-
// frontmatter fence never matched `extractFrontmatter`'s byte-0
|
|
811
|
-
// `---\n`/`---\r\n` check, so `status: passed` was read as absent and
|
|
812
|
-
// this function reported 'missing' — under-reporting a completed
|
|
813
|
-
// verification as if the step never ran, the fail-safe direction but the
|
|
814
|
-
// same root cause as the false-clean class fixed elsewhere in #3707-CR.
|
|
815
|
-
const content = normalizeLineEndings(fsImpl.readFileSync(filePath, 'utf-8'));
|
|
816
|
-
fm = extractFrontmatter(content, filePath);
|
|
817
|
-
// #4806: an unparseable frontmatter block is NOT "missing" — the file
|
|
818
|
-
// exists and verification ran. Report a distinct status so the caller is
|
|
819
|
-
// sent to fix the YAML, not to re-run execute-phase.
|
|
820
|
-
if (fm[FRONTMATTER_UNPARSEABLE] === true) {
|
|
821
|
-
return {
|
|
822
|
-
status: 'unparseable',
|
|
823
|
-
next_action: "The *-VERIFICATION.md frontmatter is not parseable YAML — fix the syntax error in the report itself. Re-running execute-phase cannot fix a YAML typo in an existing report.",
|
|
824
|
-
next_command: '',
|
|
825
|
-
};
|
|
826
|
-
}
|
|
827
|
-
const statusVal = fm['status'];
|
|
828
|
-
// status is always a scalar string in a well-formed VERIFICATION.md frontmatter;
|
|
829
|
-
// only accept string values — arrays and objects are not valid status values.
|
|
830
|
-
if (typeof statusVal === 'string') {
|
|
831
|
-
const trimmed = statusVal.trim();
|
|
832
|
-
rawStatus = trimmed.length > 0 ? trimmed : null;
|
|
833
|
-
}
|
|
834
|
-
}
|
|
835
|
-
catch {
|
|
836
|
-
rawStatus = null;
|
|
837
|
-
}
|
|
838
|
-
if (!rawStatus) {
|
|
839
|
-
return missingResult(runtime, phaseArg);
|
|
1358
|
+
const route = (status, extra = {}) => routeResult(status, { runtime, phaseArg, ...extra });
|
|
1359
|
+
// Steps 0-2 (no phase dir → usage error; find the report; parse its
|
|
1360
|
+
// frontmatter and judge `status`) are the frontmatter-only locator shared
|
|
1361
|
+
// with the pre-write validator (`findVerificationStatusError`). An
|
|
1362
|
+
// out-of-set status THROWS VerificationStatusError out of the locator —
|
|
1363
|
+
// before the gaps_found short-circuit and the staleness check below, so
|
|
1364
|
+
// `stale` can never mask it (#4817 Part 2).
|
|
1365
|
+
const located = locatePhaseReport(phaseDir, fsImpl, opts.convention);
|
|
1366
|
+
if (located.kind === 'phase_dir_not_found') {
|
|
1367
|
+
return route(VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND, { message: phaseDirNotFoundMessage(phaseDir) });
|
|
840
1368
|
}
|
|
1369
|
+
if (located.kind === 'missing')
|
|
1370
|
+
return route(VERIFICATION_STATUS.MISSING);
|
|
1371
|
+
if (located.kind === 'unparseable')
|
|
1372
|
+
return route(VERIFICATION_STATUS.UNPARSEABLE);
|
|
1373
|
+
const { status: reportStatus, fm } = located;
|
|
841
1374
|
// gaps_found takes priority over stale — gap closure is the correct next
|
|
842
1375
|
// step regardless of whether summaries are newer than the verification file.
|
|
843
|
-
if (
|
|
844
|
-
|
|
845
|
-
return {
|
|
846
|
-
status: entry.status,
|
|
847
|
-
next_action: entry.next_action,
|
|
848
|
-
next_command: projectNextCommand('plan-phase', runtime, `${phaseArg} --gaps`),
|
|
849
|
-
};
|
|
1376
|
+
if (reportStatus === VERIFICATION_STATUS.GAPS_FOUND) {
|
|
1377
|
+
return route(VERIFICATION_STATUS.GAPS_FOUND);
|
|
850
1378
|
}
|
|
851
1379
|
// #4155: a report that declares a covered-input fingerprint is checked by
|
|
852
1380
|
// RECOMPUTING that fingerprint over current file content — strictly
|
|
@@ -880,18 +1408,33 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
880
1408
|
// runs once the digest itself has already matched.
|
|
881
1409
|
//
|
|
882
1410
|
// #4623: recompute under the STORED digest's own version, not the
|
|
883
|
-
// current constant — a v1 report written before
|
|
884
|
-
//
|
|
1411
|
+
// current constant — a v1/v2 report written before a later semantics
|
|
1412
|
+
// change keeps its own semantics rather than going stale on upgrade. An
|
|
885
1413
|
// unknown version parses to `null`, which `computeCoveredDigest`
|
|
886
1414
|
// refuses (returns `null`), so the compare below fails closed.
|
|
1415
|
+
const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
|
|
887
1416
|
const storedVersion = hasWellFormedFingerprint && typeof coveredDigestVal === 'string'
|
|
888
1417
|
? parseFingerprintVersion(coveredDigestVal)
|
|
889
1418
|
: null;
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
1419
|
+
if (!hasWellFormedFingerprint || storedVersion === null) {
|
|
1420
|
+
isStale = true;
|
|
1421
|
+
}
|
|
1422
|
+
else if (storedVersion >= 3) {
|
|
1423
|
+
// #5095 (R1): v3's digest is computed over the declared set UNIONED
|
|
1424
|
+
// with the phase's own live artifacts — a plan/summary added after
|
|
1425
|
+
// fingerprinting already moves the digest itself, so the separate
|
|
1426
|
+
// live-directory re-scan (`allCurrentArtifactsCovered`) is redundant
|
|
1427
|
+
// for v3 and is not run.
|
|
1428
|
+
isStale =
|
|
1429
|
+
computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal;
|
|
1430
|
+
}
|
|
1431
|
+
else {
|
|
1432
|
+
// v1/v2: unchanged — the digest alone cannot see a plan/summary that
|
|
1433
|
+
// was never declared, so the live-directory re-scan still runs.
|
|
1434
|
+
isStale =
|
|
1435
|
+
computeCoveredDigest(projectRoot, coveredFilesVal, storedVersion, { phaseDir }) !== coveredDigestVal ||
|
|
1436
|
+
!allCurrentArtifactsCovered(phaseDir, coveredFilesVal);
|
|
1437
|
+
}
|
|
895
1438
|
}
|
|
896
1439
|
else {
|
|
897
1440
|
const staleCheck = findStaleVerificationSummary(phaseDir, fsImpl, phaseCleanCommitTimesMs, opts.convention);
|
|
@@ -904,39 +1447,12 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
904
1447
|
staleCheckIndeterminate = !staleCheck.determined;
|
|
905
1448
|
}
|
|
906
1449
|
if (isStale) {
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
// routing the `missing` sentinel has used since #2868.
|
|
914
|
-
next_command: projectNextCommand('execute-phase', runtime, phaseArg),
|
|
915
|
-
};
|
|
916
|
-
}
|
|
917
|
-
// 3. Route — exclude internal sentinels from raw-file lookup (they are
|
|
918
|
-
// constructed internally above, never written by the verifier).
|
|
919
|
-
if (rawStatus in VERIFICATION_ROUTING_TABLE &&
|
|
920
|
-
rawStatus !== 'missing' &&
|
|
921
|
-
rawStatus !== 'unknown' &&
|
|
922
|
-
rawStatus !== 'stale' &&
|
|
923
|
-
rawStatus !== 'gaps_found') {
|
|
924
|
-
const entry = VERIFICATION_ROUTING_TABLE[rawStatus];
|
|
925
|
-
return {
|
|
926
|
-
status: entry.status,
|
|
927
|
-
next_action: entry.next_action,
|
|
928
|
-
next_command: projectNextCommand(entry.next_command, runtime, phaseArg),
|
|
929
|
-
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
930
|
-
};
|
|
931
|
-
}
|
|
932
|
-
// Unknown value
|
|
933
|
-
const unknownRoute = VERIFICATION_ROUTING_TABLE['unknown'];
|
|
934
|
-
return {
|
|
935
|
-
status: unknownRoute.status,
|
|
936
|
-
next_action: `Unexpected verification status '${rawStatus}'. If this is an intentional non-standard marker (e.g. a hand-set failed/superseded state), no action is needed. Otherwise, run execute-phase to regenerate verification — it will not re-run plans that already have a SUMMARY.md.`,
|
|
937
|
-
next_command: projectNextCommand(unknownRoute.next_command, runtime, phaseArg),
|
|
938
|
-
...(staleCheckIndeterminate ? { staleCheckIndeterminate: true } : {}),
|
|
939
|
-
};
|
|
1450
|
+
// #4682 / #5118: the one stale route — execute-phase's verify_phase_goal
|
|
1451
|
+
// step re-runs the verifier, regenerating VERIFICATION.md and its digest.
|
|
1452
|
+
return route(VERIFICATION_STATUS.STALE);
|
|
1453
|
+
}
|
|
1454
|
+
// 3. Route the writer-set member through the one table.
|
|
1455
|
+
return route(reportStatus, { staleCheckIndeterminate });
|
|
940
1456
|
}
|
|
941
1457
|
/**
|
|
942
1458
|
* isPhaseComplete — the single canonical owner of "is phase P complete?"
|
|
@@ -949,11 +1465,18 @@ function readVerificationStatus(phaseDir, opts = {}) {
|
|
|
949
1465
|
* `*-VERIFICATION.md` is complete (#3168). A ROADMAP checkbox has no machine
|
|
950
1466
|
* authority and is never consulted — this function never reads ROADMAP.md.
|
|
951
1467
|
*
|
|
952
|
-
* `complete` is exactly `verification.status ===
|
|
953
|
-
* carries the FULL routing result
|
|
954
|
-
* caller can distinguish a
|
|
955
|
-
* `stale
|
|
956
|
-
* complete", but they are not the same
|
|
1468
|
+
* `complete` is exactly `verification.status === VERIFICATION_STATUS.PASSED`.
|
|
1469
|
+
* `verification` carries the FULL routing result
|
|
1470
|
+
* (status/next_action/next_command/route), so a caller can distinguish a
|
|
1471
|
+
* failing verdict (`gaps_found`/`human_needed`/`stale`) from an absent one
|
|
1472
|
+
* (`missing`) — both are "not complete", but they are not the same
|
|
1473
|
+
* non-answer.
|
|
1474
|
+
*
|
|
1475
|
+
* #5118: a report whose `status` is outside the closed set does NOT throw
|
|
1476
|
+
* out of here (ADR-5057 :223 — the no-throw contract Phases 2–4 preserve): it
|
|
1477
|
+
* degrades to scope UNREADABLE, `verification.status` is `null` (route `''`,
|
|
1478
|
+
* the error's message as `next_action`), `complete` is false, and
|
|
1479
|
+
* `value.statusError` holds the typed error for the caller to carry.
|
|
957
1480
|
*
|
|
958
1481
|
* `scope` is UNREADABLE when `phaseDir` itself could not be listed — this is
|
|
959
1482
|
* INDEPENDENT of readVerificationStatus's own no-throw fail-open contract for
|
|
@@ -976,17 +1499,29 @@ function isPhaseComplete(phaseDir, deps = {}) {
|
|
|
976
1499
|
catch {
|
|
977
1500
|
readable = false;
|
|
978
1501
|
}
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
1502
|
+
let verification;
|
|
1503
|
+
let statusError;
|
|
1504
|
+
try {
|
|
1505
|
+
verification = readVerificationStatus(phaseDir, {
|
|
1506
|
+
fs: deps.fs,
|
|
1507
|
+
phaseCleanCommitTimesMs: deps.phaseCleanCommitTimesMs,
|
|
1508
|
+
runtime: deps.runtime,
|
|
1509
|
+
phaseNumber: deps.phaseNumber,
|
|
1510
|
+
convention: deps.convention,
|
|
1511
|
+
});
|
|
1512
|
+
}
|
|
1513
|
+
catch (err) {
|
|
1514
|
+
if (!(err instanceof VerificationStatusError))
|
|
1515
|
+
throw err;
|
|
1516
|
+
statusError = err;
|
|
1517
|
+
readable = false;
|
|
1518
|
+
verification = { status: null, next_action: err.message, next_command: '', route: '' };
|
|
1519
|
+
}
|
|
986
1520
|
return {
|
|
987
1521
|
value: {
|
|
988
|
-
complete: verification.status ===
|
|
1522
|
+
complete: verification.status === VERIFICATION_STATUS.PASSED,
|
|
989
1523
|
verification,
|
|
1524
|
+
...(statusError ? { statusError } : {}),
|
|
990
1525
|
},
|
|
991
1526
|
scope: readable ? SCOPE.COMPLETE : SCOPE.UNREADABLE,
|
|
992
1527
|
};
|
|
@@ -995,6 +1530,13 @@ function isPhaseComplete(phaseDir, deps = {}) {
|
|
|
995
1530
|
* CLI command handler: resolve phaseDir against cwd, call readVerificationStatus,
|
|
996
1531
|
* emit via io.output().
|
|
997
1532
|
*
|
|
1533
|
+
* #5118: an out-of-set report status throws `VerificationStatusError` out of
|
|
1534
|
+
* here with nothing on stdout; gsd-tools.cjs translates it (once, centrally)
|
|
1535
|
+
* into ERROR_REASON `verification_status_invalid`. A nonexistent phase
|
|
1536
|
+
* directory is an ANSWER (`phase_dir_not_found`, `route: ''`, a `message`,
|
|
1537
|
+
* no `error` field — so `--pick status` prints it and the run is not
|
|
1538
|
+
* DEGRADED), not a failure.
|
|
1539
|
+
*
|
|
998
1540
|
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
999
1541
|
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
1000
1542
|
* @param raw - Whether to emit raw (non-JSON) output.
|
|
@@ -1023,6 +1565,11 @@ function cmdVerificationStatus(cwd, phaseDirArg, raw) {
|
|
|
1023
1565
|
* bare path string (possibly empty) so `VAR=$(gsd_run query
|
|
1024
1566
|
* verification.resolve-file "$PHASE_DIR" --raw)` is directly assignable.
|
|
1025
1567
|
*
|
|
1568
|
+
* #5118 (#4987 item 2): a path with no phase directory behind it adds the
|
|
1569
|
+
* marker `status: "phase_dir_not_found"` and a `message` — never an `error`
|
|
1570
|
+
* field (that would declare DEGRADED) — so it is distinguishable from an
|
|
1571
|
+
* existing directory with no report, where `""` alone keeps its meaning.
|
|
1572
|
+
*
|
|
1026
1573
|
* @param cwd - Current working directory (used to resolve phaseDirArg).
|
|
1027
1574
|
* @param phaseDirArg - Phase directory path (absolute or relative to cwd).
|
|
1028
1575
|
* @param raw - Whether to emit raw (non-JSON) output.
|
|
@@ -1033,6 +1580,14 @@ function cmdVerificationResolveFile(cwd, phaseDirArg, raw) {
|
|
|
1033
1580
|
return;
|
|
1034
1581
|
}
|
|
1035
1582
|
const phaseDir = node_path_1.default.resolve(cwd, phaseDirArg);
|
|
1583
|
+
if (isPhaseDirNotFound(defaultFsImpl, phaseDir)) {
|
|
1584
|
+
output({
|
|
1585
|
+
verification_file: '',
|
|
1586
|
+
status: VERIFICATION_STATUS.PHASE_DIR_NOT_FOUND,
|
|
1587
|
+
message: phaseDirNotFoundMessage(phaseDir),
|
|
1588
|
+
}, raw, '');
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1036
1591
|
let verificationPath = '';
|
|
1037
1592
|
try {
|
|
1038
1593
|
const entries = node_fs_1.default.readdirSync(phaseDir);
|
|
@@ -1136,9 +1691,16 @@ function parseFingerprintFileArgs(tokens) {
|
|
|
1136
1691
|
* @param raw - Whether to emit raw (non-JSON) output: just the
|
|
1137
1692
|
* `covered_digest` string, so `VAR=$(gsd_run query
|
|
1138
1693
|
* verification.fingerprint "$PHASE_DIR" ... --raw)` is
|
|
1139
|
-
* directly assignable. `
|
|
1140
|
-
*
|
|
1141
|
-
*
|
|
1694
|
+
* directly assignable. #5095 (ADR-5057 Phase 2): `raw`
|
|
1695
|
+
* returning only the digest is safe even though the
|
|
1696
|
+
* emitted `covered_files` is a SUPERSET of the caller's
|
|
1697
|
+
* declared list — the v3 digest is computed over
|
|
1698
|
+
* `canonicalize(declared ∪ phaseArtifactPaths)` on BOTH
|
|
1699
|
+
* the emit side (here) and the check side
|
|
1700
|
+
* (`readVerificationStatus`), so a `--raw` caller that
|
|
1701
|
+
* writes its OWN declared list (without enumerating
|
|
1702
|
+
* plans/summaries) alongside the raw digest still
|
|
1703
|
+
* matches — the checker adds the identical union.
|
|
1142
1704
|
*/
|
|
1143
1705
|
function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
|
|
1144
1706
|
if (!phaseDirArg) {
|
|
@@ -1168,37 +1730,322 @@ function cmdVerificationFingerprint(cwd, phaseDirArg, fileArgs, raw) {
|
|
|
1168
1730
|
return;
|
|
1169
1731
|
}
|
|
1170
1732
|
const projectRoot = (0, project_root_cjs_1.resolveProjectRoot)(phaseDir);
|
|
1171
|
-
// canonicalizeCoveredFiles here is for
|
|
1172
|
-
//
|
|
1173
|
-
//
|
|
1174
|
-
//
|
|
1175
|
-
//
|
|
1176
|
-
//
|
|
1177
|
-
const
|
|
1178
|
-
|
|
1733
|
+
// canonicalizeCoveredFiles here is for `deriveCoveredDigest`'s
|
|
1734
|
+
// `coveredFiles` argument — it canonicalizes internally too (it must, for
|
|
1735
|
+
// callers like readVerificationStatus that pass raw, un-canonicalized
|
|
1736
|
+
// frontmatter values), so passing an already-canonical list keeps that
|
|
1737
|
+
// internal pass a cheap no-op rather than a second meaningfully different
|
|
1738
|
+
// canonicalization.
|
|
1739
|
+
const declaredCanonical = canonicalizeCoveredFiles(files, { version: FINGERPRINT_VERSION });
|
|
1740
|
+
// #5095 (R1/R2/R7, ADR-5057 Phase 2): ONE call derives both the emitted
|
|
1741
|
+
// `covered_files` (`.files`, the declared set unioned with the phase's own
|
|
1742
|
+
// live artifacts) and the hashed digest (`.digest`) — the CLI no longer
|
|
1743
|
+
// runs `phaseArtifactPaths` itself and again inside the digest computation,
|
|
1744
|
+
// which used to leave a race window where the two calls could see a
|
|
1745
|
+
// different phase directory and disagree. An artifact-resolution failure
|
|
1746
|
+
// fails the WHOLE command (fail closed: the emitter cannot vouch for a set
|
|
1747
|
+
// it could not fully see).
|
|
1748
|
+
const derivation = deriveCoveredDigest(projectRoot, declaredCanonical, FINGERPRINT_VERSION, { phaseDir });
|
|
1749
|
+
if (!derivation.ok) {
|
|
1750
|
+
error(`could not compute fingerprint — ${derivation.reason}`);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const unionSorted = derivation.files;
|
|
1754
|
+
if (unionSorted.length === 0) {
|
|
1755
|
+
error('at least one covered file required for verification.fingerprint');
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const digest = derivation.digest;
|
|
1179
1759
|
if (digest === null) {
|
|
1180
1760
|
// #4623: name the one null that is NOT a bad path — a declaration made
|
|
1181
|
-
// only of shared planning documents
|
|
1182
|
-
// generic message below would send the caller
|
|
1183
|
-
// that is not missing. Discriminated AFTER
|
|
1184
|
-
// v1 pass over the same list
|
|
1185
|
-
//
|
|
1186
|
-
// a
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1761
|
+
// only of shared planning documents (with no other evidence) hashes
|
|
1762
|
+
// nothing under v2+, and the generic message below would send the caller
|
|
1763
|
+
// looking for a missing file that is not missing. Discriminated AFTER
|
|
1764
|
+
// the versioned attempt, and only when a v1 pass over the same list
|
|
1765
|
+
// (which hashes, and therefore validates, every path) succeeds: an
|
|
1766
|
+
// all-shared list with a missing or directory member is a bad path
|
|
1767
|
+
// first, and gets the generic message. #5095 (R5): a phase WITH real
|
|
1768
|
+
// artifacts never reaches this branch — the union above already supplied
|
|
1769
|
+
// evidence — so this error is reachable only when every declared path is
|
|
1770
|
+
// a shared planning doc AND the phase has no plans/summaries of its own.
|
|
1771
|
+
const sharedRoots = sharedRootsFor(projectRoot, phaseDir, derivation.mappedPhaseDir);
|
|
1772
|
+
if (unionSorted.every((f) => isSharedPlanningDoc(f, sharedRoots)) &&
|
|
1773
|
+
computeCoveredDigest(projectRoot, unionSorted, 1) !== null) {
|
|
1190
1774
|
error(`could not compute fingerprint — every covered file is a repo-wide planning document (direct children of ${sharedRoots.join(', ')} never enter the digest); declare the phase's own artifacts and implementation files`);
|
|
1191
1775
|
return;
|
|
1192
1776
|
}
|
|
1193
1777
|
error('could not compute fingerprint — a covered file is missing, unreadable, or escapes the project root');
|
|
1194
1778
|
return;
|
|
1195
1779
|
}
|
|
1196
|
-
output({ covered_files:
|
|
1780
|
+
output({ covered_files: unionSorted, covered_digest: digest }, raw, digest);
|
|
1781
|
+
}
|
|
1782
|
+
// ─── verification.append-audit (#5105 R3) ──────────────────────────────────
|
|
1783
|
+
/**
|
|
1784
|
+
* Render a single `## <heading> <date>` block followed by a `| Metric |
|
|
1785
|
+
* Count |` table over `rows` — the exact shape `secure-phase.md` /
|
|
1786
|
+
* `validate-phase.md` compose by hand today (#4887 Defect 2, #4981).
|
|
1787
|
+
*/
|
|
1788
|
+
function renderAuditBlock(heading, date, rows) {
|
|
1789
|
+
const lines = [`## ${heading} ${date}`, '', '| Metric | Count |', '|---|---|'];
|
|
1790
|
+
for (const [k, v] of Object.entries(rows))
|
|
1791
|
+
lines.push(`| ${k} | ${String(v)} |`);
|
|
1792
|
+
return lines.join('\n') + '\n';
|
|
1793
|
+
}
|
|
1794
|
+
/**
|
|
1795
|
+
* #5105 (S4): parse a block body's `| Metric | Count |`-shaped table into a
|
|
1796
|
+
* plain metric→count string map, addressed by the table's ACTUAL first/second
|
|
1797
|
+
* column (never a hard-coded `Metric`/`Count` name) so a legacy block with
|
|
1798
|
+
* different header text still compares. Whitespace/separator-width tolerant
|
|
1799
|
+
* by construction — `parseMarkdownTable` trims every cell and accepts any
|
|
1800
|
+
* `-{1,}` delimiter width. Returns `null` when the body carries no parseable
|
|
1801
|
+
* 2+-column table (no prior block to compare against).
|
|
1802
|
+
*/
|
|
1803
|
+
function parseAuditTableRows(bodyText) {
|
|
1804
|
+
const parsed = (0, markdown_table_cjs_1.parseMarkdownTable)(bodyText);
|
|
1805
|
+
if (!parsed.ok || parsed.value.columns.length < 2)
|
|
1806
|
+
return null;
|
|
1807
|
+
const [metricCol, countCol] = parsed.value.columns;
|
|
1808
|
+
const map = {};
|
|
1809
|
+
for (const row of parsed.value.rows) {
|
|
1810
|
+
map[row[metricCol]] = String(row[countCol]).trim();
|
|
1811
|
+
}
|
|
1812
|
+
return map;
|
|
1813
|
+
}
|
|
1814
|
+
/** Order-insensitive equality over two metric→count maps. */
|
|
1815
|
+
function auditRowsEqual(live, candidate) {
|
|
1816
|
+
if (!live)
|
|
1817
|
+
return false;
|
|
1818
|
+
const liveKeys = Object.keys(live);
|
|
1819
|
+
const candidateKeys = Object.keys(candidate);
|
|
1820
|
+
if (liveKeys.length !== candidateKeys.length)
|
|
1821
|
+
return false;
|
|
1822
|
+
return liveKeys.every((k) => Object.prototype.hasOwnProperty.call(candidate, k) && live[k] === candidate[k]);
|
|
1823
|
+
}
|
|
1824
|
+
/**
|
|
1825
|
+
* #5105 R3 — pure core of `verification.append-audit`.
|
|
1826
|
+
*
|
|
1827
|
+
* Finds the LAST `## <heading> <date>` block in `content` — a level-2
|
|
1828
|
+
* heading whose text matches `^<heading> (\d{4}-\d{2}-\d{2})\b` — via the
|
|
1829
|
+
* shared, fence-aware `tokenizeHeadings`/`collectSection` primitives (#5105
|
|
1830
|
+
* S6) instead of a hand-rolled `^## ` scan: a `## <heading> <date>`-looking
|
|
1831
|
+
* line inside a fenced code block is not a heading and cannot be selected,
|
|
1832
|
+
* and a heading whose trailing word ISN'T a date (e.g. the template's bare
|
|
1833
|
+
* `## Security Audit Trail`) is not matched either (#5105 S4 — the anchored
|
|
1834
|
+
* heading date shape, not `(\S+)`, is what excludes it).
|
|
1835
|
+
*
|
|
1836
|
+
* Comparison (#5105 S4) is over the block's PARSED table rows
|
|
1837
|
+
* (`parseAuditTableRows`/`auditRowsEqual`) — whitespace/separator-width
|
|
1838
|
+
* insensitive, order-insensitive, and tolerant of an optional blank line
|
|
1839
|
+
* after the heading — never a byte-for-byte body string compare. Identical
|
|
1840
|
+
* rows on the last block → `{ appended: false }`, no write. Different rows
|
|
1841
|
+
* (or no prior block) → appends the new block at the end and returns
|
|
1842
|
+
* `{ appended: true }`.
|
|
1843
|
+
*
|
|
1844
|
+
* Deliberately compares against the LAST block only, never any earlier one —
|
|
1845
|
+
* a re-audit that regresses back to an earlier count must still append.
|
|
1846
|
+
*
|
|
1847
|
+
* `date` defaults through the `clock` seam (default: the global `Date`
|
|
1848
|
+
* constructor) rather than a bare `new Date()` call, so a caller can pin the
|
|
1849
|
+
* date deterministically — directly (pass `clock`) or via `node:test`
|
|
1850
|
+
* `mock.timers` (which replaces global `Date`, picked up automatically since
|
|
1851
|
+
* the default is evaluated per call).
|
|
1852
|
+
*/
|
|
1853
|
+
function planAuditAppend(content, { heading, rows, date, clock = Date }) {
|
|
1854
|
+
const resolvedDate = date ?? new clock().toISOString().slice(0, 10);
|
|
1855
|
+
const escapedHeading = (0, pattern_cjs_1.escapeRegex)(heading);
|
|
1856
|
+
const headingRe = new RegExp(`^${escapedHeading} (\\d{4}-\\d{2}-\\d{2})\\b`);
|
|
1857
|
+
const matchingHeadings = (0, markdown_sectionizer_cjs_1.tokenizeHeadings)(content).filter((h) => h.level === 2 && headingRe.test(h.text));
|
|
1858
|
+
const newBlock = renderAuditBlock(heading, resolvedDate, rows);
|
|
1859
|
+
const newRowsMap = {};
|
|
1860
|
+
for (const [k, v] of Object.entries(rows))
|
|
1861
|
+
newRowsMap[k] = String(v);
|
|
1862
|
+
if (matchingHeadings.length > 0) {
|
|
1863
|
+
const last = matchingHeadings[matchingHeadings.length - 1];
|
|
1864
|
+
const section = (0, markdown_sectionizer_cjs_1.collectSection)(content, (h) => h.offset === last.offset);
|
|
1865
|
+
const liveRows = section ? parseAuditTableRows(section.body) : null;
|
|
1866
|
+
if (auditRowsEqual(liveRows, newRowsMap)) {
|
|
1867
|
+
return { appended: false, content };
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
const trimmed = content.replace(/\s+$/, '');
|
|
1871
|
+
const appendedContent = (trimmed.length > 0 ? trimmed + '\n\n' : '') + newBlock;
|
|
1872
|
+
return { appended: true, content: appendedContent };
|
|
1873
|
+
}
|
|
1874
|
+
/** Reject a `\r`, `\n`, or `|` — any of the three would corrupt the rendered heading/table shape. */
|
|
1875
|
+
function hasForbiddenAuditChar(s) {
|
|
1876
|
+
return /[\r\n|]/.test(s);
|
|
1877
|
+
}
|
|
1878
|
+
/** `rows` values must be a non-negative integer, as either a JSON number or an all-digit string. */
|
|
1879
|
+
function isNonNegativeIntegerValue(v) {
|
|
1880
|
+
if (typeof v === 'number')
|
|
1881
|
+
return Number.isInteger(v) && v >= 0;
|
|
1882
|
+
if (typeof v === 'string')
|
|
1883
|
+
return /^\d+$/.test(v);
|
|
1884
|
+
return false;
|
|
1885
|
+
}
|
|
1886
|
+
/** Reject a value carrying leading/trailing whitespace — a heading or row key
|
|
1887
|
+
* with padding would not match `parseMarkdownTable`'s trimmed reads on a
|
|
1888
|
+
* later append, so the same key would silently fail to be recognized as the
|
|
1889
|
+
* "already present" row (re-review finding 6). */
|
|
1890
|
+
function hasLeadingOrTrailingWhitespace(s) {
|
|
1891
|
+
return s !== s.trim();
|
|
1892
|
+
}
|
|
1893
|
+
/** True calendar-date check for `--date` (re-review finding 9): rejects an
|
|
1894
|
+
* out-of-range month/day (e.g. `2026-99-99`) or a day that does not exist in
|
|
1895
|
+
* that month (e.g. `2026-02-30`), which `/^\d{4}-\d{2}-\d{2}$/` alone lets
|
|
1896
|
+
* through — `Date.UTC` normalizes overflow instead of raising, so the parsed
|
|
1897
|
+
* fields must be compared back against the input. */
|
|
1898
|
+
function isRealCalendarDate(date) {
|
|
1899
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date);
|
|
1900
|
+
if (!m)
|
|
1901
|
+
return false;
|
|
1902
|
+
const year = Number(m[1]);
|
|
1903
|
+
const month = Number(m[2]);
|
|
1904
|
+
const day = Number(m[3]);
|
|
1905
|
+
const d = new Date(Date.UTC(year, month - 1, day));
|
|
1906
|
+
return d.getUTCFullYear() === year && d.getUTCMonth() === month - 1 && d.getUTCDate() === day;
|
|
1907
|
+
}
|
|
1908
|
+
/**
|
|
1909
|
+
* #5105 S3 — validate `verification.append-audit` input shape before it ever
|
|
1910
|
+
* reaches `planAuditAppend`/the file. `--rows` being valid JSON (checked by
|
|
1911
|
+
* the caller before this runs) is necessary but not sufficient: a newline,
|
|
1912
|
+
* `\r`, or `|` in the heading or any row key/value would corrupt the
|
|
1913
|
+
* rendered `## heading date` line or `| key | value |` row, and a non-integer
|
|
1914
|
+
* or negative count is not a countable metric.
|
|
1915
|
+
*/
|
|
1916
|
+
function validateAuditAppendInput(heading, rows, date) {
|
|
1917
|
+
if (hasForbiddenAuditChar(heading)) {
|
|
1918
|
+
return { ok: false, reason: '--heading must not contain a newline, carriage return, or |' };
|
|
1919
|
+
}
|
|
1920
|
+
if (hasLeadingOrTrailingWhitespace(heading)) {
|
|
1921
|
+
return { ok: false, reason: '--heading must not have leading or trailing whitespace' };
|
|
1922
|
+
}
|
|
1923
|
+
for (const [key, value] of Object.entries(rows)) {
|
|
1924
|
+
if (hasForbiddenAuditChar(key)) {
|
|
1925
|
+
return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
|
|
1926
|
+
}
|
|
1927
|
+
if (hasLeadingOrTrailingWhitespace(key)) {
|
|
1928
|
+
return { ok: false, reason: `--rows key ${JSON.stringify(key)} must not have leading or trailing whitespace` };
|
|
1929
|
+
}
|
|
1930
|
+
if (typeof value === 'string' && hasForbiddenAuditChar(value)) {
|
|
1931
|
+
return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must not contain a newline, carriage return, or |` };
|
|
1932
|
+
}
|
|
1933
|
+
if (!isNonNegativeIntegerValue(value)) {
|
|
1934
|
+
return { ok: false, reason: `--rows value for ${JSON.stringify(key)} must be a non-negative integer` };
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
if (date !== undefined && !isRealCalendarDate(date)) {
|
|
1938
|
+
return { ok: false, reason: '--date must be a real calendar date in YYYY-MM-DD form' };
|
|
1939
|
+
}
|
|
1940
|
+
return { ok: true };
|
|
1941
|
+
}
|
|
1942
|
+
/**
|
|
1943
|
+
* #5105 S2 — case-insensitive re-implementation of `isVerificationReportPath`'s
|
|
1944
|
+
* shape. That helper is deliberately case-SENSITIVE (matching
|
|
1945
|
+
* `resolveVerificationFile`'s own convention — see its doc comment), so it
|
|
1946
|
+
* cannot be reused directly for a containment refusal that must catch a
|
|
1947
|
+
* lowercase `07-verification.md` too.
|
|
1948
|
+
*/
|
|
1949
|
+
function isVerificationReportBasenameCI(basename) {
|
|
1950
|
+
const lower = basename.toLowerCase();
|
|
1951
|
+
return lower === 'verification.md' || lower.endsWith('-verification.md');
|
|
1952
|
+
}
|
|
1953
|
+
/** #5105 S2 — the only two shapes `verification.append-audit` may target. */
|
|
1954
|
+
function isAllowedAuditTargetBasenameCI(basename) {
|
|
1955
|
+
const lower = basename.toLowerCase();
|
|
1956
|
+
return lower.endsWith('-security.md') || lower.endsWith('-validation.md');
|
|
1957
|
+
}
|
|
1958
|
+
/**
|
|
1959
|
+
* CLI command handler (#5105 R3): `verification.append-audit <file>
|
|
1960
|
+
* --heading <H> --rows '<json {metric:count}>' [--date <YYYY-MM-DD>]`.
|
|
1961
|
+
*
|
|
1962
|
+
* Never reads or writes `covered_files`/`covered_digest` (#4981 invariant
|
|
1963
|
+
* 5) — a genuinely changed count publishes and stales any report that covers
|
|
1964
|
+
* `file`; nothing here ever restamps it.
|
|
1965
|
+
*
|
|
1966
|
+
* #5105 S2: `file` is resolved through the same `requireSafePath(...,
|
|
1967
|
+
* PathAcceptance.AbsoluteInsideRoot)` seam `uat.complete-session` uses —
|
|
1968
|
+
* refusing an absolute-outside-root target or a `../` escape by throwing
|
|
1969
|
+
* before any read/write is attempted (uncaught here, matching every other
|
|
1970
|
+
* `requireSafePath` call site in this codebase — a top-level command
|
|
1971
|
+
* dispatcher turns the throw into a failed exit). The REAL (symlink-resolved)
|
|
1972
|
+
* basename is then checked twice, case-insensitively: it must not be a
|
|
1973
|
+
* verification report itself, and it must be a `*-SECURITY.md` or
|
|
1974
|
+
* `*-VALIDATION.md` file — the only two artifact kinds this command may
|
|
1975
|
+
* mutate.
|
|
1976
|
+
*/
|
|
1977
|
+
function cmdVerificationAppendAudit(cwd, fileArg, argTokens, raw) {
|
|
1978
|
+
if (!fileArg) {
|
|
1979
|
+
error('file required for verification.append-audit');
|
|
1980
|
+
return;
|
|
1981
|
+
}
|
|
1982
|
+
const { heading, rows: rowsArg, date: dateArg } = (0, command_arg_projection_cjs_1.parseNamedArgsOrExit)(argTokens, { valueFlags: ['heading', 'rows', 'date'], positionals: 0 }, error);
|
|
1983
|
+
if (!heading) {
|
|
1984
|
+
error('--heading required for verification.append-audit');
|
|
1985
|
+
return;
|
|
1986
|
+
}
|
|
1987
|
+
if (!rowsArg) {
|
|
1988
|
+
error('--rows required for verification.append-audit');
|
|
1989
|
+
return;
|
|
1990
|
+
}
|
|
1991
|
+
let rows;
|
|
1992
|
+
try {
|
|
1993
|
+
const parsedRows = JSON.parse(rowsArg);
|
|
1994
|
+
if (!parsedRows || typeof parsedRows !== 'object' || Array.isArray(parsedRows)) {
|
|
1995
|
+
throw new Error('not an object');
|
|
1996
|
+
}
|
|
1997
|
+
rows = parsedRows;
|
|
1998
|
+
}
|
|
1999
|
+
catch {
|
|
2000
|
+
error('--rows must be a JSON object for verification.append-audit');
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
const date = dateArg ?? undefined;
|
|
2004
|
+
const validation = validateAuditAppendInput(heading, rows, date);
|
|
2005
|
+
if (!validation.ok) {
|
|
2006
|
+
error(validation.reason);
|
|
2007
|
+
return;
|
|
2008
|
+
}
|
|
2009
|
+
const resolvedPath = (0, security_cjs_1.requireSafePath)(fileArg, cwd, 'verification.append-audit file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
2010
|
+
const realBasename = node_path_1.default.basename(resolvedPath);
|
|
2011
|
+
if (isVerificationReportBasenameCI(realBasename)) {
|
|
2012
|
+
error('verification.append-audit refuses a verification report path');
|
|
2013
|
+
return;
|
|
2014
|
+
}
|
|
2015
|
+
if (!isAllowedAuditTargetBasenameCI(realBasename)) {
|
|
2016
|
+
error('verification.append-audit target must be a *-SECURITY.md or *-VALIDATION.md file');
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
let content;
|
|
2020
|
+
try {
|
|
2021
|
+
content = node_fs_1.default.readFileSync(resolvedPath, 'utf-8');
|
|
2022
|
+
}
|
|
2023
|
+
catch {
|
|
2024
|
+
error(`file not found: ${fileArg}`);
|
|
2025
|
+
return;
|
|
2026
|
+
}
|
|
2027
|
+
const result = planAuditAppend(content, { heading: heading, rows, date });
|
|
2028
|
+
if (result.appended) {
|
|
2029
|
+
node_fs_1.default.writeFileSync(resolvedPath, result.content);
|
|
2030
|
+
}
|
|
2031
|
+
output({ appended: result.appended }, raw);
|
|
1197
2032
|
}
|
|
1198
|
-
|
|
2033
|
+
const verificationModule = {
|
|
2034
|
+
VERIFICATION_STATUS,
|
|
1199
2035
|
VERIFIER_STATUSES,
|
|
1200
|
-
|
|
2036
|
+
VERIFICATION_ROUTES,
|
|
2037
|
+
isVerificationStatus,
|
|
2038
|
+
assertVerificationStatus,
|
|
2039
|
+
VerificationStatusError,
|
|
2040
|
+
VERIFICATION_STATUS_ERROR_CODE,
|
|
2041
|
+
failOnVerificationStatusError,
|
|
2042
|
+
firstStatusError,
|
|
2043
|
+
reportStatusOf,
|
|
2044
|
+
isReportContained,
|
|
2045
|
+
routeResult,
|
|
2046
|
+
findVerificationStatusError,
|
|
1201
2047
|
defaultPhaseCleanCommitTimesMs,
|
|
2048
|
+
resolvePhaseArtifactFile,
|
|
1202
2049
|
resolveVerificationFile,
|
|
1203
2050
|
resolveUatFile,
|
|
1204
2051
|
findStaleVerificationSummary,
|
|
@@ -1209,7 +2056,11 @@ module.exports = {
|
|
|
1209
2056
|
computeCoveredDigest,
|
|
1210
2057
|
sharedPlanningRoots,
|
|
1211
2058
|
isSharedPlanningDoc,
|
|
2059
|
+
isVerificationReportPath,
|
|
1212
2060
|
parseFingerprintVersion,
|
|
1213
2061
|
parseFingerprintFileArgs,
|
|
1214
2062
|
cmdVerificationFingerprint,
|
|
2063
|
+
planAuditAppend,
|
|
2064
|
+
cmdVerificationAppendAudit,
|
|
1215
2065
|
};
|
|
2066
|
+
module.exports = verificationModule;
|