@opengsd/gsd-core 1.13.0 → 1.15.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/README.ja-JP.md +3 -3
- package/README.ko-KR.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.zh-CN.md +3 -3
- package/agents/gsd-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +459 -0
- package/agents/gsd-code-fixer.md +9 -8
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +360 -0
- package/agents/gsd-debug-session-manager.md +17 -2
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-executor.md +13 -8
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-phase-researcher.md +19 -11
- package/agents/gsd-plan-checker.md +8 -7
- package/agents/gsd-planner.md +12 -8
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-auditor.md +155 -17
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/agents/gsd-verifier.md +10 -9
- package/bin/install.js +848 -163
- package/commands/gsd/autonomous.md +2 -2
- package/commands/gsd/capture.md +1 -1
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +8 -3
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +1 -0
- package/commands/gsd/plan-review-convergence.md +6 -6
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/progress.md +1 -1
- package/commands/gsd/quick-batch.md +1 -1
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/review.md +2 -3
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +672 -146
- package/gsd-core/bin/lib/adr-parser.cjs +4 -2
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +119 -34
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +96 -189
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-validator.cjs +14 -2
- package/gsd-core/bin/lib/check-command-router.cjs +213 -49
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
- package/gsd-core/bin/lib/commands.cjs +823 -112
- package/gsd-core/bin/lib/config-loader.cjs +66 -4
- package/gsd-core/bin/lib/config.cjs +186 -45
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +164 -45
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- package/gsd-core/bin/lib/frontmatter.cjs +13 -0
- package/gsd-core/bin/lib/graphify.cjs +10 -2
- package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
- package/gsd-core/bin/lib/init.cjs +614 -86
- package/gsd-core/bin/lib/install-engine.cjs +29 -3
- package/gsd-core/bin/lib/install-profiles.cjs +14 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +41 -5
- package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +37 -13
- package/gsd-core/bin/lib/model-resolver.cjs +253 -53
- package/gsd-core/bin/lib/phase-command-router.cjs +16 -2
- package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
- package/gsd-core/bin/lib/phase-id.cjs +268 -27
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase-locator.cjs +29 -10
- package/gsd-core/bin/lib/phase.cjs +393 -88
- package/gsd-core/bin/lib/plan-document.cjs +49 -1
- package/gsd-core/bin/lib/planning-document.cjs +459 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +52 -19
- package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
- package/gsd-core/bin/lib/planning-workspace.cjs +57 -3
- package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/probe-core.cjs +7 -1
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- package/gsd-core/bin/lib/project-root.cjs +41 -2
- package/gsd-core/bin/lib/quick-batch.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
- package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +219 -18
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
- package/gsd-core/bin/lib/roadmap.cjs +356 -42
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +310 -41
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +15 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
- package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +408 -37
- package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
- package/gsd-core/bin/lib/security.cjs +126 -7
- package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
- package/gsd-core/bin/lib/state-document.cjs +130 -28
- package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
- package/gsd-core/bin/lib/state-transition.cjs +181 -30
- package/gsd-core/bin/lib/state.cjs +265 -27
- package/gsd-core/bin/lib/surface.cjs +77 -3
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
- package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
- package/gsd-core/bin/lib/uat.cjs +9 -1
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
- package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
- package/gsd-core/bin/lib/update-context.cjs +30 -24
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification.cjs +315 -30
- package/gsd-core/bin/lib/verify-command-grounding.cjs +47 -3
- package/gsd-core/bin/lib/verify.cjs +320 -48
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
- package/gsd-core/bin/lib/worktree-safety.cjs +797 -58
- package/gsd-core/bin/shared/config-defaults.manifest.json +4 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +6 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
- package/gsd-core/references/checkpoints.md +5 -3
- package/gsd-core/references/compact-content-gate.md +66 -0
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe.md +195 -21
- package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
- package/gsd-core/references/execute-phase-wave-guard.md +22 -11
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +13 -4
- package/gsd-core/references/phase-argument-parsing.md +9 -7
- package/gsd-core/references/phase-id-convention.md +28 -0
- package/gsd-core/references/planner-gap-closure.md +2 -0
- package/gsd-core/references/planner-load-graph-context.md +24 -13
- package/gsd-core/references/planner-verify-command-grounding.md +14 -0
- package/gsd-core/references/planning-config.md +14 -2
- package/gsd-core/references/tdd.md +30 -4
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/ui-consideration-probe.md +10 -5
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/verify-command-path-resolvability.md +10 -2
- package/gsd-core/references/worktree-path-safety.md +433 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- 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 +1 -1
- package/gsd-core/workflows/add-tests.md +2 -2
- package/gsd-core/workflows/add-todo.md +6 -5
- package/gsd-core/workflows/ai-integration-phase.md +11 -3
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
- package/gsd-core/workflows/autonomous.md +29 -16
- package/gsd-core/workflows/check-todos.md +6 -4
- package/gsd-core/workflows/cleanup.md +5 -3
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +8 -1
- package/gsd-core/workflows/code-review-fix.md +108 -22
- package/gsd-core/workflows/code-review.md +216 -73
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +41 -264
- package/gsd-core/workflows/debug.md +3 -3
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- 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 +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +2 -2
- package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +17 -158
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +10 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +19 -4
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +43 -4
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +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 +46 -9
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
- package/gsd-core/workflows/execute-phase.md +83 -172
- package/gsd-core/workflows/execute-plan.md +24 -10
- package/gsd-core/workflows/explore.md +3 -3
- package/gsd-core/workflows/extract-learnings.md +2 -1
- 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 -2
- package/gsd-core/workflows/help/modes/full.compact.md +398 -0
- package/gsd-core/workflows/help/modes/full.md +5 -5
- package/gsd-core/workflows/help/modes/topic.md +15 -5
- package/gsd-core/workflows/help.md +1 -1
- package/gsd-core/workflows/import.md +2 -2
- package/gsd-core/workflows/inbox.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -3
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +2 -2
- package/gsd-core/workflows/map-codebase.md +52 -5
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +56 -14
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
- package/gsd-core/workflows/new-project.md +39 -209
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/note.md +1 -1
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
- package/gsd-core/workflows/plan-phase.md +45 -187
- package/gsd-core/workflows/plan-review-convergence.md +21 -5
- package/gsd-core/workflows/plant-seed.md +62 -20
- package/gsd-core/workflows/pr-branch.md +132 -20
- package/gsd-core/workflows/profile-user.md +2 -2
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
- 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 +1 -1
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +1 -1
- package/gsd-core/workflows/quick.md +29 -10
- package/gsd-core/workflows/reapply-patches.md +86 -6
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +31 -16
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -2
- package/gsd-core/workflows/settings-advanced.md +30 -10
- package/gsd-core/workflows/settings-integrations.md +2 -3
- package/gsd-core/workflows/settings.md +22 -9
- package/gsd-core/workflows/ship.md +3 -2
- 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 +2 -2
- package/gsd-core/workflows/spec-phase.md +15 -5
- 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 +5 -5
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +44 -8
- package/gsd-core/workflows/ui-review.md +18 -4
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/undo.md +339 -20
- package/gsd-core/workflows/update.md +14 -12
- package/gsd-core/workflows/validate-phase.md +3 -2
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +101 -196
- package/hooks/dist/gsd-agent-isolation-guard.js +66 -16
- package/hooks/dist/gsd-context-monitor.js +88 -15
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- package/hooks/dist/gsd-secret-read-guard.js +71 -19
- package/hooks/dist/gsd-statusline.js +81 -20
- package/hooks/dist/gsd-validate-commit.sh +97 -8
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/gsd-agent-isolation-guard.js +66 -16
- package/hooks/gsd-context-monitor.js +88 -15
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- package/hooks/gsd-secret-read-guard.js +71 -19
- package/hooks/gsd-statusline.js +81 -20
- package/hooks/gsd-validate-commit.sh +97 -8
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/package.json +11 -6
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/build-hooks.js +15 -6
- package/scripts/check-contract-drift.cjs +131 -12
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +126 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/command-contract-helpers.cjs +3 -0
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +35 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-loop-host-contract.cjs +69 -0
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +224 -0
- package/scripts/lib/ndjson-reporter.cjs +3 -2
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +287 -0
- package/scripts/lib/suite-detection.cjs +32 -0
- package/scripts/lint-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +47 -3
- package/scripts/lint-phase-arg-assignment.cjs +257 -0
- package/scripts/lint-phase-id-drift.cjs +623 -13
- package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
- package/scripts/lint-response-language-coverage.cjs +9 -3
- package/scripts/lint-retired-runtime-name.cjs +619 -0
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-state-write-path-drift.cjs +93 -0
- package/scripts/lint-test-file-count.allowlist.json +29 -9
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +100 -0
- package/scripts/prompt-injection-scan.sh +18 -0
- package/scripts/release-tarball-smoke.cjs +194 -1
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-autonomous/SKILL.md +2 -2
- package/skills/gsd-capture/SKILL.md +1 -1
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +8 -3
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -0
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-progress/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +1 -1
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review/SKILL.md +2 -3
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -17,7 +17,11 @@ const pattern_cjs_1 = require("./pattern.cjs");
|
|
|
17
17
|
const security_cjs_1 = require("./security.cjs");
|
|
18
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
19
19
|
const ioMod = require("./io.cjs");
|
|
20
|
-
const { output,
|
|
20
|
+
const { output, ERROR_REASON } = ioMod;
|
|
21
|
+
// Explicitly annotated so TypeScript applies never-return control-flow narrowing.
|
|
22
|
+
// See the identical note in check-command-router.cts: a destructured const carries no
|
|
23
|
+
// type annotation, so TS will not narrow after `error(...)` without this.
|
|
24
|
+
const error = ioMod.error;
|
|
21
25
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
22
26
|
const configLoaderMod = require("./config-loader.cjs");
|
|
23
27
|
const { loadConfig, isGitIgnored } = configLoaderMod;
|
|
@@ -26,7 +30,10 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
26
30
|
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
|
|
27
31
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
28
32
|
const phaseIdMod = require("./phase-id.cjs");
|
|
29
|
-
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
|
|
33
|
+
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId, renderPhaseBranchName, parsePhaseId, renderPhaseId, phaseHeadingPrefixSrcFor, PHASE_HEADING_BASELINE, } = phaseIdMod;
|
|
34
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
|
+
const phaseIdDisplayMod = require("./phase-id-display.cjs");
|
|
36
|
+
const { renderBracketMilestoneDisplay } = phaseIdDisplayMod;
|
|
30
37
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
31
38
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
32
39
|
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
@@ -52,7 +59,7 @@ const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
|
|
|
52
59
|
const hostIntegrationMod = require("./host-integration.cjs");
|
|
53
60
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
54
61
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
55
|
-
const { planningDir, planningPaths } = planningWorkspace;
|
|
62
|
+
const { planningDir, planningPaths, todosDir, resolvePhaseIdConvention } = planningWorkspace;
|
|
56
63
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
57
64
|
const frontmatter = require("./frontmatter.cjs");
|
|
58
65
|
const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
|
|
@@ -68,6 +75,32 @@ const { scanPhasePlans } = planScanMod;
|
|
|
68
75
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
69
76
|
const verificationMod = require("./verification.cjs");
|
|
70
77
|
const { resolveVerificationFile } = verificationMod;
|
|
78
|
+
/**
|
|
79
|
+
* Project one canonical bracket directory onto its display identity and slug.
|
|
80
|
+
*
|
|
81
|
+
* The identity is deliberately obtained only through parsePhaseId/renderPhaseId;
|
|
82
|
+
* this helper owns no second bracket grammar. Callers invoke it only after the
|
|
83
|
+
* project's resolved convention is exactly `bracket`.
|
|
84
|
+
*/
|
|
85
|
+
function bracketPhaseDirProjection(dir) {
|
|
86
|
+
const id = parsePhaseId(dir);
|
|
87
|
+
const number = id.subphase ? `${id.phase}.${id.subphase}` : id.phase;
|
|
88
|
+
const identityPrefix = `${id.project}.${id.milestone}-${number}`;
|
|
89
|
+
const slug = dir.slice(identityPrefix.length).replace(/^-/, '');
|
|
90
|
+
return {
|
|
91
|
+
number,
|
|
92
|
+
display_id: renderPhaseId(id),
|
|
93
|
+
name: slug ? slug.replace(/-/g, ' ') : '',
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
function recoverBracketPhaseName(dir, phaseToken) {
|
|
97
|
+
const tokenBoundary = `-${phaseToken}`;
|
|
98
|
+
const tokenOffset = dir.indexOf(tokenBoundary);
|
|
99
|
+
if (tokenOffset === -1)
|
|
100
|
+
return '';
|
|
101
|
+
const afterToken = dir.slice(tokenOffset + tokenBoundary.length).replace(/^-/, '');
|
|
102
|
+
return afterToken ? afterToken.replace(/-/g, ' ') : '';
|
|
103
|
+
}
|
|
71
104
|
// ─── Phase Status ─────────────────────────────────────────────────────────────
|
|
72
105
|
/**
|
|
73
106
|
* Phase-status precedence ladder — furthest-along wins (#2408).
|
|
@@ -186,7 +219,10 @@ function cmdCurrentTimestamp(format, raw) {
|
|
|
186
219
|
output({ timestamp: result }, raw, result);
|
|
187
220
|
}
|
|
188
221
|
function cmdListTodos(cwd, area, raw) {
|
|
189
|
-
|
|
222
|
+
// #4256: todos are root-scoped shared state — resolve via todosDir(cwd),
|
|
223
|
+
// never planningDir(cwd) (workstream-scoped), or the listing goes empty
|
|
224
|
+
// under a workstream.
|
|
225
|
+
const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
|
|
190
226
|
let count = 0;
|
|
191
227
|
const todos = [];
|
|
192
228
|
try {
|
|
@@ -230,26 +266,43 @@ function cmdListTodos(cwd, area, raw) {
|
|
|
230
266
|
* displayed field is passed through sanitizeForDisplay and each file path is
|
|
231
267
|
* validated with requireSafePath before reading. Read-only — never mutates.
|
|
232
268
|
*/
|
|
269
|
+
/**
|
|
270
|
+
* Seed id grammars. `SEED-YYMMDD-xxx` (date + 3 base36 chars, the shape
|
|
271
|
+
* `.planning/quick/` uses) is what plant-seed has minted since #4378 removed
|
|
272
|
+
* the shared `wc -l` counter; `SEED-NNN` is the legacy counter form, which
|
|
273
|
+
* keeps parsing forever — existing seeds must never lose their identity.
|
|
274
|
+
*
|
|
275
|
+
* Known (theoretical, documented-not-fixed per #4378 review): a frontmatter-less
|
|
276
|
+
* legacy file whose counter is exactly 6 digits and whose slug opens with
|
|
277
|
+
* exactly 3 base36 chars parses as new-format. Requires a counter >= 100000 AND
|
|
278
|
+
* a missing frontmatter id; with frontmatter the legacy id always wins.
|
|
279
|
+
*/
|
|
280
|
+
const CANONICAL_SEED_ID_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)$/i;
|
|
281
|
+
const SEED_ID_PREFIX_RE = /^(SEED-(?:\d{6}-[a-z0-9]{3}|\d+))/i;
|
|
282
|
+
const SEED_SLUG_RE = /^SEED-(?:\d{6}-[a-z0-9]{3}|\d+)-(.+)$/i;
|
|
233
283
|
/**
|
|
234
284
|
* Derive the canonical `{ seed_id, slug }` from a seed filename stem and the
|
|
235
285
|
* frontmatter `id:` value. Pure (no I/O) so it can be property-tested directly.
|
|
236
286
|
*
|
|
237
|
-
* seed_id: frontmatter `id:` when it matches
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
*
|
|
287
|
+
* seed_id: frontmatter `id:` when it matches a seed id grammar (`SEED-YYMMDD-xxx`
|
|
288
|
+
* or legacy `SEED-NNN`), else the id prefix of the filename (`SEED-…-<slug>`),
|
|
289
|
+
* else the whole stem. The prefix fallback must keep the FULL new-format id —
|
|
290
|
+
* truncating at the date gives every same-day seed the same id (#4378).
|
|
291
|
+
* slug: the descriptive remainder after the id, else the stem with a leading
|
|
292
|
+
* `SEED-` stripped. `rawFmId` is `unknown` because frontmatter values are not
|
|
293
|
+
* guaranteed strings.
|
|
241
294
|
*/
|
|
242
295
|
function deriveSeedIdentity(stem, rawFmId) {
|
|
243
296
|
const fmId = typeof rawFmId === 'string' ? rawFmId.trim() : '';
|
|
244
297
|
let seedId;
|
|
245
|
-
if (
|
|
298
|
+
if (CANONICAL_SEED_ID_RE.test(fmId)) {
|
|
246
299
|
seedId = fmId;
|
|
247
300
|
}
|
|
248
301
|
else {
|
|
249
|
-
const
|
|
250
|
-
seedId =
|
|
302
|
+
const prefixMatch = stem.match(SEED_ID_PREFIX_RE);
|
|
303
|
+
seedId = prefixMatch ? prefixMatch[1] : stem;
|
|
251
304
|
}
|
|
252
|
-
const slugMatch = stem.match(
|
|
305
|
+
const slugMatch = stem.match(SEED_SLUG_RE);
|
|
253
306
|
const slug = slugMatch ? slugMatch[1] : stem.replace(/^SEED-/i, '');
|
|
254
307
|
return { seed_id: seedId, slug };
|
|
255
308
|
}
|
|
@@ -282,7 +335,7 @@ function cmdListSeeds(cwd, statusFilter, raw) {
|
|
|
282
335
|
continue;
|
|
283
336
|
let safeFilePath;
|
|
284
337
|
try {
|
|
285
|
-
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file',
|
|
338
|
+
safeFilePath = (0, security_cjs_1.requireSafePath)(node_path_1.default.join(seedsDir, entry.name), planDir, 'seed file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
286
339
|
}
|
|
287
340
|
catch {
|
|
288
341
|
continue;
|
|
@@ -296,9 +349,11 @@ function cmdListSeeds(cwd, statusFilter, raw) {
|
|
|
296
349
|
// sanitizeForDisplay is for output, not comparison.
|
|
297
350
|
if (wantStatus && status !== wantStatus)
|
|
298
351
|
continue;
|
|
299
|
-
// Canonical seed
|
|
300
|
-
//
|
|
301
|
-
//
|
|
352
|
+
// Canonical seed ids are `SEED-YYMMDD-xxx` (frontmatter `id:`, what
|
|
353
|
+
// plant-seed has minted since #4378) or legacy `SEED-NNN`; deriveSeedIdentity
|
|
354
|
+
// owns that grammar. Fall back to the id prefix of the filename, then to the
|
|
355
|
+
// whole stem. The descriptive remainder of the filename (`SEED-…-<slug>.md`)
|
|
356
|
+
// is the slug.
|
|
302
357
|
const stem = node_path_1.default.basename(entry.name, '.md');
|
|
303
358
|
const { seed_id: seedId, slug } = deriveSeedIdentity(stem, fm.id);
|
|
304
359
|
let title = (0, security_cjs_1.sanitizeForDisplay)(fmStr(fm.title).slice(0, 100));
|
|
@@ -496,13 +551,10 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
496
551
|
opts = opts || {};
|
|
497
552
|
const config = loadConfig(cwd);
|
|
498
553
|
const profile = config['model_profile'] || 'balanced';
|
|
499
|
-
// #2068: resolve the model per-attempt so dynamic_routing
|
|
500
|
-
// (heavy tier) alongside effort.
|
|
501
|
-
//
|
|
502
|
-
//
|
|
503
|
-
// including dynamic_routing-enabled users who don't pass --attempt), and only an
|
|
504
|
-
// explicit attempt routes through the tier ladder. resolveModelForTier itself
|
|
505
|
-
// still falls back to resolveModelInternal when dynamic_routing is off.
|
|
554
|
+
// #2068: resolve the model per-attempt so dynamic_routing ESCALATES the MODEL
|
|
555
|
+
// (heavy tier) alongside effort. The FIRST-spawn tier now comes from
|
|
556
|
+
// resolveModelInternal's own dynamic_routing step (#4505), so the absent-attempt
|
|
557
|
+
// branch below reaches it too — this gate is only about escalation.
|
|
506
558
|
let model = (opts.attempt !== undefined && opts.attempt !== null)
|
|
507
559
|
? resolveModelForTier(cwd, agentType, opts.attempt)
|
|
508
560
|
: resolveModelInternal(cwd, agentType);
|
|
@@ -553,13 +605,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
553
605
|
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
554
606
|
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
555
607
|
const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
|
|
556
|
-
const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
|
|
557
608
|
// agentType is an unvalidated CLI positional: keep the read inside the
|
|
558
609
|
// agents dir so `../../x` cannot point it elsewhere (defense in depth —
|
|
559
|
-
// the reflected surface is only a frontmatter effort line).
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
610
|
+
// the reflected surface is only a frontmatter effort line). Untrusted
|
|
611
|
+
// input feeding a real read → realpath family (ADR-4650 decision 6).
|
|
612
|
+
const agentPath = (0, security_cjs_1.assertWithinRoot)(`${agentType}.md`, agentsDirEff, 'agent file');
|
|
563
613
|
const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
|
|
564
614
|
// eslint-disable-next-line local/no-unbounded-quantifier -- same lazy `*?` bounded by the `^---$/m` closing anchor as the sibling frontmatter regexes in this file
|
|
565
615
|
const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
|
|
@@ -1357,19 +1407,33 @@ function detectPhaseNumberFromFiles(files) {
|
|
|
1357
1407
|
if (!phaseDir)
|
|
1358
1408
|
continue;
|
|
1359
1409
|
const token = extractPhaseToken(phaseDir);
|
|
1360
|
-
//
|
|
1361
|
-
//
|
|
1362
|
-
//
|
|
1363
|
-
//
|
|
1364
|
-
//
|
|
1365
|
-
//
|
|
1366
|
-
//
|
|
1367
|
-
|
|
1410
|
+
// normalizePhaseName is the canonical arbiter of "is this a real phase
|
|
1411
|
+
// token": it strips the project-code prefix and returns a zero-padded
|
|
1412
|
+
// numeric form for a genuine phase token, or the input unchanged
|
|
1413
|
+
// otherwise. Accept the token whenever it normalizes to a numeric
|
|
1414
|
+
// phase form (the single-owner rule shared by every other phase-token
|
|
1415
|
+
// reader — see #2528).
|
|
1416
|
+
//
|
|
1417
|
+
// #4126 fix: this used to also require `token !== phaseDir`, on the
|
|
1418
|
+
// assumption that extractPhaseToken returning its input unchanged
|
|
1419
|
+
// always means "no numeric token found" (its no-match fallback).
|
|
1420
|
+
// That assumption is false for a BARE phase directory with no slug
|
|
1421
|
+
// remainder (e.g. `.planning/phases/01/`): extractPhaseToken correctly
|
|
1422
|
+
// reads "01" as the token, which is simply identical to the directory
|
|
1423
|
+
// name in that case — not a fallback. The stale equality check
|
|
1424
|
+
// rejected every such directory, leaving `phaseNum` null and silently
|
|
1425
|
+
// skipping the whole phase-branch block below (undetected because
|
|
1426
|
+
// `phaseTokenShape.test(normalized)` already excludes genuine
|
|
1427
|
+
// non-phase fallbacks — e.g. `docs`, `CK-docs` — on its own, since
|
|
1428
|
+
// extractPhaseToken's real no-match fallback only fires for dirNames
|
|
1429
|
+
// that do not start with a digit or short letter+digit prefix, which
|
|
1430
|
+
// normalizePhaseName's leading-`\d+` requirement rejects regardless).
|
|
1368
1431
|
// Built from the single-owner PHASE_NUMBER_TOKEN_SOURCE (the canonical
|
|
1369
1432
|
// phase-number grammar — #2128 anti-divergence guard) so this read-side
|
|
1370
1433
|
// acceptance check cannot drift from every other phase-token reader.
|
|
1434
|
+
const normalized = normalizePhaseName(token);
|
|
1371
1435
|
const phaseTokenShape = new RegExp(`^${PHASE_NUMBER_TOKEN_SOURCE}$`, 'i');
|
|
1372
|
-
if (
|
|
1436
|
+
if (phaseTokenShape.test(normalized)) {
|
|
1373
1437
|
return token;
|
|
1374
1438
|
}
|
|
1375
1439
|
}
|
|
@@ -1442,7 +1506,296 @@ const COMMIT_DOCS_SKIP_REASON = {
|
|
|
1442
1506
|
config: 'skipped_commit_docs_false',
|
|
1443
1507
|
gitignore: 'skipped_gitignored',
|
|
1444
1508
|
};
|
|
1445
|
-
|
|
1509
|
+
// #4208 review: the declared-removal staging lifted out of cmdCommit, which was
|
|
1510
|
+
// already a critical-risk hotspot before this flag existed. Pure motion -- the
|
|
1511
|
+
// classification, canonicalisation and entry recording below are unchanged; only
|
|
1512
|
+
// the two accumulators are local names that the caller merges. `removedPathspec`
|
|
1513
|
+
// is what joins the commit's pathspec; `removedEntries` is what the caller's
|
|
1514
|
+
// rollback and its no-change exits restore from.
|
|
1515
|
+
function stageDeclaredRemovals(cwd, removedDeclared) {
|
|
1516
|
+
const failures = [];
|
|
1517
|
+
const removedPathspec = [];
|
|
1518
|
+
// The empty blob under SHA-1 and SHA-256 object formats — intent-to-add's tell.
|
|
1519
|
+
const EMPTY_BLOBS = new Set(['e69de29bb2d1d6434b8b29ae775ad8c2e48c5391', '473a0f4c3be8a93681a267e3b1e9a7dcda1185436fe141f7749120a303721813']);
|
|
1520
|
+
// A PATH FROM THE INDEX IS NOT A PATHSPEC. `git rm`, `ls-files` and friends
|
|
1521
|
+
// parse their operands as pathspecs, so a tracked file literally named
|
|
1522
|
+
// `.planning/*.md` GLOBS when handed back to git: driven, `rm --cached` on it
|
|
1523
|
+
// also removed `peer.md` and `stays.md`, and only the declared entry was
|
|
1524
|
+
// recorded — so the rollback restored one of three and the other two rode out
|
|
1525
|
+
// as undisclosed staged deletions. The magic-prefix twin is quieter still: a
|
|
1526
|
+
// file named `:(literal)mine` has its prefix PARSED, so the rm matches nothing,
|
|
1527
|
+
// exits 0, and the entry silently survives a removal this call then claims.
|
|
1528
|
+
// `:(literal)` disables every other magic, including globbing, so the operand
|
|
1529
|
+
// means the file it names.
|
|
1530
|
+
const lit = (p) => `:(literal)${p}`;
|
|
1531
|
+
const notARemoval = (e) => {
|
|
1532
|
+
if (e.mode === '160000')
|
|
1533
|
+
return 'a submodule gitlink, not a file';
|
|
1534
|
+
if (e.tag === 'S')
|
|
1535
|
+
return 'skip-worktree (sparse-checkout): absent by checkout, not removed';
|
|
1536
|
+
if (e.tag === 'h')
|
|
1537
|
+
return 'assume-unchanged: git does not consult its worktree state';
|
|
1538
|
+
if (e.stage !== '0')
|
|
1539
|
+
return 'an unmerged index entry';
|
|
1540
|
+
if (e.tag !== 'H')
|
|
1541
|
+
return `index state '${e.tag}'`;
|
|
1542
|
+
return null;
|
|
1543
|
+
};
|
|
1544
|
+
const lstatState = (p) => {
|
|
1545
|
+
try {
|
|
1546
|
+
node_fs_1.default.lstatSync(p);
|
|
1547
|
+
return 'present';
|
|
1548
|
+
}
|
|
1549
|
+
catch (e) {
|
|
1550
|
+
const err = e;
|
|
1551
|
+
return err.code === 'ENOENT' || err.code === 'ENOTDIR' ? 'absent' : err;
|
|
1552
|
+
}
|
|
1553
|
+
};
|
|
1554
|
+
// `rev-parse -q --verify HEAD` exits 1 both for an unborn HEAD and for a
|
|
1555
|
+
// spawn timeout (`execGit` collapses one to `exitCode: 1`). Only a probe that
|
|
1556
|
+
// actually answered may downgrade the union to index-only; an unanswered one
|
|
1557
|
+
// fails closed, because silently dropping the HEAD half re-opens the
|
|
1558
|
+
// pre-staged-deletion omission this union exists to close.
|
|
1559
|
+
let headExists = false;
|
|
1560
|
+
let headProbeFailure = null;
|
|
1561
|
+
if (removedDeclared.length > 0) {
|
|
1562
|
+
const headProbe = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '-q', '--verify', 'HEAD'], { cwd });
|
|
1563
|
+
if (headProbe.exitCode === 0) {
|
|
1564
|
+
headExists = true;
|
|
1565
|
+
}
|
|
1566
|
+
else if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) || headProbe.error !== null) {
|
|
1567
|
+
headProbeFailure = { error: headProbe.stderr || headProbe.stdout || 'HEAD probe failed', timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(headProbe) };
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
// Every index entry this call removes, recorded BEFORE the `rm --cached`
|
|
1571
|
+
// so the rollback below can put it back exactly — mode and blob — with
|
|
1572
|
+
// `update-index --cacheinfo`. `git reset -- <path>` cannot do that: it
|
|
1573
|
+
// restores from HEAD, which does not exist on an unborn branch (so a root
|
|
1574
|
+
// commit's failed call used to leave every earlier removal unstaged, in
|
|
1575
|
+
// violation of the only-what-THIS-call-staged invariant above) and which
|
|
1576
|
+
// is not what the index held when the caller had pre-staged a modified
|
|
1577
|
+
// blob at that path. Recording the entry answers both without putting the
|
|
1578
|
+
// path on the commit pathspec, where an unborn HEAD makes `git commit`
|
|
1579
|
+
// refuse it (driven; see the union note above).
|
|
1580
|
+
const removedEntries = [];
|
|
1581
|
+
for (const entry of removedDeclared) {
|
|
1582
|
+
if (headProbeFailure !== null) {
|
|
1583
|
+
failures.push({ file: entry, ...headProbeFailure });
|
|
1584
|
+
continue;
|
|
1585
|
+
}
|
|
1586
|
+
// `-v -s`: tag, mode, blob, stage and path per record — see notARemoval.
|
|
1587
|
+
// `lit` here too: the caller's declared entry is a PATH, not a glob —
|
|
1588
|
+
// that is `--files-removed`'s whole contract — and :(literal) still
|
|
1589
|
+
// resolves a directory to its descendants (driven), so the directory form
|
|
1590
|
+
// is unaffected while a file literally named `*.md` or `:(literal)x` means
|
|
1591
|
+
// itself.
|
|
1592
|
+
const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '-s', '-z', '--', lit(entry)], { cwd });
|
|
1593
|
+
if (listed.exitCode !== 0) {
|
|
1594
|
+
failures.push({
|
|
1595
|
+
file: entry,
|
|
1596
|
+
error: listed.stderr || listed.stdout,
|
|
1597
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(listed),
|
|
1598
|
+
});
|
|
1599
|
+
continue;
|
|
1600
|
+
}
|
|
1601
|
+
const indexed = new Map();
|
|
1602
|
+
let unparseable = null;
|
|
1603
|
+
for (const rec of listed.stdout.split('\0').filter(Boolean)) {
|
|
1604
|
+
const m = /^(\S) (\d{6}) ([0-9a-f]+) ([0-3])\t([\s\S]+)$/.exec(rec);
|
|
1605
|
+
if (m === null) {
|
|
1606
|
+
unparseable = rec;
|
|
1607
|
+
break;
|
|
1608
|
+
}
|
|
1609
|
+
indexed.set(m[5], { tag: m[1], mode: m[2], sha: m[3], stage: m[4] });
|
|
1610
|
+
}
|
|
1611
|
+
if (unparseable !== null) {
|
|
1612
|
+
// A record this code cannot read is not a path it may remove.
|
|
1613
|
+
failures.push({ file: entry, error: `unparseable ls-files record: ${unparseable}`, timed_out: false });
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
const tracked = new Set(indexed.keys());
|
|
1617
|
+
// Does the entry name THIS tracked path itself (the caller declared a
|
|
1618
|
+
// FILE removed) or a directory above it? Decided on RESOLVED paths, never
|
|
1619
|
+
// on the strings: `ls-files` prints cwd-relative paths, and a caller may
|
|
1620
|
+
// pass an absolute path, `./x`, a trailing slash, or run under `--cwd`,
|
|
1621
|
+
// any of which fails a string compare and would silently take the
|
|
1622
|
+
// directory polarity — a directly named gitlink then SKIPS instead of
|
|
1623
|
+
// refusing (found by the round's review, driven with an absolute path).
|
|
1624
|
+
const entryAbs = node_path_1.default.resolve(cwd, entry);
|
|
1625
|
+
const entryRel = node_path_1.default.relative(cwd, entryAbs).split(node_path_1.default.sep).join('/');
|
|
1626
|
+
// Canonical form: realpath of the longest EXISTING prefix, with the absent
|
|
1627
|
+
// tail re-appended. The declared path is usually absent (that is the
|
|
1628
|
+
// point), and `process.cwd()` returns the real path where the caller may
|
|
1629
|
+
// hold a symlinked spelling — macOS `/var` → `/private/var` is the live
|
|
1630
|
+
// instance (CI, this PR's own test) — so a resolve-only compare still
|
|
1631
|
+
// took the directory polarity there.
|
|
1632
|
+
const canon = (p) => {
|
|
1633
|
+
let cur = node_path_1.default.resolve(cwd, p);
|
|
1634
|
+
const tail = [];
|
|
1635
|
+
for (;;) {
|
|
1636
|
+
try {
|
|
1637
|
+
return node_path_1.default.join(node_fs_1.default.realpathSync.native(cur), ...tail);
|
|
1638
|
+
}
|
|
1639
|
+
catch { /* absent: climb */ }
|
|
1640
|
+
const parent = node_path_1.default.dirname(cur);
|
|
1641
|
+
if (parent === cur)
|
|
1642
|
+
return node_path_1.default.join(cur, ...tail);
|
|
1643
|
+
tail.unshift(node_path_1.default.basename(cur));
|
|
1644
|
+
cur = parent;
|
|
1645
|
+
}
|
|
1646
|
+
};
|
|
1647
|
+
const namesItself = (p) => p === entryRel || node_path_1.default.resolve(cwd, p) === entryAbs || canon(p) === canon(entry);
|
|
1648
|
+
const inHeadPaths = new Set();
|
|
1649
|
+
if (headExists) {
|
|
1650
|
+
const inHead = (0, shell_command_projection_cjs_1.execGit)(['ls-tree', '-r', '-z', '--name-only', 'HEAD', '--', lit(entry)], { cwd });
|
|
1651
|
+
if (inHead.exitCode !== 0) {
|
|
1652
|
+
failures.push({
|
|
1653
|
+
file: entry,
|
|
1654
|
+
error: inHead.stderr || inHead.stdout,
|
|
1655
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(inHead),
|
|
1656
|
+
});
|
|
1657
|
+
continue;
|
|
1658
|
+
}
|
|
1659
|
+
for (const p of inHead.stdout.split('\0').filter(Boolean)) {
|
|
1660
|
+
tracked.add(p);
|
|
1661
|
+
inHeadPaths.add(p);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
if (tracked.size === 0)
|
|
1665
|
+
continue;
|
|
1666
|
+
const entryState = lstatState(node_path_1.default.resolve(cwd, entry));
|
|
1667
|
+
if (entryState !== 'present' && entryState !== 'absent') {
|
|
1668
|
+
failures.push({ file: entry, error: `lstat ${entryState.code ?? ''}: ${entryState.message}`, timed_out: false });
|
|
1669
|
+
continue;
|
|
1670
|
+
}
|
|
1671
|
+
let entryIsDirectory = false;
|
|
1672
|
+
if (entryState === 'present') {
|
|
1673
|
+
try {
|
|
1674
|
+
entryIsDirectory = node_fs_1.default.lstatSync(node_path_1.default.resolve(cwd, entry)).isDirectory();
|
|
1675
|
+
}
|
|
1676
|
+
catch { /* raced away: treat as a present non-directory below */ }
|
|
1677
|
+
}
|
|
1678
|
+
if (entryState === 'present' && !entryIsDirectory) {
|
|
1679
|
+
// A present non-directory entry (a file, or ANY symlink — a link to a
|
|
1680
|
+
// directory is still one tracked path) contradicts the declaration.
|
|
1681
|
+
failures.push({
|
|
1682
|
+
file: entry,
|
|
1683
|
+
error: `declared in --files-removed but still present on disk: ${entry}`,
|
|
1684
|
+
timed_out: false,
|
|
1685
|
+
});
|
|
1686
|
+
continue;
|
|
1687
|
+
}
|
|
1688
|
+
for (const trackedPath of tracked) {
|
|
1689
|
+
const indexEntry = indexed.get(trackedPath);
|
|
1690
|
+
let reason = indexEntry === undefined ? null : notARemoval(indexEntry);
|
|
1691
|
+
// Intent-to-add (`git add -N`) renders as a plain `H 100644 <empty
|
|
1692
|
+
// blob> 0` — the flag is not in the listing — yet nothing tracked exists
|
|
1693
|
+
// to remove, and a rollback via `--cacheinfo` cannot restore the flag.
|
|
1694
|
+
// It is the one state whose blob is the empty blob, whose path is not in
|
|
1695
|
+
// HEAD, and which `diff --cached` treats as absent from the index; an
|
|
1696
|
+
// ordinary staged empty file shows there as added. Three probes, on the
|
|
1697
|
+
// rare empty-blob path only.
|
|
1698
|
+
if (reason === null && indexEntry !== undefined && EMPTY_BLOBS.has(indexEntry.sha) && !inHeadPaths.has(trackedPath)) {
|
|
1699
|
+
const cached = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--', lit(trackedPath)], { cwd });
|
|
1700
|
+
if (cached.exitCode === 0 && cached.stdout.split('\0').filter(Boolean).length === 0)
|
|
1701
|
+
reason = 'an intent-to-add entry (git add -N), not tracked content';
|
|
1702
|
+
}
|
|
1703
|
+
if (reason !== null) {
|
|
1704
|
+
if (namesItself(trackedPath)) {
|
|
1705
|
+
failures.push({
|
|
1706
|
+
file: entry,
|
|
1707
|
+
error: `declared in --files-removed but is ${reason}: ${trackedPath}`,
|
|
1708
|
+
timed_out: false,
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
continue;
|
|
1712
|
+
}
|
|
1713
|
+
const state = lstatState(node_path_1.default.resolve(cwd, trackedPath));
|
|
1714
|
+
if (state === 'present')
|
|
1715
|
+
continue;
|
|
1716
|
+
if (state !== 'absent') {
|
|
1717
|
+
failures.push({ file: trackedPath, error: `lstat ${state.code ?? ''}: ${state.message}`, timed_out: false });
|
|
1718
|
+
continue;
|
|
1719
|
+
}
|
|
1720
|
+
// A HEAD-only path (the caller already `git rm`'d it) has no index entry
|
|
1721
|
+
// to record or restore; the `rm` below is then a no-op.
|
|
1722
|
+
// READ the entry before the mutation, RECORD it only after the mutation
|
|
1723
|
+
// SUCCEEDS. The read must precede (the rm is what destroys the mode/blob
|
|
1724
|
+
// the restore needs); the record must not, because `removedEntries` is
|
|
1725
|
+
// the set this call claims to have staged. Recording ahead of the rm made
|
|
1726
|
+
// a FAILED rm — a stale `index.lock` is the driven case — contribute an
|
|
1727
|
+
// entry the rollback then reported as "still staged in the index" when
|
|
1728
|
+
// nothing had been staged at all: a false disclosure, the mirror of the
|
|
1729
|
+
// silent one the disclosure was added to fix.
|
|
1730
|
+
const recordable = indexEntry !== undefined
|
|
1731
|
+
? { path: trackedPath, mode: indexEntry.mode, sha: indexEntry.sha }
|
|
1732
|
+
: null;
|
|
1733
|
+
// `--ignore-unmatch` makes "no such index entry" a success, so a non-zero
|
|
1734
|
+
// exit is a real I/O failure — same reading as the default-mode branch.
|
|
1735
|
+
const rmResult = (0, shell_command_projection_cjs_1.execGit)(['rm', '--cached', '--ignore-unmatch', '--', lit(trackedPath)], { cwd });
|
|
1736
|
+
if (rmResult.exitCode === 0) {
|
|
1737
|
+
if (recordable !== null)
|
|
1738
|
+
removedEntries.push(recordable);
|
|
1739
|
+
// Re-check AFTER the index mutation. The absence test and the `rm` are
|
|
1740
|
+
// not atomic, and the scoped `git commit -- <paths>` below reads the
|
|
1741
|
+
// WORKTREE, so a path recreated in between would be committed as its
|
|
1742
|
+
// new content under a message that declared it removed. A reappearance
|
|
1743
|
+
// is a contradiction like any other: staging failure, and the rollback
|
|
1744
|
+
// restores the recorded entry. Narrows the window; does not close it.
|
|
1745
|
+
if (lstatState(node_path_1.default.resolve(cwd, trackedPath)) !== 'absent') {
|
|
1746
|
+
failures.push({
|
|
1747
|
+
file: trackedPath,
|
|
1748
|
+
error: `declared in --files-removed but reappeared on disk: ${trackedPath}`,
|
|
1749
|
+
timed_out: false,
|
|
1750
|
+
});
|
|
1751
|
+
continue;
|
|
1752
|
+
}
|
|
1753
|
+
// Unborn HEAD: nothing to delete FROM, so the path is unstaged only and
|
|
1754
|
+
// never joins the pathspec; its rollback is the recorded entry above.
|
|
1755
|
+
if (headExists)
|
|
1756
|
+
removedPathspec.push(trackedPath);
|
|
1757
|
+
}
|
|
1758
|
+
else {
|
|
1759
|
+
// A NON-ZERO rm is NOT proof the index is untouched. `execGit` collapses
|
|
1760
|
+
// a spawn timeout to a non-zero exit, and a killed `git rm` can already
|
|
1761
|
+
// have written the index — so keying the record on the exit code alone
|
|
1762
|
+
// drops a real mutation on the timeout path (driven: a post-index-change
|
|
1763
|
+
// hook that outlives the timeout leaves `D <path>` staged and reported
|
|
1764
|
+
// nowhere). The exit code answers "did the command succeed", never "did
|
|
1765
|
+
// the index change". ASK THE INDEX instead — three honest arms, and no
|
|
1766
|
+
// arm asserts a state it did not observe.
|
|
1767
|
+
// THE ORIGINAL FAILURE IS PUSHED FIRST. `failures[0]` sets the result's
|
|
1768
|
+
// `reason`, `file`, `error` and timeout classification, so appending the
|
|
1769
|
+
// probe's diagnostic ahead of it renamed the cause: a timed-out rm was
|
|
1770
|
+
// reported as a permission error and lost its `timed_out: true`.
|
|
1771
|
+
failures.push({
|
|
1772
|
+
file: trackedPath,
|
|
1773
|
+
error: rmResult.stderr || rmResult.stdout,
|
|
1774
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(rmResult),
|
|
1775
|
+
});
|
|
1776
|
+
if (recordable !== null) {
|
|
1777
|
+
const after = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', lit(trackedPath)], { cwd });
|
|
1778
|
+
if (after.exitCode !== 0) {
|
|
1779
|
+
// Could not determine. Say so; never silently assume either way.
|
|
1780
|
+
failures.push({
|
|
1781
|
+
file: trackedPath,
|
|
1782
|
+
error: `removal failed and the index state for this path could NOT be determined: ${after.stderr || after.stdout}`,
|
|
1783
|
+
timed_out: (0, shell_command_projection_cjs_1.isSpawnTimeout)(after),
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
else if (after.stdout.replace(/\0/g, '').trim() === '') {
|
|
1787
|
+
// The entry is gone: the rm mutated the index before it failed, so
|
|
1788
|
+
// this call owns the removal and must restore/disclose it.
|
|
1789
|
+
removedEntries.push(recordable);
|
|
1790
|
+
}
|
|
1791
|
+
// else: the entry is still there — nothing was staged, nothing to undo.
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
return { removedEntries, removedPathspec, failures };
|
|
1797
|
+
}
|
|
1798
|
+
function cmdCommit(cwd, message, files, raw, amend, noVerify, filesRemoved) {
|
|
1446
1799
|
if (!message && !amend) {
|
|
1447
1800
|
error('commit message required');
|
|
1448
1801
|
}
|
|
@@ -1478,6 +1831,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1478
1831
|
const branchingStrategy = config['branching_strategy'];
|
|
1479
1832
|
if (branchingStrategy && branchingStrategy !== 'none') {
|
|
1480
1833
|
let branchName = null;
|
|
1834
|
+
// #4055: the phase directory (cwd-relative POSIX path from
|
|
1835
|
+
// findPhaseInternal) captured while resolving the phase identity — the
|
|
1836
|
+
// state-3 guard below needs it for the committed-history check.
|
|
1837
|
+
let phaseDirRelative = null;
|
|
1481
1838
|
if (branchingStrategy === 'phase') {
|
|
1482
1839
|
// Determine which phase we're committing for from the file paths.
|
|
1483
1840
|
// #2539: the extraction is anchored to the directory SEGMENT immediately
|
|
@@ -1499,9 +1856,16 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1499
1856
|
if (phaseNum && !isSentinelPhaseId(phaseNum)) {
|
|
1500
1857
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
1501
1858
|
if (phaseInfo) {
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1859
|
+
// #4126: shared with init.cts's cmdInitExecutePhase branch_name field
|
|
1860
|
+
// via the one canonical renderer (src/phase-id.cts) so an undeliverable
|
|
1861
|
+
// phase_slug degrades identically at both call sites instead of each
|
|
1862
|
+
// independently substituting the literal word 'phase'.
|
|
1863
|
+
branchName = renderPhaseBranchName(config['phase_branch_template'], phaseInfo['phase_number'], phaseInfo['phase_slug']);
|
|
1864
|
+
// #4055: findPhaseInternal already returns the directory as a
|
|
1865
|
+
// cwd-relative POSIX path.
|
|
1866
|
+
const dir = phaseInfo['directory'];
|
|
1867
|
+
if (typeof dir === 'string' && dir !== '')
|
|
1868
|
+
phaseDirRelative = dir;
|
|
1505
1869
|
}
|
|
1506
1870
|
}
|
|
1507
1871
|
}
|
|
@@ -1526,6 +1890,14 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1526
1890
|
}
|
|
1527
1891
|
}
|
|
1528
1892
|
if (branchName) {
|
|
1893
|
+
// #4055: state-3 discriminator for the create arm. `rev-parse --verify`
|
|
1894
|
+
// alone cannot distinguish "branch never existed" (create is the #1278
|
|
1895
|
+
// intent) from "branch existed, was merged, then deleted" (the phase is
|
|
1896
|
+
// over — recreating it hijacks the close-out commit onto a resurrected
|
|
1897
|
+
// ref, the #3079 bug #3363 reopened). Both extra conditions come from
|
|
1898
|
+
// the confirmed issue: the create arm may fire only for a phase whose
|
|
1899
|
+
// directory has NO committed history on the current line (a genuinely
|
|
1900
|
+
// new phase) while the caller sits on the resolved base branch.
|
|
1529
1901
|
const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
1530
1902
|
if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
|
|
1531
1903
|
// #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
|
|
@@ -1540,18 +1912,63 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1540
1912
|
// EXISTING branch is never switched to (the else arm logs + commits in
|
|
1541
1913
|
// place). The fresh create is logged so the first phase-scoped commit is
|
|
1542
1914
|
// not silent about where the work is landing (#3207 AC3).
|
|
1915
|
+
// #4055: "brand-new" is now VERIFIED, not assumed — see the state-3
|
|
1916
|
+
// guard between the verify and the create below.
|
|
1543
1917
|
const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
|
|
1544
1918
|
if (verify.exitCode !== 0) {
|
|
1545
|
-
// Branch does not exist —
|
|
1546
|
-
//
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1919
|
+
// Branch does not exist — but absence alone cannot distinguish a
|
|
1920
|
+
// genuinely new phase from a merged-and-deleted one (#4055).
|
|
1921
|
+
let createBlockReason = null;
|
|
1922
|
+
if (branchingStrategy === 'phase' && phaseDirRelative) {
|
|
1923
|
+
// #4055 residual: searchPhaseInDir's #2237 fail-safe can return an
|
|
1924
|
+
// empty `directory` for ambiguous phase names (leaving
|
|
1925
|
+
// phaseDirRelative null) — there the history half is skipped and
|
|
1926
|
+
// only the base check below guards; shallow clones can also show
|
|
1927
|
+
// an empty probe for old merged phases (depth-sensitive).
|
|
1928
|
+
const history = (0, shell_command_projection_cjs_1.execGit)(['log', 'HEAD', '--oneline', '--', phaseDirRelative], { cwd });
|
|
1929
|
+
if (history.exitCode === 0 && history.stdout.trim() !== '') {
|
|
1930
|
+
createBlockReason =
|
|
1931
|
+
'its phase directory already has committed history (the phase is resolved)';
|
|
1932
|
+
}
|
|
1933
|
+
}
|
|
1934
|
+
if (!createBlockReason) {
|
|
1935
|
+
// The base half of the guard applies to BOTH strategies (it does
|
|
1936
|
+
// not need a directory): a phase/milestone branch is created only
|
|
1937
|
+
// from the resolved base branch. NOTE the milestone arm keeps its
|
|
1938
|
+
// existence-only guard for the HISTORY half — a merged-and-deleted
|
|
1939
|
+
// milestone branch remains resurrectable by an on-base caller
|
|
1940
|
+
// until a milestone-directory derivation exists here (#4055
|
|
1941
|
+
// follow-up candidate).
|
|
1942
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
1943
|
+
const gitBaseBranch = require('./git-base-branch.cjs');
|
|
1944
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
1945
|
+
const resolvedBase = gitBaseBranch.resolveBaseBranch(cwd);
|
|
1946
|
+
if (resolvedBase && resolvedBase !== currentBranch.stdout.trim()) {
|
|
1947
|
+
createBlockReason =
|
|
1948
|
+
`the current branch "${currentBranch.stdout.trim()}" is not the ` +
|
|
1949
|
+
`resolved base branch "${resolvedBase}"`;
|
|
1950
|
+
}
|
|
1951
|
+
}
|
|
1952
|
+
if (createBlockReason === null) {
|
|
1953
|
+
// State 1 confirmed: brand-new phase, first phase-scoped commit
|
|
1954
|
+
// from the base branch. CREATE AND SWITCH (the #1278 first-commit
|
|
1955
|
+
// case). checkout -b cannot resurrect anything: the branch was
|
|
1956
|
+
// just verified absent, so it is created fresh at HEAD.
|
|
1957
|
+
const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
|
|
1958
|
+
if (create.exitCode === 0) {
|
|
1959
|
+
process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
|
|
1960
|
+
}
|
|
1961
|
+
else {
|
|
1962
|
+
process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
|
|
1963
|
+
`(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
|
|
1964
|
+
}
|
|
1551
1965
|
}
|
|
1552
1966
|
else {
|
|
1553
|
-
|
|
1554
|
-
|
|
1967
|
+
// State 3 (or a non-base caller): the phase is resolved — commit
|
|
1968
|
+
// in place, disclosed (#2539 AC2), never recreate the branch.
|
|
1969
|
+
process.stderr.write(`Warning: resolved ${branchingStrategy} branch "${branchName}" is absent and ` +
|
|
1970
|
+
`will not be recreated (${createBlockReason}); committing on the current ` +
|
|
1971
|
+
`branch "${currentBranch.stdout.trim()}" instead of recreating it.\n`);
|
|
1555
1972
|
}
|
|
1556
1973
|
}
|
|
1557
1974
|
else {
|
|
@@ -1563,8 +1980,12 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1563
1980
|
}
|
|
1564
1981
|
}
|
|
1565
1982
|
// Stage files
|
|
1566
|
-
|
|
1567
|
-
|
|
1983
|
+
// #4208: `--files-removed` is a declared scope in its own right — a caller
|
|
1984
|
+
// that names only removals must not fall through to the unscoped
|
|
1985
|
+
// `.planning/` sweep, which would commit everything under it.
|
|
1986
|
+
const removedDeclared = filesRemoved ?? [];
|
|
1987
|
+
const explicitFiles = (files && files.length > 0) || removedDeclared.length > 0;
|
|
1988
|
+
const filesToStage = explicitFiles ? (files ?? []) : ['.planning/'];
|
|
1568
1989
|
const stagedPaths = [];
|
|
1569
1990
|
// #2608: a `git add` that fails must abort the commit, not be skipped.
|
|
1570
1991
|
// #2523 stopped a failed path entering the commit pathspec, but skipping it
|
|
@@ -1574,11 +1995,27 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1574
1995
|
// a linked worktree, timeout) was discarded and the operator saw a downstream
|
|
1575
1996
|
// pathspec error pointing at an innocent file.
|
|
1576
1997
|
const stagingFailures = [];
|
|
1998
|
+
// #4454: explicit --files paths skipped because they were missing from disk
|
|
1999
|
+
// (the #2014 guard below). Tracked so the caller can tell a partial commit
|
|
2000
|
+
// from a complete one instead of an unqualified `committed: true`.
|
|
2001
|
+
const skippedFiles = [];
|
|
1577
2002
|
// Paths already in the index BEFORE this call. On a staging failure the
|
|
1578
2003
|
// rollback below unstages only what THIS call added — unstaging a path the
|
|
1579
2004
|
// caller had staged themselves would destroy their work.
|
|
1580
|
-
|
|
1581
|
-
|
|
2005
|
+
// `-z`: without it `core.quotePath` renders a non-ASCII name as
|
|
2006
|
+
// `"caf\303\251.md"`, which never equals the raw path in `stagedPaths`, so
|
|
2007
|
+
// the rollback below would treat a caller-pre-staged `café.md` as this
|
|
2008
|
+
// call's own and unstage it (#4208 review, driven).
|
|
2009
|
+
// `--relative`: `diff --cached` prints REPO-relative paths whatever the cwd,
|
|
2010
|
+
// while `stagedPaths` holds the caller's own cwd-relative names. In a project
|
|
2011
|
+
// nested inside its repo (`<repo>/sub/.planning/...`) the two name spaces
|
|
2012
|
+
// never intersect, so `preStaged` matched NOTHING and the rollback unstaged
|
|
2013
|
+
// every path including the caller's own pre-staged work. Driven on a nested
|
|
2014
|
+
// fixture: a caller-staged deletion vanished from `diff --cached` after an
|
|
2015
|
+
// unrelated declaration failed. Pre-existing -- it governs the `--files` side
|
|
2016
|
+
// too -- and a no-op when the project IS the repo root.
|
|
2017
|
+
const preStaged = new Set((0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z', '--relative'], { cwd })
|
|
2018
|
+
.stdout.split('\0').filter(Boolean));
|
|
1582
2019
|
for (const file of filesToStage) {
|
|
1583
2020
|
const fullPath = node_path_1.default.resolve(cwd, file);
|
|
1584
2021
|
if (!node_fs_1.default.existsSync(fullPath)) {
|
|
@@ -1586,6 +2023,9 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1586
2023
|
// Caller passed an explicit --files list: missing files are skipped.
|
|
1587
2024
|
// Staging a deletion here would silently remove tracked planning files
|
|
1588
2025
|
// (e.g. STATE.md, ROADMAP.md) when they are temporarily absent (#2014).
|
|
2026
|
+
// #4454: record what was skipped so the caller can tell a partial
|
|
2027
|
+
// commit from a complete one, instead of an unqualified success.
|
|
2028
|
+
skippedFiles.push(file);
|
|
1589
2029
|
continue;
|
|
1590
2030
|
}
|
|
1591
2031
|
// Default mode (staging all of .planning/): stage the deletion so
|
|
@@ -1623,6 +2063,75 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1623
2063
|
}
|
|
1624
2064
|
}
|
|
1625
2065
|
}
|
|
2066
|
+
// #4208: caller-declared removals -- see stageDeclaredRemovals.
|
|
2067
|
+
const declaredRemovals = stageDeclaredRemovals(cwd, removedDeclared);
|
|
2068
|
+
const removedEntries = declaredRemovals.removedEntries;
|
|
2069
|
+
stagingFailures.push(...declaredRemovals.failures);
|
|
2070
|
+
stagedPaths.push(...declaredRemovals.removedPathspec);
|
|
2071
|
+
// A REMOVAL'S PATH IS A PATH DOWNSTREAM TOO. Literalising the staging alone
|
|
2072
|
+
// does not protect the COMMIT's own pathspec: with a tracked file literally
|
|
2073
|
+
// named `.planning/*.md` declared removed beside a MODIFIED `peer.md`, the
|
|
2074
|
+
// `git commit -- <paths>` below globs and commits `M peer.md` the caller
|
|
2075
|
+
// never declared — the sweep this flag exists to remove, arriving one step
|
|
2076
|
+
// later. Driven. Only the removal-derived entries are literalised: `--files`
|
|
2077
|
+
// entries keep whatever pathspec behaviour they have today, which is not this
|
|
2078
|
+
// change's to alter.
|
|
2079
|
+
const removalPathspecs = new Set(declaredRemovals.removedPathspec);
|
|
2080
|
+
const asPathspec = (p) => (removalPathspecs.has(p) ? `:(literal)${p}` : p);
|
|
2081
|
+
const restoreRemovedEntries = () => {
|
|
2082
|
+
if (removedEntries.length === 0)
|
|
2083
|
+
return 'restored';
|
|
2084
|
+
(0, shell_command_projection_cjs_1.execGit)(['update-index', '--add', ...removedEntries.flatMap(e => ['--cacheinfo', `${e.mode},${e.sha},${e.path}`])], { cwd });
|
|
2085
|
+
// VERIFY BY READING THE INDEX BACK, never by the exit code. `execGit`
|
|
2086
|
+
// collapses a spawn timeout to a non-zero exit, and a killed `update-index`
|
|
2087
|
+
// can already have written the index — so an exit code answers "did the
|
|
2088
|
+
// command succeed", never "is the entry back". Driven: a post-index-change
|
|
2089
|
+
// hook outliving the timeout made the restore report failure over an index
|
|
2090
|
+
// it had in fact restored, publishing a disclosure that was simply false.
|
|
2091
|
+
//
|
|
2092
|
+
// `-z` IS LOAD-BEARING, and its absence is the #2014-era defect this PR
|
|
2093
|
+
// already fixed once for `preStaged`: without it `core.quotePath` renders a
|
|
2094
|
+
// non-ASCII name as `"caf\303\251.md"`, which never equals the raw path, so
|
|
2095
|
+
// an exactly-restored `café.md` (and any name carrying a tab or a newline)
|
|
2096
|
+
// read as NOT restored. Driven on all three shapes.
|
|
2097
|
+
const back = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-s', '-z', '--', ...removedEntries.map(e => `:(literal)${e.path}`)], { cwd });
|
|
2098
|
+
if (back.exitCode !== 0)
|
|
2099
|
+
return 'unverified'; // no observation — never an assertion of failure
|
|
2100
|
+
// COMPARE THE WHOLE ENTRY, not just the path. `--cacheinfo` restores mode,
|
|
2101
|
+
// blob and stage; a path present at a DIFFERENT mode or blob is not the
|
|
2102
|
+
// entry this call removed. Driven: a hook that rewrote the restored entry
|
|
2103
|
+
// 100644 -> 100755 was reported as restored by a path-only test.
|
|
2104
|
+
const present = new Map();
|
|
2105
|
+
for (const rec of back.stdout.split('\0')) {
|
|
2106
|
+
if (rec === '')
|
|
2107
|
+
continue;
|
|
2108
|
+
const tab = rec.indexOf('\t');
|
|
2109
|
+
if (tab === -1)
|
|
2110
|
+
continue;
|
|
2111
|
+
present.set(rec.slice(tab + 1), rec.slice(0, tab));
|
|
2112
|
+
}
|
|
2113
|
+
const ok = removedEntries.every(e => present.get(e.path) === `${e.mode} ${e.sha} 0`);
|
|
2114
|
+
return ok ? 'restored' : 'not-restored';
|
|
2115
|
+
};
|
|
2116
|
+
// The no-change exits' shared arm: restore, and if the restore failed, say so
|
|
2117
|
+
// instead of claiming nothing changed. `staging_failed` is the honest reason —
|
|
2118
|
+
// the index carries a mutation this call made and could not undo.
|
|
2119
|
+
const removalsLeftStaged = (verdict) => ({
|
|
2120
|
+
committed: false,
|
|
2121
|
+
hash: null,
|
|
2122
|
+
reason: 'staging_failed',
|
|
2123
|
+
file: removedEntries[0]?.path ?? null,
|
|
2124
|
+
error: verdict === 'not-restored'
|
|
2125
|
+
? `declared removal(s) staged but could not be restored after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`
|
|
2126
|
+
: `declared removal(s) staged and the restore could NOT be VERIFIED after the commit recorded nothing: ${removedEntries.map(e => e.path).join(', ')}`,
|
|
2127
|
+
failures: removedEntries.map(e => ({
|
|
2128
|
+
file: e.path,
|
|
2129
|
+
error: verdict === 'not-restored'
|
|
2130
|
+
? 'update-index --cacheinfo restore failed'
|
|
2131
|
+
: 'update-index --cacheinfo restore could not be verified — the index was not readable',
|
|
2132
|
+
timed_out: false,
|
|
2133
|
+
})),
|
|
2134
|
+
});
|
|
1626
2135
|
// #2608: fail closed before `git commit` runs. Checked ahead of the
|
|
1627
2136
|
// nothing_to_commit branch below so a run where EVERY path failed to stage
|
|
1628
2137
|
// reports the staging cause rather than "nothing to commit", and ahead of the
|
|
@@ -1637,10 +2146,37 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1637
2146
|
// best-effort: if the index is unwritable — the very failure being reported
|
|
1638
2147
|
// — the reset cannot succeed either, and the staging error is still what
|
|
1639
2148
|
// gets returned.
|
|
1640
|
-
const
|
|
2149
|
+
const removedPaths = new Set(removedEntries.map(e => e.path));
|
|
2150
|
+
const toUnstage = stagedPaths.filter(p => !preStaged.has(p) && !removedPaths.has(p));
|
|
1641
2151
|
if (toUnstage.length > 0) {
|
|
1642
|
-
|
|
1643
|
-
|
|
2152
|
+
// `asPathspec` here too. This reset is the LAST place a removal-derived
|
|
2153
|
+
// name reaches git as a pathspec, and it is the most damaging: driven,
|
|
2154
|
+
// a wildcard-named entry that slipped into `toUnstage` globbed and
|
|
2155
|
+
// unstaged the CALLER'S OWN pre-staged deletion and modification, then
|
|
2156
|
+
// reported only the contradiction that triggered the rollback.
|
|
2157
|
+
(0, shell_command_projection_cjs_1.execGit)(['reset', '-q', '--', ...toUnstage.map(asPathspec)], { cwd });
|
|
2158
|
+
}
|
|
2159
|
+
// Removals are restored from the recorded entries, never via `reset`
|
|
2160
|
+
// (no HEAD to reset to on an unborn branch; not the pre-staged blob when
|
|
2161
|
+
// the caller had one) — and unconditionally, since a removal this call
|
|
2162
|
+
// performed is this call's to undo whether or not the path was pre-staged.
|
|
2163
|
+
// DISCLOSE a failed restore here too. The earlier reading -- that this exit
|
|
2164
|
+
// is already reporting a failure, so the restore's result adds nothing --
|
|
2165
|
+
// is wrong, and the counterexample is the ordinary one: the reported
|
|
2166
|
+
// failure is usually a DIFFERENT cause (a contradictory declaration, a
|
|
2167
|
+
// reappeared path), so a caller reading `failures` sees only that cause
|
|
2168
|
+
// and learns nothing about the removal still sitting in its index. Append
|
|
2169
|
+
// rather than replace: the original failure is still the reason.
|
|
2170
|
+
const restoreVerdict = restoreRemovedEntries();
|
|
2171
|
+
const failures = restoreVerdict === 'restored'
|
|
2172
|
+
? stagingFailures
|
|
2173
|
+
: [...stagingFailures, ...removedEntries.map(e => ({
|
|
2174
|
+
file: e.path,
|
|
2175
|
+
error: restoreVerdict === 'not-restored'
|
|
2176
|
+
? 'staged removal could NOT be restored during rollback — it is still staged in the index'
|
|
2177
|
+
: 'staged removal was rolled back but the result could NOT be VERIFIED — the index was not readable',
|
|
2178
|
+
timed_out: false,
|
|
2179
|
+
}))];
|
|
1644
2180
|
const first = stagingFailures[0];
|
|
1645
2181
|
const result = {
|
|
1646
2182
|
committed: false,
|
|
@@ -1648,7 +2184,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1648
2184
|
reason: first.timed_out ? 'staging_timeout' : 'staging_failed',
|
|
1649
2185
|
file: first.file,
|
|
1650
2186
|
error: first.error,
|
|
1651
|
-
failures
|
|
2187
|
+
failures,
|
|
1652
2188
|
};
|
|
1653
2189
|
output(result, raw, 'failed');
|
|
1654
2190
|
return;
|
|
@@ -1851,13 +2387,13 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1851
2387
|
// failing-closed (drop the content) and failing-open (re-enter #3776) are
|
|
1852
2388
|
// wrong answers to a question we can just ask directly.
|
|
1853
2389
|
const assumeUnchangedWouldRecord = () => {
|
|
1854
|
-
const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths], { cwd });
|
|
2390
|
+
const listed = (0, shell_command_projection_cjs_1.execGit)(['ls-files', '-v', '--', ...stagedPaths.map(asPathspec)], { cwd });
|
|
1855
2391
|
// Only the TAG is read; the path is deliberately never parsed out — see the
|
|
1856
2392
|
// `core.quotePath` note above, and the dry run below needs no path anyway.
|
|
1857
2393
|
if (listed.exitCode === 0
|
|
1858
2394
|
&& !listed.stdout.split('\n').some((line) => /^[a-z] /.test(line)))
|
|
1859
2395
|
return false;
|
|
1860
|
-
const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths], { cwd });
|
|
2396
|
+
const dryRun = (0, shell_command_projection_cjs_1.execGit)(['commit', '--dry-run', '--porcelain', '--no-verify', '-m', sanitizedMessage, '--', ...stagedPaths.map(asPathspec)], { cwd });
|
|
1861
2397
|
// Only a CONFIRMED "nothing to record" closes the path: rc 1 from a git
|
|
1862
2398
|
// that actually answered. This is the one probe in the guard whose rc 0
|
|
1863
2399
|
// is the REASSURING answer, so it inverts the diff probe's safety: there
|
|
@@ -1879,10 +2415,30 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1879
2415
|
const nothingToCommit = guardApplies
|
|
1880
2416
|
&& (stagedPaths.length === 0
|
|
1881
2417
|
|| (!partialCommitRefused
|
|
1882
|
-
&& (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths], { cwd }).exitCode === 0
|
|
2418
|
+
&& (0, shell_command_projection_cjs_1.execGit)(['diff', '--quiet', '--ignore-submodules=dirty', '--no-textconv', 'HEAD', '--', ...stagedPaths.map(asPathspec)], { cwd }).exitCode === 0
|
|
1883
2419
|
&& !assumeUnchangedWouldRecord()));
|
|
1884
2420
|
if (nothingToCommit) {
|
|
1885
|
-
|
|
2421
|
+
// Nothing is being recorded, so any removal this call staged has no commit
|
|
2422
|
+
// to land in. Put it back before reporting no state change. Reachable on
|
|
2423
|
+
// two shapes, and keying on either one alone leaves the other broken:
|
|
2424
|
+
// an unborn HEAD (a removal never joins `stagedPaths`, so the pathspec is
|
|
2425
|
+
// empty), and a HEAD that simply does not carry the removed path -- an
|
|
2426
|
+
// index-only entry the caller `git add`ed but never committed, where the
|
|
2427
|
+
// `diff HEAD` probe reads clean because the path is absent on both sides.
|
|
2428
|
+
const rv = restoreRemovedEntries();
|
|
2429
|
+
if (rv !== 'restored') {
|
|
2430
|
+
output(removalsLeftStaged(rv), raw, 'failed');
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
// #4454: an explicit --files list where every named path was missing
|
|
2434
|
+
// reaches this branch via `stagedPaths.length === 0` above — surface
|
|
2435
|
+
// which path(s) were the reason, same as the success result below.
|
|
2436
|
+
const result = {
|
|
2437
|
+
committed: false,
|
|
2438
|
+
hash: null,
|
|
2439
|
+
reason: 'nothing_to_commit',
|
|
2440
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2441
|
+
};
|
|
1886
2442
|
output(result, raw, 'nothing');
|
|
1887
2443
|
return;
|
|
1888
2444
|
}
|
|
@@ -1894,7 +2450,7 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1894
2450
|
if (noVerify)
|
|
1895
2451
|
commitArgs.push('--no-verify');
|
|
1896
2452
|
if (canScope) {
|
|
1897
|
-
commitArgs.push('--', ...stagedPaths);
|
|
2453
|
+
commitArgs.push('--', ...stagedPaths.map(asPathspec));
|
|
1898
2454
|
}
|
|
1899
2455
|
// #3859 follow-up: on git 2.39.5 (confirmed on the CI Linux bench image,
|
|
1900
2456
|
// ghcr.io/open-gsd/gsd-tester-linux:v1.8.0-node24; NOT reproducible on git
|
|
@@ -1952,7 +2508,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1952
2508
|
return;
|
|
1953
2509
|
}
|
|
1954
2510
|
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
1955
|
-
|
|
2511
|
+
// Same reading as the guard above: git recorded nothing, so a removal
|
|
2512
|
+
// this call staged must not be left behind under a `nothing_to_commit`
|
|
2513
|
+
// report. The failure exits below are deliberately NOT restored -- they
|
|
2514
|
+
// report a failure rather than "no state changed", and the addition side
|
|
2515
|
+
// leaves its own staged paths in place there too.
|
|
2516
|
+
const rv = restoreRemovedEntries();
|
|
2517
|
+
if (rv !== 'restored') {
|
|
2518
|
+
output(removalsLeftStaged(rv), raw, 'failed');
|
|
2519
|
+
return;
|
|
2520
|
+
}
|
|
2521
|
+
// #4454: this is the residual window the surrounding comments already
|
|
2522
|
+
// document (a partial skip + partialCommitRefused bypassing the diff
|
|
2523
|
+
// probe + git's own empty-commit refusal) — skippedFiles can be
|
|
2524
|
+
// non-empty here too, and omitting it would be the same misreport
|
|
2525
|
+
// this fix exists to close, just on the other branch that reaches
|
|
2526
|
+
// "nothing to commit".
|
|
2527
|
+
const result = {
|
|
2528
|
+
committed: false,
|
|
2529
|
+
hash: null,
|
|
2530
|
+
reason: 'nothing_to_commit',
|
|
2531
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2532
|
+
};
|
|
1956
2533
|
output(result, raw, 'nothing');
|
|
1957
2534
|
return;
|
|
1958
2535
|
}
|
|
@@ -1968,7 +2545,15 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
1968
2545
|
// Get short hash
|
|
1969
2546
|
const hashResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--short', 'HEAD'], { cwd });
|
|
1970
2547
|
const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
|
|
1971
|
-
|
|
2548
|
+
// #4454: report explicit --files paths that were skipped as missing (the
|
|
2549
|
+
// #2014 guard above) so a caller can tell a partial commit from a complete
|
|
2550
|
+
// one, without changing the payload shape when nothing was skipped.
|
|
2551
|
+
const result = {
|
|
2552
|
+
committed: true,
|
|
2553
|
+
hash,
|
|
2554
|
+
reason: 'committed',
|
|
2555
|
+
...(skippedFiles.length > 0 ? { skipped_files: skippedFiles } : {}),
|
|
2556
|
+
};
|
|
1972
2557
|
output(result, raw, hash || 'committed');
|
|
1973
2558
|
}
|
|
1974
2559
|
/**
|
|
@@ -2007,7 +2592,7 @@ function groupFilesBySubrepo(files, subRepos) {
|
|
|
2007
2592
|
let matchLen = -1;
|
|
2008
2593
|
if (candidates) {
|
|
2009
2594
|
for (const repo of candidates) {
|
|
2010
|
-
if (file.startsWith(repo + '/')) {
|
|
2595
|
+
if (file.startsWith(repo + '/')) { // allow-handrolled-containment: sub-repo file grouping, not a safety decision
|
|
2011
2596
|
const repoLen = String(repo).length;
|
|
2012
2597
|
if (repoLen > matchLen) {
|
|
2013
2598
|
match = repo;
|
|
@@ -2156,15 +2741,13 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
|
|
|
2156
2741
|
error(`Branch name must not start with '-': ${branch}`);
|
|
2157
2742
|
}
|
|
2158
2743
|
// 0. Security: validate repo path is contained within the workspace root.
|
|
2159
|
-
// Uses security.cjs
|
|
2744
|
+
// Uses security.cjs tryWithinRoot (symlink-safe realpathSync + startsWith guard)
|
|
2160
2745
|
// to reject ../escape, absolute paths, and symlink traversal.
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
if (!pathCheck.safe) {
|
|
2165
|
-
error(`Sub-repo path is unsafe: ${pathCheck.error}`);
|
|
2746
|
+
const repoContained = (0, security_cjs_1.tryWithinRoot)(repo, cwd);
|
|
2747
|
+
if (repoContained === null) {
|
|
2748
|
+
error(`Sub-repo path is unsafe: resolves outside the workspace root`);
|
|
2166
2749
|
}
|
|
2167
|
-
const repoCwd =
|
|
2750
|
+
const repoCwd = repoContained;
|
|
2168
2751
|
if (!node_fs_1.default.existsSync(repoCwd)) {
|
|
2169
2752
|
error(`Sub-repo not found: ${repoCwd}`);
|
|
2170
2753
|
}
|
|
@@ -2468,6 +3051,11 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
2468
3051
|
function cmdProgressRender(cwd, format, raw) {
|
|
2469
3052
|
const phasesDir = planningPaths(cwd).phases;
|
|
2470
3053
|
const milestone = getMilestoneInfo(cwd).value;
|
|
3054
|
+
const phaseIdConvention = resolvePhaseIdConvention(cwd);
|
|
3055
|
+
const milestoneDisplay = phaseIdConvention === 'bracket'
|
|
3056
|
+
? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
|
|
3057
|
+
: null;
|
|
3058
|
+
const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
|
|
2471
3059
|
const phases = [];
|
|
2472
3060
|
let totalPlans = 0;
|
|
2473
3061
|
let totalSummaries = 0;
|
|
@@ -2478,12 +3066,36 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2478
3066
|
// comparePhaseNum. This command previously read the phases directory
|
|
2479
3067
|
// directly with neither, which is why `query progress` listed 999.*
|
|
2480
3068
|
// backlog directories as current-milestone phases (#3167).
|
|
2481
|
-
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
3069
|
+
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
3070
|
+
cwd,
|
|
3071
|
+
phaseIdConvention,
|
|
3072
|
+
});
|
|
2482
3073
|
phaseScope = scope;
|
|
2483
3074
|
for (const dir of dirs) {
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
|
|
3075
|
+
let phaseNum;
|
|
3076
|
+
let phaseName;
|
|
3077
|
+
let displayId;
|
|
3078
|
+
if (phaseIdConvention === 'bracket') {
|
|
3079
|
+
try {
|
|
3080
|
+
const projection = bracketPhaseDirProjection(dir);
|
|
3081
|
+
phaseNum = projection.number;
|
|
3082
|
+
phaseName = projection.name;
|
|
3083
|
+
displayId = projection.display_id;
|
|
3084
|
+
}
|
|
3085
|
+
catch {
|
|
3086
|
+
// A malformed/tolerated directory must not suppress every later row.
|
|
3087
|
+
// It cannot receive a canonical display_id because parsePhaseId
|
|
3088
|
+
// rejected it, but the convention-aware token keeps it observable.
|
|
3089
|
+
const phaseToken = extractPhaseToken(dir, phaseIdConvention);
|
|
3090
|
+
phaseNum = phaseToken || dir;
|
|
3091
|
+
phaseName = recoverBracketPhaseName(dir, phaseToken);
|
|
3092
|
+
}
|
|
3093
|
+
}
|
|
3094
|
+
else {
|
|
3095
|
+
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
3096
|
+
phaseNum = dm ? dm[1] : dir;
|
|
3097
|
+
phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
3098
|
+
}
|
|
2487
3099
|
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
|
2488
3100
|
// canonical pairing) from the single owner.
|
|
2489
3101
|
const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
@@ -2492,7 +3104,14 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2492
3104
|
totalPlans += plans;
|
|
2493
3105
|
totalSummaries += summaries;
|
|
2494
3106
|
const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Pending');
|
|
2495
|
-
phases.push({
|
|
3107
|
+
phases.push({
|
|
3108
|
+
number: phaseNum,
|
|
3109
|
+
...(displayId ? { display_id: displayId } : {}),
|
|
3110
|
+
name: phaseName,
|
|
3111
|
+
plans,
|
|
3112
|
+
summaries,
|
|
3113
|
+
status,
|
|
3114
|
+
});
|
|
2496
3115
|
}
|
|
2497
3116
|
}
|
|
2498
3117
|
catch { /* intentionally empty */ }
|
|
@@ -2508,23 +3127,19 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2508
3127
|
: null;
|
|
2509
3128
|
if (format === 'table') {
|
|
2510
3129
|
// Render markdown table
|
|
2511
|
-
const
|
|
2512
|
-
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
2513
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
3130
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
|
|
2514
3131
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2515
|
-
let out = `# ${
|
|
3132
|
+
let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''}\n\n`;
|
|
2516
3133
|
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
|
|
2517
3134
|
out += `| Phase | Name | Plans | Status |\n`;
|
|
2518
3135
|
out += `|-------|------|-------|--------|\n`;
|
|
2519
3136
|
for (const p of phases) {
|
|
2520
|
-
out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
|
|
3137
|
+
out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
|
|
2521
3138
|
}
|
|
2522
3139
|
output({ rendered: out }, raw, out);
|
|
2523
3140
|
}
|
|
2524
3141
|
else if (format === 'bar') {
|
|
2525
|
-
const
|
|
2526
|
-
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
2527
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
3142
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 20);
|
|
2528
3143
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2529
3144
|
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
|
|
2530
3145
|
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
@@ -2532,7 +3147,7 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
2532
3147
|
else {
|
|
2533
3148
|
// JSON format
|
|
2534
3149
|
output({
|
|
2535
|
-
milestone_version:
|
|
3150
|
+
milestone_version: milestoneVersion,
|
|
2536
3151
|
milestone_name: milestone?.name ?? null,
|
|
2537
3152
|
phases,
|
|
2538
3153
|
total_plans: totalPlans,
|
|
@@ -2553,7 +3168,8 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
2553
3168
|
if (!phase) {
|
|
2554
3169
|
error('phase required for todo match-phase');
|
|
2555
3170
|
}
|
|
2556
|
-
|
|
3171
|
+
// #4256: root-scoped todos read — see cmdListTodos.
|
|
3172
|
+
const pendingDir = node_path_1.default.join(todosDir(cwd), 'pending');
|
|
2557
3173
|
const todos = [];
|
|
2558
3174
|
// Load pending todos
|
|
2559
3175
|
try {
|
|
@@ -2685,13 +3301,58 @@ function cmdTodoComplete(cwd, filename, options, raw) {
|
|
|
2685
3301
|
if (!filename) {
|
|
2686
3302
|
error('filename required for todo complete');
|
|
2687
3303
|
}
|
|
2688
|
-
|
|
2689
|
-
|
|
3304
|
+
// #4256: root-scoped todos read/write — see cmdListTodos. The pending and
|
|
3305
|
+
// completed halves of the move must resolve from the SAME root or the
|
|
3306
|
+
// completion would strand files where no reader looks.
|
|
3307
|
+
const todosRoot = todosDir(cwd);
|
|
3308
|
+
const pendingDir = node_path_1.default.join(todosRoot, 'pending');
|
|
3309
|
+
const completedDir = node_path_1.default.join(todosRoot, 'completed');
|
|
3310
|
+
// #4652: containment against todosRoot only rejects paths that leave the
|
|
3311
|
+
// root — it cannot express "a todo name is a basename, not a path" (see
|
|
3312
|
+
// #4327). `../sibling.md`, `a/../../b.md`, and `sub/name.md` all resolve
|
|
3313
|
+
// to a location inside todosRoot (or inside pending/) and would pass
|
|
3314
|
+
// containment, yet none of them is a bare filename. Reject on basename
|
|
3315
|
+
// shape FIRST, before any path is even joined — same predicate shape as
|
|
3316
|
+
// findPhaseArtifact in check-command-router.cts. Checking both `/` and
|
|
3317
|
+
// `\` explicitly (not just path.basename) matters on POSIX, where a
|
|
3318
|
+
// literal backslash is just an ordinary filename character to
|
|
3319
|
+
// path.basename but not to path.win32.basename or to the user's intent.
|
|
3320
|
+
const rawFilename = filename;
|
|
3321
|
+
if (rawFilename === '.' ||
|
|
3322
|
+
rawFilename === '..' ||
|
|
3323
|
+
rawFilename.includes('\0') ||
|
|
3324
|
+
rawFilename.includes('/') ||
|
|
3325
|
+
rawFilename.includes('\\') ||
|
|
3326
|
+
node_path_1.default.basename(rawFilename) !== rawFilename ||
|
|
3327
|
+
node_path_1.default.win32.basename(rawFilename) !== rawFilename) {
|
|
3328
|
+
error(`todo name must be a plain filename inside the pending directory, not a path: ${rawFilename}`, ERROR_REASON.USAGE);
|
|
3329
|
+
}
|
|
2690
3330
|
const sourcePath = node_path_1.default.join(pendingDir, filename);
|
|
2691
|
-
|
|
3331
|
+
const targetPath = node_path_1.default.join(completedDir, filename);
|
|
3332
|
+
const sourceContained = (0, security_cjs_1.tryWithinRoot)(sourcePath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
3333
|
+
if (sourceContained === null) {
|
|
3334
|
+
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
|
|
3335
|
+
}
|
|
3336
|
+
const targetContained = (0, security_cjs_1.tryWithinRoot)(targetPath, todosRoot, security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
3337
|
+
if (targetContained === null) {
|
|
3338
|
+
error(`todo file escapes its allowed directory: ${filename}`, ERROR_REASON.USAGE);
|
|
3339
|
+
}
|
|
3340
|
+
const resolvedSource = sourceContained;
|
|
3341
|
+
const resolvedTarget = targetContained;
|
|
3342
|
+
if (!node_fs_1.default.existsSync(resolvedSource)) {
|
|
2692
3343
|
error(`Todo not found: ${filename}`);
|
|
2693
3344
|
}
|
|
2694
|
-
|
|
3345
|
+
// #4652: a name that IS a bare basename can still resolve to something that
|
|
3346
|
+
// is not a regular file — a directory, symlink-to-directory, FIFO or socket
|
|
3347
|
+
// sitting in pending/ under an ordinary-looking name. `.` and `..` no longer
|
|
3348
|
+
// reach here (the basename guard above rejects them first), so this is not
|
|
3349
|
+
// about traversal; it stops fs.readFileSync from throwing an uncaught EISDIR
|
|
3350
|
+
// with an absolute-path stack trace where every sibling case gives a clean
|
|
3351
|
+
// USAGE rejection.
|
|
3352
|
+
if (!node_fs_1.default.statSync(resolvedSource).isFile()) {
|
|
3353
|
+
error(`todo name is not a file: ${filename}`, ERROR_REASON.USAGE);
|
|
3354
|
+
}
|
|
3355
|
+
const content = node_fs_1.default.readFileSync(resolvedSource, 'utf-8');
|
|
2695
3356
|
const today = clock_cjs_1.realClock.localToday();
|
|
2696
3357
|
// #4096: --dry-run mirrors `milestone complete --dry-run` (#2118) — every
|
|
2697
3358
|
// existence check above still runs, nothing below mutates, and the payload
|
|
@@ -2703,8 +3364,8 @@ function cmdTodoComplete(cwd, filename, options, raw) {
|
|
|
2703
3364
|
file: filename,
|
|
2704
3365
|
date: today,
|
|
2705
3366
|
would_move: {
|
|
2706
|
-
source: node_path_1.default.relative(cwd,
|
|
2707
|
-
target: node_path_1.default.relative(cwd,
|
|
3367
|
+
source: node_path_1.default.relative(cwd, resolvedSource).split(node_path_1.default.sep).join('/'),
|
|
3368
|
+
target: node_path_1.default.relative(cwd, resolvedTarget).split(node_path_1.default.sep).join('/'),
|
|
2708
3369
|
},
|
|
2709
3370
|
would_set: { completed: today, status: 'completed' },
|
|
2710
3371
|
}, raw);
|
|
@@ -2714,8 +3375,8 @@ function cmdTodoComplete(cwd, filename, options, raw) {
|
|
|
2714
3375
|
// creates nothing).
|
|
2715
3376
|
(0, shell_command_projection_cjs_1.platformEnsureDir)(completedDir);
|
|
2716
3377
|
const completedContent = upsertTodoCompletionFields(content, today);
|
|
2717
|
-
(0, shell_command_projection_cjs_1.platformWriteSync)(
|
|
2718
|
-
node_fs_1.default.unlinkSync(
|
|
3378
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(resolvedTarget, completedContent);
|
|
3379
|
+
node_fs_1.default.unlinkSync(resolvedSource);
|
|
2719
3380
|
output({ completed: true, file: filename, date: today }, raw, 'completed');
|
|
2720
3381
|
}
|
|
2721
3382
|
function cmdScaffold(cwd, type, options, raw) {
|
|
@@ -2781,6 +3442,11 @@ function cmdStats(cwd, format, raw) {
|
|
|
2781
3442
|
const reqPath = planningPaths(cwd).requirements;
|
|
2782
3443
|
const statePath = planningPaths(cwd).state;
|
|
2783
3444
|
const milestone = getMilestoneInfo(cwd).value;
|
|
3445
|
+
const phaseIdConvention = resolvePhaseIdConvention(cwd);
|
|
3446
|
+
const milestoneDisplay = phaseIdConvention === 'bracket'
|
|
3447
|
+
? renderBracketMilestoneDisplay(milestone?.version, loadConfig(cwd).project_code)
|
|
3448
|
+
: null;
|
|
3449
|
+
const milestoneVersion = milestoneDisplay ?? milestone?.version ?? null;
|
|
2784
3450
|
// Phase & plan stats (reuse progress pattern)
|
|
2785
3451
|
const phasesByNumber = new Map();
|
|
2786
3452
|
let totalPlans = 0;
|
|
@@ -2800,20 +3466,43 @@ function cmdStats(cwd, format, raw) {
|
|
|
2800
3466
|
// prose mentioning `### Phase N:` inside an inline code span produced a phantom
|
|
2801
3467
|
// Not-Started row and made phases_total disagree with roadmap analyze.
|
|
2802
3468
|
// phase-id-owner: uses the [.-] (dot-or-dash) separator variant, not the canonical dot-only token; a swap to PHASE_NUMBER_TOKEN_SOURCE would drop hyphenated phase-id matches.
|
|
2803
|
-
const
|
|
3469
|
+
const capturesBracketId = phaseIdConvention === 'bracket';
|
|
3470
|
+
const headingPrefix = phaseHeadingPrefixSrcFor(PHASE_HEADING_BASELINE.ANY_BRACKET, phaseIdConvention, capturesBracketId);
|
|
3471
|
+
// phase-id-owner: this preserves cmdStats's shipped dot-or-dash heading
|
|
3472
|
+
// token variant; PHASE_NUMBER_TOKEN_SOURCE is dot-only and would drop M-NN.
|
|
3473
|
+
const headingPattern = new RegExp(
|
|
3474
|
+
// phase-id-owner: cmdStats's shipped [.-] token variant; the canonical token is dot-only.
|
|
3475
|
+
`#{2,4}\\s*${headingPrefix}([A-Za-z]?\\d+[A-Z]?(?:[.-]\\d+)*)(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi');
|
|
2804
3476
|
let match;
|
|
2805
3477
|
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
|
3478
|
+
const bracketId = capturesBracketId ? match[1] : undefined;
|
|
3479
|
+
const phaseToken = capturesBracketId ? match[2] : match[1];
|
|
3480
|
+
const phaseName = capturesBracketId ? match[3] : match[2];
|
|
2806
3481
|
// #3185: the heading seed carried no sentinel filter, so a
|
|
2807
3482
|
// `### Phase 999.1:` backlog heading produced a stats row even with no
|
|
2808
3483
|
// directory on disk. Uses the canonical predicate (phase-id.cts), not a
|
|
2809
3484
|
// local literal — the rule had five copies and three regex variants
|
|
2810
3485
|
// before this phase, disagreeing about Phase 0.
|
|
2811
|
-
|
|
3486
|
+
const sentinelId = bracketId ? `${bracketId}-${phaseToken}` : phaseToken;
|
|
3487
|
+
if (capturesBracketId
|
|
3488
|
+
? isSentinelPhaseId(sentinelId, phaseIdConvention)
|
|
3489
|
+
: isSentinelPhaseId(sentinelId))
|
|
2812
3490
|
continue;
|
|
2813
|
-
const key = normalizePhaseName(
|
|
3491
|
+
const key = normalizePhaseName(phaseToken);
|
|
3492
|
+
let displayId;
|
|
3493
|
+
if (bracketId) {
|
|
3494
|
+
try {
|
|
3495
|
+
displayId = renderPhaseId(parsePhaseId(`${bracketId}-${phaseToken}`));
|
|
3496
|
+
}
|
|
3497
|
+
catch {
|
|
3498
|
+
// Read tolerance can admit a non-canonical heading spelling; keep
|
|
3499
|
+
// the stats row but do not invent a canonical identity for it.
|
|
3500
|
+
}
|
|
3501
|
+
}
|
|
2814
3502
|
phasesByNumber.set(key, {
|
|
2815
3503
|
number: key,
|
|
2816
|
-
|
|
3504
|
+
...(displayId ? { display_id: displayId } : {}),
|
|
3505
|
+
name: phaseName.replace(/\(INSERTED\)/i, '').trim(),
|
|
2817
3506
|
plans: 0,
|
|
2818
3507
|
summaries: 0,
|
|
2819
3508
|
status: 'Not Started',
|
|
@@ -2827,15 +3516,36 @@ function cmdStats(cwd, format, raw) {
|
|
|
2827
3516
|
// sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
|
|
2828
3517
|
// predicate when its heading set is empty, at which point every directory
|
|
2829
3518
|
// on disk passed, backlog included (#3167).
|
|
2830
|
-
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
3519
|
+
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, {
|
|
3520
|
+
cwd,
|
|
3521
|
+
phaseIdConvention,
|
|
3522
|
+
});
|
|
2831
3523
|
phaseScope = scope;
|
|
2832
3524
|
for (const dir of dirs) {
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
2838
|
-
|
|
3525
|
+
let phaseNum;
|
|
3526
|
+
let phaseName;
|
|
3527
|
+
let displayId;
|
|
3528
|
+
if (phaseIdConvention === 'bracket') {
|
|
3529
|
+
try {
|
|
3530
|
+
const projection = bracketPhaseDirProjection(dir);
|
|
3531
|
+
phaseNum = projection.number;
|
|
3532
|
+
phaseName = projection.name;
|
|
3533
|
+
displayId = projection.display_id;
|
|
3534
|
+
}
|
|
3535
|
+
catch {
|
|
3536
|
+
const phaseToken = extractPhaseToken(dir, phaseIdConvention);
|
|
3537
|
+
phaseNum = phaseToken || dir;
|
|
3538
|
+
phaseName = recoverBracketPhaseName(dir, phaseToken);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
else {
|
|
3542
|
+
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
|
|
3543
|
+
const phaseToken = extractPhaseToken(dir);
|
|
3544
|
+
phaseNum = phaseToken || dir;
|
|
3545
|
+
// phaseName is everything after the token (strip leading '-')
|
|
3546
|
+
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
|
3547
|
+
phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
|
|
3548
|
+
}
|
|
2839
3549
|
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
|
2840
3550
|
// canonical pairing) from the single owner.
|
|
2841
3551
|
const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
@@ -2848,6 +3558,9 @@ function cmdStats(cwd, format, raw) {
|
|
|
2848
3558
|
const existing = phasesByNumber.get(normalizedNum);
|
|
2849
3559
|
phasesByNumber.set(normalizedNum, {
|
|
2850
3560
|
number: normalizedNum,
|
|
3561
|
+
...(existing?.display_id || displayId
|
|
3562
|
+
? { display_id: existing?.display_id || displayId }
|
|
3563
|
+
: {}),
|
|
2851
3564
|
name: existing?.name || phaseName,
|
|
2852
3565
|
plans: (existing?.plans || 0) + plans,
|
|
2853
3566
|
summaries: (existing?.summaries || 0) + summaries,
|
|
@@ -2906,7 +3619,7 @@ function cmdStats(cwd, format, raw) {
|
|
|
2906
3619
|
}
|
|
2907
3620
|
}
|
|
2908
3621
|
const result = {
|
|
2909
|
-
milestone_version:
|
|
3622
|
+
milestone_version: milestoneVersion,
|
|
2910
3623
|
milestone_name: milestone?.name ?? null,
|
|
2911
3624
|
phases,
|
|
2912
3625
|
phases_completed: completedPhases,
|
|
@@ -2925,10 +3638,8 @@ function cmdStats(cwd, format, raw) {
|
|
|
2925
3638
|
phase_scope: phaseScope,
|
|
2926
3639
|
};
|
|
2927
3640
|
if (format === 'table') {
|
|
2928
|
-
const
|
|
2929
|
-
|
|
2930
|
-
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
2931
|
-
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
|
3641
|
+
const bar = (0, phase_lifecycle_cjs_1.renderProgressBar)(percent, 10);
|
|
3642
|
+
let out = `# ${milestoneVersion ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
|
2932
3643
|
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2933
3644
|
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
|
|
2934
3645
|
if (totalPlans > 0 && planPercent !== null) {
|
|
@@ -2942,7 +3653,7 @@ function cmdStats(cwd, format, raw) {
|
|
|
2942
3653
|
out += `| Phase | Name | Plans | Completed | Status |\n`;
|
|
2943
3654
|
out += `|-------|------|-------|-----------|--------|\n`;
|
|
2944
3655
|
for (const p of phases) {
|
|
2945
|
-
out += `| ${p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
|
|
3656
|
+
out += `| ${p.display_id ?? p.number} | ${p.name} | ${p.plans} | ${p.summaries} | ${p.status} |\n`;
|
|
2946
3657
|
}
|
|
2947
3658
|
if (gitCommits > 0) {
|
|
2948
3659
|
out += `\n**Git:** ${gitCommits} commits`;
|