@opengsd/gsd-core 1.10.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +471 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -107
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -11,11 +11,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
11
11
|
};
|
|
12
12
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
13
13
|
const node_path_1 = __importDefault(require("node:path"));
|
|
14
|
+
const text_lines_cjs_1 = require("./text-lines.cjs");
|
|
14
15
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
16
|
+
const pattern_cjs_1 = require("./pattern.cjs");
|
|
15
17
|
const security_cjs_1 = require("./security.cjs");
|
|
16
18
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
17
19
|
const ioMod = require("./io.cjs");
|
|
18
|
-
const { output, error } = ioMod;
|
|
20
|
+
const { output, error, ERROR_REASON } = ioMod;
|
|
19
21
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
22
|
const configLoaderMod = require("./config-loader.cjs");
|
|
21
23
|
const { loadConfig, isGitIgnored } = configLoaderMod;
|
|
@@ -24,20 +26,28 @@ const coreUtilsMod = require("./core-utils.cjs");
|
|
|
24
26
|
const { toPosixPath, generateSlugInternal, extractOneLinerFromBody } = coreUtilsMod;
|
|
25
27
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
26
28
|
const phaseIdMod = require("./phase-id.cjs");
|
|
27
|
-
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
|
|
29
|
+
const { normalizePhaseName, comparePhaseNum, extractPhaseToken, PHASE_NUMBER_TOKEN_SOURCE, isSentinelPhaseId } = phaseIdMod;
|
|
28
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
29
31
|
const phaseLocatorMod = require("./phase-locator.cjs");
|
|
30
|
-
const { getArchivedPhaseDirs, findPhaseInternal } = phaseLocatorMod;
|
|
32
|
+
const { getArchivedPhaseDirs, findPhaseInternal, listMilestonePhaseDirs } = phaseLocatorMod;
|
|
31
33
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
32
34
|
const roadmapParserMod = require("./roadmap-parser.cjs");
|
|
33
|
-
const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo,
|
|
35
|
+
const { extractCurrentMilestone, stripShippedMilestones: _stripShippedMilestones, getMilestoneInfo, getRoadmapPhaseInternal } = roadmapParserMod;
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
38
|
+
const { SCOPE } = planningScopeMod;
|
|
34
39
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
35
40
|
const modelResolverMod = require("./model-resolver.cjs");
|
|
36
|
-
const { resolveModelInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
|
|
41
|
+
const { resolveModelInternal, resolveTierInternal, resolveModelForTier, resolveProviderEscalation, resolveEffortInternal, resolveFastModeInternal, resolveEffortForTier, resolveGranularityInternal, assertValidGranularityOverride } = modelResolverMod;
|
|
37
42
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
38
43
|
const agentCommandRouterMod = require("./agent-command-router.cjs");
|
|
39
44
|
const { AGENT_FAILURE_CLASSES } = agentCommandRouterMod;
|
|
40
45
|
const model_catalog_cjs_1 = require("./model-catalog.cjs");
|
|
46
|
+
// #3243 (ADR-2313 D7) — the Codex `.toml` sync's typed IR: parse/render/strip
|
|
47
|
+
// primitives moved from agent-install-check.cts's Phase-2 parsing into this
|
|
48
|
+
// leaf so both consumers share one block-range detector. See
|
|
49
|
+
// codex-agent-toml.cts's module header for the reader/writer reconciliation.
|
|
50
|
+
const codex_agent_toml_cjs_1 = require("./codex-agent-toml.cjs");
|
|
41
51
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
52
|
const hostIntegrationMod = require("./host-integration.cjs");
|
|
43
53
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
@@ -45,12 +55,19 @@ const planningWorkspace = require("./planning-workspace.cjs");
|
|
|
45
55
|
const { planningDir, planningPaths } = planningWorkspace;
|
|
46
56
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
57
|
const frontmatter = require("./frontmatter.cjs");
|
|
48
|
-
const { extractFrontmatter } = frontmatter;
|
|
58
|
+
const { extractFrontmatter, agentScalarNeedsDoubleQuoting, escapeDoubleQuotedScalar } = frontmatter;
|
|
49
59
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
50
60
|
const modelProfiles = require("./model-profiles.cjs");
|
|
51
61
|
const { MODEL_PROFILES, VALID_PHASE_TYPES } = modelProfiles;
|
|
52
62
|
const runtime_slash_cjs_1 = require("./runtime-slash.cjs");
|
|
53
63
|
const clock_cjs_1 = require("./clock.cjs");
|
|
64
|
+
const phase_lifecycle_cjs_1 = require("./phase-lifecycle.cjs");
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
66
|
+
const planScanMod = require("./plan-scan.cjs");
|
|
67
|
+
const { scanPhasePlans } = planScanMod;
|
|
68
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports -- verification.cjs is an export= CommonJS module
|
|
69
|
+
const verificationMod = require("./verification.cjs");
|
|
70
|
+
const { resolveVerificationFile } = verificationMod;
|
|
54
71
|
// ─── Phase Status ─────────────────────────────────────────────────────────────
|
|
55
72
|
/**
|
|
56
73
|
* Phase-status precedence ladder — furthest-along wins (#2408).
|
|
@@ -104,7 +121,16 @@ function determinePhaseStatus(plans, summaries, phaseDir, defaultPending) {
|
|
|
104
121
|
// summaries >= plans — check verification
|
|
105
122
|
try {
|
|
106
123
|
const files = node_fs_1.default.readdirSync(phaseDir);
|
|
107
|
-
|
|
124
|
+
// #3473 F2: routed through the shared resolver (readdir order is
|
|
125
|
+
// filesystem-dependent, so the prior hand-rolled `.find()` could pick
|
|
126
|
+
// either file when a phase held both a canonical report and an ad-hoc
|
|
127
|
+
// `-CORRECTION-VERIFICATION.md` worksheet — see #3357).
|
|
128
|
+
// #3492: pin selection to THIS phase's own token so a stray cross-phase
|
|
129
|
+
// or sentinel-numbered canonically-shaped file cannot outrank this
|
|
130
|
+
// phase's own (possibly non-canonical) report.
|
|
131
|
+
const phaseDirName = node_path_1.default.basename(phaseDir);
|
|
132
|
+
const phaseToken = extractPhaseToken(phaseDirName);
|
|
133
|
+
const verificationFile = resolveVerificationFile(files, { allowBare: true, phaseToken, phaseDirName });
|
|
108
134
|
if (verificationFile) {
|
|
109
135
|
const verificationFilePath = node_path_1.default.join(phaseDir, verificationFile);
|
|
110
136
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(verificationFilePath) || '';
|
|
@@ -134,16 +160,16 @@ function cmdGenerateSlug(text, raw) {
|
|
|
134
160
|
if (!text) {
|
|
135
161
|
error('text required for slug generation');
|
|
136
162
|
}
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
163
|
+
// #3883 (ADR-3473 §8.3): delegate to the canonical slug formula
|
|
164
|
+
// (generateSlugInternal, core-utils.cts) instead of re-implementing it —
|
|
165
|
+
// this call site previously diverged from it (Cyrillic collapsed to "",
|
|
166
|
+
// and truncation could leave a trailing hyphen; #2848/#2849).
|
|
167
|
+
const slug = coreUtilsMod.generateSlugInternal(text) ?? '';
|
|
142
168
|
const result = { slug };
|
|
143
169
|
output(result, raw, slug);
|
|
144
170
|
}
|
|
145
171
|
function cmdCurrentTimestamp(format, raw) {
|
|
146
|
-
const now = new Date();
|
|
172
|
+
const now = new Date(clock_cjs_1.realClock.now());
|
|
147
173
|
let result;
|
|
148
174
|
switch (format) {
|
|
149
175
|
case 'date':
|
|
@@ -348,7 +374,14 @@ function cmdHistoryDigest(cwd, raw) {
|
|
|
348
374
|
}
|
|
349
375
|
try {
|
|
350
376
|
for (const { name: dir, fullPath: dirPath } of allPhaseDirs) {
|
|
351
|
-
|
|
377
|
+
// #3183: canonical summary set (root+nested) from the single owner.
|
|
378
|
+
// This call also opens every plan file's frontmatter to check
|
|
379
|
+
// superseded status even though cmdHistoryDigest never uses planFiles
|
|
380
|
+
// or the superseded distinction — that per-phase-dir cost is accepted
|
|
381
|
+
// deliberately (correctness/single-ownership over micro-optimization;
|
|
382
|
+
// summaryFiles itself is not superseded-filtered either way). Do not
|
|
383
|
+
// "optimize" this back into a second hand-rolled summary derivation.
|
|
384
|
+
const summaries = scanPhasePlans(dirPath).summaryFiles;
|
|
352
385
|
for (const summary of summaries) {
|
|
353
386
|
const summaryFilePath = node_path_1.default.join(dirPath, summary);
|
|
354
387
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(summaryFilePath);
|
|
@@ -419,10 +452,20 @@ function cmdResolveModel(cwd, agentType, raw) {
|
|
|
419
452
|
const profile = config['model_profile'] || 'balanced';
|
|
420
453
|
const model = resolveModelInternal(cwd, agentType);
|
|
421
454
|
const effort = resolveEffortInternal(cwd, agentType);
|
|
422
|
-
|
|
455
|
+
// Own-property guard: agentType is an unvalidated CLI positional, so a
|
|
456
|
+
// prototype-chain value ("toString", "constructor") would otherwise return
|
|
457
|
+
// an inherited truthy member from this plain object and misreport a
|
|
458
|
+
// genuinely unknown agent as known (unknown_agent dropped from the result).
|
|
459
|
+
const agentModelsMap = MODEL_PROFILES;
|
|
460
|
+
const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
|
|
461
|
+
// #2229: `tier` is additive — existing keys and their values are untouched, so
|
|
462
|
+
// every `--pick model` / `--pick profile` / `--raw` consumer is unaffected. It
|
|
463
|
+
// exists because the model id is deliberately blank under resolve_model_ids:"omit",
|
|
464
|
+
// which leaves a tier-sensitive guard with nothing to read.
|
|
465
|
+
const tier = resolveTierInternal(cwd, agentType);
|
|
423
466
|
const result = agentModels
|
|
424
|
-
? { model, profile, effort }
|
|
425
|
-
: { model, profile, effort, unknown_agent: true };
|
|
467
|
+
? { model, profile, effort, tier }
|
|
468
|
+
: { model, profile, effort, tier, unknown_agent: true };
|
|
426
469
|
output(result, raw, model);
|
|
427
470
|
}
|
|
428
471
|
function cmdResolveGranularity(cwd, phaseType, raw, override) {
|
|
@@ -487,9 +530,59 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
487
530
|
: resolveEffortInternal(cwd, agentType, effortOpts);
|
|
488
531
|
const fastMode = resolveFastModeInternal(cwd, agentType, fastModeOpts);
|
|
489
532
|
const runtime = config['runtime'] || 'claude';
|
|
490
|
-
|
|
533
|
+
// #3007: pass the resolved model so the per-model advertised-effort ceiling
|
|
534
|
+
// (CODEX_MODEL_EFFORT) is reachable from this production seam. `model` may
|
|
535
|
+
// be a tier alias or a non-Codex id for other runtimes — that's fine and
|
|
536
|
+
// must not be special-cased here: advertisedCodexEffort() falls back to the
|
|
537
|
+
// family baseline for any id it doesn't recognize.
|
|
538
|
+
const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, effort, model);
|
|
491
539
|
const fastModeSupported = model_catalog_cjs_1.RUNTIMES_WITH_FAST_MODE.has(runtime);
|
|
492
|
-
|
|
540
|
+
// #3534 (10a): the effective effort — what the installed agent will actually
|
|
541
|
+
// run at. `effort` above is the config cascade; for the claude runtime the
|
|
542
|
+
// per-agent frontmatter key is the source of truth (Claude Code's Agent tool
|
|
543
|
+
// has no per-spawn effort parameter), so the query reads the installed file.
|
|
544
|
+
// An ABSENT key is a real state — the agent follows the session effort
|
|
545
|
+
// ('inherit'), not drift. No file / no frontmatter / any read failure means
|
|
546
|
+
// no evidence: the resolved value is reported, flagged 'resolved' so a
|
|
547
|
+
// consumer can tell evidence from echo. Additive only — every existing key
|
|
548
|
+
// is unchanged.
|
|
549
|
+
let effortEffectiveSource = 'resolved';
|
|
550
|
+
let effortEffective = effort;
|
|
551
|
+
if (runtime === 'claude') {
|
|
552
|
+
try {
|
|
553
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
554
|
+
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
555
|
+
const agentsDirEff = node_path_1.default.join(getGlobalConfigDir(runtime), 'agents');
|
|
556
|
+
const agentPath = node_path_1.default.join(agentsDirEff, `${agentType}.md`);
|
|
557
|
+
// agentType is an unvalidated CLI positional: keep the read inside the
|
|
558
|
+
// agents dir so `../../x` cannot point it elsewhere (defense in depth —
|
|
559
|
+
// the reflected surface is only a frontmatter effort line).
|
|
560
|
+
if (!node_path_1.default.resolve(agentPath).startsWith(node_path_1.default.resolve(agentsDirEff) + node_path_1.default.sep)) {
|
|
561
|
+
throw new Error('agent path escapes the agents directory');
|
|
562
|
+
}
|
|
563
|
+
const agentContent = node_fs_1.default.readFileSync(agentPath, 'utf8');
|
|
564
|
+
// 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
|
+
const fmMatchEff = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(agentContent);
|
|
566
|
+
if (fmMatchEff) {
|
|
567
|
+
const effortLine = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchEff[1]);
|
|
568
|
+
if (effortLine) {
|
|
569
|
+
effortEffective = effortLine[1];
|
|
570
|
+
effortEffectiveSource = 'frontmatter';
|
|
571
|
+
}
|
|
572
|
+
else {
|
|
573
|
+
effortEffective = 'inherit';
|
|
574
|
+
effortEffectiveSource = 'frontmatter-absent';
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
catch { /* no frontmatter evidence — stay on the resolved value */ }
|
|
579
|
+
}
|
|
580
|
+
// Own-property guard: agentType is an unvalidated CLI positional, so a
|
|
581
|
+
// prototype-chain value ("toString", "constructor") would otherwise return
|
|
582
|
+
// an inherited truthy member from this plain object and misreport a
|
|
583
|
+
// genuinely unknown agent as known (unknown_agent dropped from the result).
|
|
584
|
+
const agentModelsMap = MODEL_PROFILES;
|
|
585
|
+
const agentModels = Object.hasOwn(agentModelsMap, agentType) ? agentModelsMap[agentType] : undefined;
|
|
493
586
|
const result = {
|
|
494
587
|
model,
|
|
495
588
|
profile,
|
|
@@ -497,6 +590,11 @@ function cmdResolveExecution(cwd, agentType, raw, opts) {
|
|
|
497
590
|
effort_rendered: rendered.value,
|
|
498
591
|
effort_param: rendered.param,
|
|
499
592
|
effort_propagation: rendered.channel,
|
|
593
|
+
effort_requested: rendered.requested,
|
|
594
|
+
effort_clamped: rendered.clamped,
|
|
595
|
+
effort_clamp_reason: rendered.reason,
|
|
596
|
+
effort_effective: effortEffective,
|
|
597
|
+
effort_effective_source: effortEffectiveSource,
|
|
500
598
|
fast_mode: fastMode,
|
|
501
599
|
fast_mode_supported: fastModeSupported,
|
|
502
600
|
};
|
|
@@ -549,22 +647,108 @@ function effortSurfaceForHost(cwd, host) {
|
|
|
549
647
|
}
|
|
550
648
|
}
|
|
551
649
|
/**
|
|
552
|
-
* #488 — Replace or inject the
|
|
650
|
+
* #488 — Replace or inject the `<key>:` value in YAML frontmatter.
|
|
553
651
|
* Unlike injectEffortFrontmatter (install.js), this overwrites an existing value.
|
|
652
|
+
* #3706: key-parameterised so the same line-editor serves both claude's
|
|
653
|
+
* `effort:` and OpenCode's `variant:`. #3706: all offsets (eol, openLen,
|
|
654
|
+
* closingStart) are derived from the MATCHED BLOCK, not the start of the
|
|
655
|
+
* file, and the existing-key replace is scoped to the frontmatter span only.
|
|
554
656
|
*/
|
|
555
|
-
function
|
|
556
|
-
const eol = /^---\r\n/.test(content) ? '\r\n' : '\n';
|
|
657
|
+
function setFrontmatterKeyLine(content, key, value) {
|
|
557
658
|
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
|
|
558
659
|
const match = fmRe.exec(content);
|
|
559
660
|
if (!match)
|
|
560
661
|
return content;
|
|
561
662
|
const fmBody = match[1];
|
|
562
|
-
|
|
563
|
-
|
|
663
|
+
// Both writers of these frontmatter keys — this sync path and the
|
|
664
|
+
// install-side `frontmatterScalar` in runtime-artifact-conversion.cts —
|
|
665
|
+
// now share one escaping rule: quote via `agentScalarNeedsDoubleQuoting` +
|
|
666
|
+
// `escapeDoubleQuotedScalar` (both from frontmatter.cts) rather than each
|
|
667
|
+
// interpolating `value` raw/differently.
|
|
668
|
+
const renderedValue = agentScalarNeedsDoubleQuoting(value) ? `"${escapeDoubleQuotedScalar(value)}"` : value;
|
|
669
|
+
// EOL comes from the MATCHED BLOCK, not the start of the file. With a
|
|
670
|
+
// preamble the two can disagree, and on a CRLF document that misaligns every
|
|
671
|
+
// offset below by one byte and mangles the opening fence.
|
|
672
|
+
const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
|
|
673
|
+
const openLen = 3 + eol.length;
|
|
674
|
+
const bodyStart = match.index + openLen;
|
|
675
|
+
const closingStart = bodyStart + fmBody.length;
|
|
676
|
+
// #3706: key is now generic (not just the literal 'effort'/'variant'
|
|
677
|
+
// callers happen to pass today) — escape it before interpolating into the
|
|
678
|
+
// RegExp so a future caller can't have its key metacharacters reinterpreted.
|
|
679
|
+
const keyLineRe = new RegExp(`^(${(0, pattern_cjs_1.escapeRegex)(key)}:)[ \\t]*.*$`, 'm');
|
|
680
|
+
if (keyLineRe.test(fmBody)) {
|
|
681
|
+
// #3706: a duplicated `<key>:` line is already invalid YAML, but a
|
|
682
|
+
// non-first-wins reader (last-wins) would otherwise honour a stale
|
|
683
|
+
// second occurrence left behind by a naive single-hit replace, while
|
|
684
|
+
// this function's own single-hit read reports "in sync" — a
|
|
685
|
+
// permanently non-converging state. Use a GLOBAL replace with a
|
|
686
|
+
// first-hit flag so every occurrence collapses to exactly one, IN THE
|
|
687
|
+
// POSITION of the first occurrence (never delete-then-append, which
|
|
688
|
+
// would move the key to the end of the frontmatter and churn every
|
|
689
|
+
// already-generated single-occurrence file).
|
|
690
|
+
const escaped = (0, pattern_cjs_1.escapeRegex)(key);
|
|
691
|
+
let seen = false;
|
|
692
|
+
const newBody = fmBody.replace(new RegExp(`^${escaped}:[ \\t]*.*(\\r?\\n?)`, 'gm'), (_m, nl) => {
|
|
693
|
+
if (!seen) {
|
|
694
|
+
seen = true;
|
|
695
|
+
return `${key}: ${renderedValue}${nl}`;
|
|
696
|
+
}
|
|
697
|
+
return '';
|
|
698
|
+
});
|
|
699
|
+
// Replace INSIDE the frontmatter span only: a whole-file /m replace would
|
|
700
|
+
// rewrite an earlier preamble line that happens to start with this key.
|
|
701
|
+
return content.slice(0, bodyStart) + newBody + content.slice(closingStart);
|
|
564
702
|
}
|
|
703
|
+
return content.slice(0, closingStart) + `${key}: ${renderedValue}${eol}` + content.slice(closingStart);
|
|
704
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* #3533 (10d) — remove exactly the frontmatter `<key>:` line (and its line
|
|
707
|
+
* ending) so an agent configured for `inherit` carries NO key. Mirrors the
|
|
708
|
+
* codex-agent-toml strip discipline: targeted line removal, EOL-aware, every
|
|
709
|
+
* other byte (comments, sibling keys, the body) untouched.
|
|
710
|
+
* #3706: key-parameterised so the same line-editor serves both claude's
|
|
711
|
+
* `effort:` and OpenCode's `variant:`. #3706: openLen is derived from the
|
|
712
|
+
* MATCHED BLOCK, not the start of the file — a preamble on a CRLF document
|
|
713
|
+
* would otherwise misalign every offset below.
|
|
714
|
+
*/
|
|
715
|
+
function removeFrontmatterKeyLine(content, key) {
|
|
716
|
+
// Scoped to the FIRST frontmatter block (not a whole-file /m match): a
|
|
717
|
+
// preamble or body line starting with `<key>:` (a fenced config example,
|
|
718
|
+
// a thematic-break flanked fragment) must never be the line removed.
|
|
719
|
+
const fmRe = /^---\r?\n([\s\S]*?)^---\r?$/m;
|
|
720
|
+
const match = fmRe.exec(content);
|
|
721
|
+
if (!match)
|
|
722
|
+
return content;
|
|
723
|
+
const fmBody = match[1];
|
|
724
|
+
// #3706: same generic-key escape as setFrontmatterKeyLine above.
|
|
725
|
+
const lineRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'm');
|
|
726
|
+
if (!lineRe.test(fmBody))
|
|
727
|
+
return content;
|
|
728
|
+
// A duplicate `<key>:` mapping key is already invalid YAML (a document with
|
|
729
|
+
// two `effort:`/`variant:` lines does not parse), so this is robustness
|
|
730
|
+
// against a malformed document, not a live corruption path. Still, "a null
|
|
731
|
+
// target means the key must not exist" is an invariant this function must
|
|
732
|
+
// leave true on disk — a non-global replace here would strip only the
|
|
733
|
+
// FIRST occurrence and require a second run to converge. Use a fresh
|
|
734
|
+
// global RegExp for the strip so every occurrence in the frontmatter body
|
|
735
|
+
// is removed in one pass.
|
|
736
|
+
const stripAllRe = new RegExp(`^${(0, pattern_cjs_1.escapeRegex)(key)}:[ \\t]*.*\\r?\\n?`, 'gm');
|
|
737
|
+
const strippedFm = fmBody.replace(stripAllRe, '');
|
|
738
|
+
// Same rule as setFrontmatterKeyLine: the EOL must come from the matched
|
|
739
|
+
// block, not the start of the file, or a preambled CRLF document misaligns.
|
|
740
|
+
const eol = /^---\r\n/.test(match[0]) ? '\r\n' : '\n';
|
|
565
741
|
const openLen = 3 + eol.length;
|
|
566
742
|
const closingStart = match.index + openLen + fmBody.length;
|
|
567
|
-
return content.slice(0,
|
|
743
|
+
return content.slice(0, match.index + openLen) + strippedFm + content.slice(closingStart);
|
|
744
|
+
}
|
|
745
|
+
/** #488 — Replace or inject the `effort:` value in YAML frontmatter. */
|
|
746
|
+
function setEffortFrontmatter(content, effortValue) {
|
|
747
|
+
return setFrontmatterKeyLine(content, 'effort', effortValue);
|
|
748
|
+
}
|
|
749
|
+
/** #3533 (10d) — remove exactly the frontmatter `effort:` line (and its line ending). */
|
|
750
|
+
function removeEffortFrontmatter(content) {
|
|
751
|
+
return removeFrontmatterKeyLine(content, 'effort');
|
|
568
752
|
}
|
|
569
753
|
/**
|
|
570
754
|
* #488 — Re-sync effort: frontmatter in all installed gsd-*.md agent files to
|
|
@@ -581,6 +765,22 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
581
765
|
const dryRun = opts.dryRun !== false;
|
|
582
766
|
const config = loadConfig(cwd);
|
|
583
767
|
const runtime = opts.runtime || config['runtime'] || 'claude';
|
|
768
|
+
// ADR-2313 D7 (#3243) — Codex gets its own `.toml` sync path (strip a stale
|
|
769
|
+
// Anthropic/tier `model` and an orphaned `model_reasoning_effort`, leaving a
|
|
770
|
+
// legal pin untouched). Every other non-claude runtime keeps the prior
|
|
771
|
+
// early-return; the claude branch below is untouched byte-for-byte.
|
|
772
|
+
if (runtime === 'codex') {
|
|
773
|
+
cmdEffortSyncCodex(raw, dryRun, opts.configDir);
|
|
774
|
+
return;
|
|
775
|
+
}
|
|
776
|
+
// #3706: install now bakes OpenCode's resolved effort into agent
|
|
777
|
+
// frontmatter under the `variant:` key (not `effort:`), so OpenCode gets
|
|
778
|
+
// its own sync path — mirroring the codex branch above — rather than
|
|
779
|
+
// falling into the generic "does not use effort: frontmatter" skip.
|
|
780
|
+
if (runtime === 'opencode') {
|
|
781
|
+
cmdEffortSyncOpencode(cwd, raw, dryRun, opts.configDir);
|
|
782
|
+
return;
|
|
783
|
+
}
|
|
584
784
|
if (runtime !== 'claude') {
|
|
585
785
|
output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, reason: `runtime '${runtime}' does not use effort: frontmatter` }, raw, '');
|
|
586
786
|
return;
|
|
@@ -609,16 +809,126 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
609
809
|
catch {
|
|
610
810
|
return false;
|
|
611
811
|
}
|
|
612
|
-
});
|
|
812
|
+
}).sort(); // #3706: sorted like the codex and
|
|
813
|
+
// opencode branches — readdir order is platform-dependent, so leaving it unsorted makes the
|
|
814
|
+
// reported `changes` ordering differ across machines for identical inputs.
|
|
613
815
|
const changes = [];
|
|
614
816
|
let synced = 0;
|
|
615
817
|
let skipped = 0;
|
|
818
|
+
// Local-only counter: reads AND writes are both guarded in this loop (an
|
|
819
|
+
// unreadable or unwritable agent file must not abort the whole sweep), but
|
|
820
|
+
// this result shape (`{synced, skipped, changes, dry_run, agents_dir}`) is
|
|
821
|
+
// long-standing and widely consumed, so it deliberately gains NO new key
|
|
822
|
+
// (no `read_failures`/`write_failures`, unlike the codex/opencode branches
|
|
823
|
+
// below). Instead every per-file failure — read or write — is folded into
|
|
824
|
+
// `skipped` and rides the raw-mode summary token below — `output()`'s
|
|
825
|
+
// third argument is never merged into the emitted JSON object (see io.cts
|
|
826
|
+
// `output()`: it is only read when `raw === true`, entirely replacing the
|
|
827
|
+
// JSON payload), so flipping it to `'failed'` costs nothing in the wire
|
|
828
|
+
// shape while still surfacing the failure to a raw-mode caller. The three
|
|
829
|
+
// branches differ on that reporting shape, but are now also consistent in
|
|
830
|
+
// HOW they publish: every write below goes through the same tmp-file +
|
|
831
|
+
// chmod + retryRenameSync atomic-publish sequence used by
|
|
832
|
+
// cmdEffortSyncCodex and cmdEffortSyncOpencode, so a fault mid-write can
|
|
833
|
+
// never leave an agent file truncated or empty.
|
|
834
|
+
let fileFailureCount = 0;
|
|
616
835
|
for (const file of files) {
|
|
617
836
|
const agentName = file.replace(/\.md$/, '');
|
|
618
837
|
const filePath = node_path_1.default.join(agentsDir, file);
|
|
619
|
-
|
|
838
|
+
let content;
|
|
839
|
+
try {
|
|
840
|
+
content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
841
|
+
}
|
|
842
|
+
catch {
|
|
843
|
+
// An unreadable agent file must not abort the whole sweep. Deliberately
|
|
844
|
+
// NOT adding a new field here: this result shape (`{synced, skipped,
|
|
845
|
+
// changes, dry_run, agents_dir}`) is long-standing and widely consumed,
|
|
846
|
+
// so the failure is folded into `skipped` only, with no
|
|
847
|
+
// read_failures/write_failures list — see `fileFailureCount` above.
|
|
848
|
+
skipped++;
|
|
849
|
+
fileFailureCount++;
|
|
850
|
+
continue;
|
|
851
|
+
}
|
|
620
852
|
// Resolve using install-time logic: home defaults merged with project config.
|
|
621
853
|
const universalEffort = resolveInstallTimeEffort(effortCfg, agentName);
|
|
854
|
+
// #3533 (10d): 'inherit' means the key must NOT exist. An absent key is
|
|
855
|
+
// the CORRECT state (in sync, skipped) — before #3533 absence read as null
|
|
856
|
+
// drift and the sync re-added a hand-stripped key on every apply. A
|
|
857
|
+
// present key under inherit is stripped, reported as {from, to: null}.
|
|
858
|
+
if (universalEffort === 'inherit') {
|
|
859
|
+
const fmMatchInherit = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
|
|
860
|
+
if (!fmMatchInherit) {
|
|
861
|
+
skipped++;
|
|
862
|
+
continue;
|
|
863
|
+
}
|
|
864
|
+
// Presence and value are distinct questions: `effort:` with an EMPTY
|
|
865
|
+
// value is a key that IS present but whose captured value is null (the
|
|
866
|
+
// `(.+?)` group requires at least one char). Deciding "already correct"
|
|
867
|
+
// from a null value alone is wrong here — it would leave an
|
|
868
|
+
// unresolvable `effort: null` key on disk forever. Test presence with
|
|
869
|
+
// its own regex, and only compare values once presence is known.
|
|
870
|
+
const effortPresentInherit = /^effort:/m.test(fmMatchInherit[1]);
|
|
871
|
+
if (!effortPresentInherit) {
|
|
872
|
+
skipped++;
|
|
873
|
+
continue;
|
|
874
|
+
}
|
|
875
|
+
const effortMatchInherit = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatchInherit[1]);
|
|
876
|
+
// `effortPresentInherit` is guaranteed true here (checked above), so a
|
|
877
|
+
// failed value match means the key is present with an EMPTY value —
|
|
878
|
+
// report `''`, not `null`, so "present-but-empty" is never conflated
|
|
879
|
+
// with "absent" in the sync output.
|
|
880
|
+
if (!dryRun) {
|
|
881
|
+
// Atomic publish AND mode preservation, same discipline as
|
|
882
|
+
// cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
|
|
883
|
+
// file, chmod it to match filePath's existing (masked) mode, then
|
|
884
|
+
// retryRenameSync it over the target so filePath is either the old
|
|
885
|
+
// bytes or the new ones, never half-written and never dropped to a
|
|
886
|
+
// default mode. On any failure the tmp file is unlinked (best-effort)
|
|
887
|
+
// and the write is reported (folded into `skipped`/`fileFailureCount`,
|
|
888
|
+
// no new field), not thrown, so the remaining agents still get
|
|
889
|
+
// processed. ONE failure path for this site — no nested try/catch.
|
|
890
|
+
const tmpPathInherit = `${filePath}.tmp.${process.pid}`;
|
|
891
|
+
// Stat filePath BEFORE the write so its mode can be passed at
|
|
892
|
+
// CREATION time — a plain `writeFileSync(tmpPath, data)` creates the
|
|
893
|
+
// tmp file at the default `0666 & ~umask` even when filePath is more
|
|
894
|
+
// restrictive. Best-effort only: a stat failure must not abort the
|
|
895
|
+
// sync, since the content write is what matters, not the mode.
|
|
896
|
+
let originalModeInherit;
|
|
897
|
+
try {
|
|
898
|
+
originalModeInherit = node_fs_1.default.statSync(filePath).mode & 0o7777;
|
|
899
|
+
}
|
|
900
|
+
catch { /* non-fatal: fall back to writing without an explicit mode */ }
|
|
901
|
+
try {
|
|
902
|
+
node_fs_1.default.writeFileSync(tmpPathInherit, removeEffortFrontmatter(content), originalModeInherit !== undefined ? { mode: originalModeInherit } : undefined);
|
|
903
|
+
// Not redundant with the `mode` option above: `mode` only applies
|
|
904
|
+
// when the file is actually created (O_CREAT). A leftover tmp file
|
|
905
|
+
// from an earlier crashed run would be reused (truncated) at its
|
|
906
|
+
// OLD mode instead, and this chmod is what corrects that case.
|
|
907
|
+
// Best-effort only: a chmod failure must not abort the sync, since
|
|
908
|
+
// the content write is what matters, not the mode.
|
|
909
|
+
try {
|
|
910
|
+
if (originalModeInherit !== undefined)
|
|
911
|
+
node_fs_1.default.chmodSync(tmpPathInherit, originalModeInherit);
|
|
912
|
+
}
|
|
913
|
+
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
914
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathInherit, filePath);
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
try {
|
|
918
|
+
node_fs_1.default.unlinkSync(tmpPathInherit);
|
|
919
|
+
}
|
|
920
|
+
catch { /* already gone or never created */ }
|
|
921
|
+
skipped++;
|
|
922
|
+
fileFailureCount++;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
changes.push({ agent: agentName, from: effortMatchInherit ? effortMatchInherit[1] : '', to: null });
|
|
927
|
+
synced++;
|
|
928
|
+
continue;
|
|
929
|
+
}
|
|
930
|
+
// `runtime` is guaranteed 'claude' by the guard above (#3007: only
|
|
931
|
+
// codex's 'ultra' rejection can produce a null value).
|
|
622
932
|
const rendered = (0, model_catalog_cjs_1.renderEffortForRuntime)(runtime, universalEffort);
|
|
623
933
|
const newEffortValue = rendered.value;
|
|
624
934
|
const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
|
|
@@ -626,19 +936,388 @@ function cmdEffortSync(cwd, raw, opts) {
|
|
|
626
936
|
skipped++;
|
|
627
937
|
continue;
|
|
628
938
|
}
|
|
939
|
+
// Presence and value are distinct questions here too: `currentEffort`
|
|
940
|
+
// reads null both when the key is ABSENT and when it is present with an
|
|
941
|
+
// EMPTY value. `effortPresent` disambiguates those two for the reported
|
|
942
|
+
// `from` below (never `null` when the key is present but empty) — but it
|
|
943
|
+
// has no bearing on the skip check that follows: `newEffortValue` is
|
|
944
|
+
// never null on this path (guarded above), so an absent key already
|
|
945
|
+
// yields `currentEffort === null !== newEffortValue` without consulting
|
|
946
|
+
// presence separately.
|
|
947
|
+
const effortPresent = /^effort:/m.test(fmMatch[1]);
|
|
629
948
|
const effortMatch = /^effort:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
|
|
630
|
-
|
|
949
|
+
// `null` (key absent) and `''` (key present, value empty) are distinct
|
|
950
|
+
// states `effortPresent` deliberately disambiguates — collapsing both to
|
|
951
|
+
// `null` here would make the reported `from` lie about which case fired.
|
|
952
|
+
const currentEffort = effortPresent ? (effortMatch ? effortMatch[1] : '') : null;
|
|
631
953
|
if (currentEffort === newEffortValue) {
|
|
632
954
|
skipped++;
|
|
633
955
|
continue;
|
|
634
956
|
}
|
|
957
|
+
if (!dryRun) {
|
|
958
|
+
// Atomic publish AND mode preservation, same discipline as
|
|
959
|
+
// cmdEffortSyncCodex/cmdEffortSyncOpencode: write to a sibling tmp
|
|
960
|
+
// file, chmod it to match filePath's existing (masked) mode, then
|
|
961
|
+
// retryRenameSync it over the target so filePath is either the old
|
|
962
|
+
// bytes or the new ones, never half-written and never dropped to a
|
|
963
|
+
// default mode. On any failure the tmp file is unlinked (best-effort)
|
|
964
|
+
// and the write is reported (folded into `skipped`/`fileFailureCount`,
|
|
965
|
+
// no new field), not thrown, so the remaining agents still get
|
|
966
|
+
// processed. ONE failure path for this site — no nested try/catch.
|
|
967
|
+
const tmpPathSet = `${filePath}.tmp.${process.pid}`;
|
|
968
|
+
// Stat filePath BEFORE the write so its mode can be passed at CREATION
|
|
969
|
+
// time — a plain `writeFileSync(tmpPath, data)` creates the tmp file
|
|
970
|
+
// at the default `0666 & ~umask` even when filePath is more
|
|
971
|
+
// restrictive. Best-effort only: a stat failure must not abort the
|
|
972
|
+
// sync, since the content write is what matters, not the mode.
|
|
973
|
+
let originalModeSet;
|
|
974
|
+
try {
|
|
975
|
+
originalModeSet = node_fs_1.default.statSync(filePath).mode & 0o7777;
|
|
976
|
+
}
|
|
977
|
+
catch { /* non-fatal: fall back to writing without an explicit mode */ }
|
|
978
|
+
try {
|
|
979
|
+
node_fs_1.default.writeFileSync(tmpPathSet, setEffortFrontmatter(content, newEffortValue), originalModeSet !== undefined ? { mode: originalModeSet } : undefined);
|
|
980
|
+
// Not redundant with the `mode` option above: `mode` only applies
|
|
981
|
+
// when the file is actually created (O_CREAT). A leftover tmp file
|
|
982
|
+
// from an earlier crashed run would be reused (truncated) at its OLD
|
|
983
|
+
// mode instead, and this chmod is what corrects that case.
|
|
984
|
+
// Best-effort only: a chmod failure must not abort the sync, since
|
|
985
|
+
// the content write is what matters, not the mode.
|
|
986
|
+
try {
|
|
987
|
+
if (originalModeSet !== undefined)
|
|
988
|
+
node_fs_1.default.chmodSync(tmpPathSet, originalModeSet);
|
|
989
|
+
}
|
|
990
|
+
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
991
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPathSet, filePath);
|
|
992
|
+
}
|
|
993
|
+
catch {
|
|
994
|
+
try {
|
|
995
|
+
node_fs_1.default.unlinkSync(tmpPathSet);
|
|
996
|
+
}
|
|
997
|
+
catch { /* already gone or never created */ }
|
|
998
|
+
skipped++;
|
|
999
|
+
fileFailureCount++;
|
|
1000
|
+
continue;
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
635
1003
|
changes.push({ agent: agentName, from: currentEffort, to: newEffortValue });
|
|
636
1004
|
synced++;
|
|
1005
|
+
}
|
|
1006
|
+
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, fileFailureCount > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
1007
|
+
}
|
|
1008
|
+
/**
|
|
1009
|
+
* ADR-2313 D7 (#3243) — the Codex branch of `cmdEffortSync`. Strips a stale
|
|
1010
|
+
* Anthropic-flavored/tier `model` pin and an orphaned `model_reasoning_effort`
|
|
1011
|
+
* from every installed `~/.codex/agents/<agent>.toml`, leaving a legal
|
|
1012
|
+
* real-Codex pin (and its coupled effort) untouched. Dry-run by default; every
|
|
1013
|
+
* strip reported as a structured `{agent, field, from}` change; an unparseable
|
|
1014
|
+
* document is refused and reported, never partially rewritten (40-design.md
|
|
1015
|
+
* "Reconciliation" — parseCodexAgentToml is the STRICT half of the reader/
|
|
1016
|
+
* writer split). Result shape is additive over the claude branch's
|
|
1017
|
+
* `{synced, skipped, changes, dry_run, agents_dir}` — `refused`,
|
|
1018
|
+
* `write_failures`, and `read_failures` are new fields, never a reshape of
|
|
1019
|
+
* the existing ones.
|
|
1020
|
+
*/
|
|
1021
|
+
function cmdEffortSyncCodex(raw, dryRun, configDir) {
|
|
1022
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
1023
|
+
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
1024
|
+
const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('codex'), 'agents');
|
|
1025
|
+
if (!node_fs_1.default.existsSync(agentsDir)) {
|
|
1026
|
+
output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
|
|
1027
|
+
return;
|
|
1028
|
+
}
|
|
1029
|
+
// Skip symlinks — matches the claude branch's existing guard above (only
|
|
1030
|
+
// write regular files, never follow a symlink into clobbering its target).
|
|
1031
|
+
const files = node_fs_1.default
|
|
1032
|
+
.readdirSync(agentsDir)
|
|
1033
|
+
.filter(f => {
|
|
1034
|
+
if (!f.endsWith('.toml'))
|
|
1035
|
+
return false;
|
|
1036
|
+
try {
|
|
1037
|
+
return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
|
|
1038
|
+
}
|
|
1039
|
+
catch {
|
|
1040
|
+
return false;
|
|
1041
|
+
}
|
|
1042
|
+
})
|
|
1043
|
+
.sort();
|
|
1044
|
+
const changes = [];
|
|
1045
|
+
const refused = [];
|
|
1046
|
+
const writeFailures = [];
|
|
1047
|
+
const readFailures = [];
|
|
1048
|
+
let synced = 0;
|
|
1049
|
+
let skipped = 0;
|
|
1050
|
+
for (const file of files) {
|
|
1051
|
+
const agentName = file.replace(/\.toml$/, '');
|
|
1052
|
+
const filePath = node_path_1.default.join(agentsDir, file);
|
|
1053
|
+
let content;
|
|
1054
|
+
try {
|
|
1055
|
+
content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
1056
|
+
}
|
|
1057
|
+
catch (err) {
|
|
1058
|
+
// An unreadable agent file must not abort the whole sweep — mirrors the
|
|
1059
|
+
// opencode branch's own read guard, reported under its own
|
|
1060
|
+
// `read_failures` key so a caller can tell "never read" apart from
|
|
1061
|
+
// "read but write failed".
|
|
1062
|
+
skipped++;
|
|
1063
|
+
readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
const parsed = (0, codex_agent_toml_cjs_1.parseCodexAgentToml)(content);
|
|
1067
|
+
if (!parsed.ok) {
|
|
1068
|
+
// Never partially rewritten (40-design.md, ADR-2313 reader/writer
|
|
1069
|
+
// boundary): an unparseable document is skipped and reported, not
|
|
1070
|
+
// guessed at.
|
|
1071
|
+
skipped++;
|
|
1072
|
+
refused.push({ agent: agentName, file: filePath, reason: parsed.reason });
|
|
1073
|
+
continue;
|
|
1074
|
+
}
|
|
1075
|
+
let doc = parsed.doc;
|
|
1076
|
+
const stripModelNeeded = doc.model !== null && (0, model_catalog_cjs_1.isAnthropicFlavoredModel)(doc.model);
|
|
1077
|
+
// #838 coupling: an orphaned effort (no model) is always stale; a stale
|
|
1078
|
+
// model's effort is coupled to it and strips with it. A legal pin's effort
|
|
1079
|
+
// (model present, not Anthropic-flavored) is left untouched (rows 4-5).
|
|
1080
|
+
const stripEffortNeeded = doc.reasoningEffort !== null && (stripModelNeeded || doc.model === null);
|
|
1081
|
+
if (!stripModelNeeded && !stripEffortNeeded) {
|
|
1082
|
+
// Posture-clean, OR a legal pin (and its coupled effort) — reported
|
|
1083
|
+
// skipped, never synced (ADR-2313 reader/writer boundary).
|
|
1084
|
+
skipped++;
|
|
1085
|
+
continue;
|
|
1086
|
+
}
|
|
1087
|
+
const pendingChanges = [];
|
|
1088
|
+
if (stripModelNeeded) {
|
|
1089
|
+
pendingChanges.push({ agent: agentName, field: 'model', from: doc.model, to: null });
|
|
1090
|
+
doc = (0, codex_agent_toml_cjs_1.stripModel)(doc);
|
|
1091
|
+
}
|
|
1092
|
+
if (stripEffortNeeded) {
|
|
1093
|
+
pendingChanges.push({ agent: agentName, field: 'model_reasoning_effort', from: doc.reasoningEffort, to: null });
|
|
1094
|
+
doc = (0, codex_agent_toml_cjs_1.stripReasoningEffort)(doc);
|
|
1095
|
+
}
|
|
637
1096
|
if (!dryRun) {
|
|
638
|
-
|
|
1097
|
+
// Atomic publish (ADR-2313 "never partially rewritten"): write the
|
|
1098
|
+
// rendered TOML to a sibling tmp file, then rename it over the target.
|
|
1099
|
+
// Same-filesystem rename is atomic, so filePath is either the old bytes
|
|
1100
|
+
// or the new ones, never truncated/half-written mid-crash. Deliberately
|
|
1101
|
+
// NOT platformWriteSync — its normalizeContent step rewrites CRLF/
|
|
1102
|
+
// trailing-newline bytes, which would break the byte-identical
|
|
1103
|
+
// round-trip (A14) this writer must preserve. retryRenameSync (not a
|
|
1104
|
+
// bare fs.renameSync) carries the transient-Windows-lock retry per
|
|
1105
|
+
// DEFECT.WINDOWS-FS-OPS.
|
|
1106
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1107
|
+
// Stat filePath BEFORE the write so the original mode is available to
|
|
1108
|
+
// pass at creation time, not just at chmod time afterward — otherwise
|
|
1109
|
+
// the tmp file is briefly created at the default `0666 & ~umask`
|
|
1110
|
+
// (world-readable under a typical 022 umask) even when filePath is
|
|
1111
|
+
// e.g. 0600, exposing its contents for the window between creation and
|
|
1112
|
+
// chmod. Best-effort: a stat failure must not abort the sync, since the
|
|
1113
|
+
// content write is what matters, not the mode.
|
|
1114
|
+
let originalMode;
|
|
1115
|
+
try {
|
|
1116
|
+
originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
|
|
1117
|
+
}
|
|
1118
|
+
catch { /* non-fatal: fall back to writing without an explicit mode */ }
|
|
1119
|
+
try {
|
|
1120
|
+
node_fs_1.default.writeFileSync(tmpPath, (0, codex_agent_toml_cjs_1.renderCodexAgentToml)(doc), originalMode !== undefined ? { mode: originalMode } : undefined);
|
|
1121
|
+
// Not redundant with the `mode` option above: `mode` only applies
|
|
1122
|
+
// when the file is actually created (O_CREAT). A leftover tmp file
|
|
1123
|
+
// from an earlier crashed run would be reused (truncated) at its OLD
|
|
1124
|
+
// mode instead, and this chmod is what corrects that case. Mask off
|
|
1125
|
+
// the file-type bits fs.statSync().mode carries (POSIX leaves
|
|
1126
|
+
// chmod's handling of those unspecified); best-effort only, since
|
|
1127
|
+
// the content write is what matters, not the mode.
|
|
1128
|
+
try {
|
|
1129
|
+
if (originalMode !== undefined)
|
|
1130
|
+
node_fs_1.default.chmodSync(tmpPath, originalMode);
|
|
1131
|
+
}
|
|
1132
|
+
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
1133
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
|
|
1134
|
+
}
|
|
1135
|
+
catch (err) {
|
|
1136
|
+
// Reported, not thrown — the remaining agents still get processed.
|
|
1137
|
+
// Clean up the orphaned tmp file; filePath itself was never touched.
|
|
1138
|
+
try {
|
|
1139
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
1140
|
+
}
|
|
1141
|
+
catch { /* already gone or never created */ }
|
|
1142
|
+
skipped++;
|
|
1143
|
+
writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1144
|
+
continue;
|
|
1145
|
+
}
|
|
639
1146
|
}
|
|
1147
|
+
changes.push(...pendingChanges);
|
|
1148
|
+
synced++;
|
|
640
1149
|
}
|
|
641
|
-
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir }, raw, synced > 0 ? 'changed' : 'ok');
|
|
1150
|
+
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, refused, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
1151
|
+
}
|
|
1152
|
+
/**
|
|
1153
|
+
* #3706 — the OpenCode branch of `cmdEffortSync`. Maintains the `variant:`
|
|
1154
|
+
* frontmatter key install now bakes into every `~/.config/opencode/agents/
|
|
1155
|
+
* gsd-*.md` (or configDir-relative equivalent), mirroring exactly what
|
|
1156
|
+
* install writes: a resolved universal effort clamped through
|
|
1157
|
+
* `clampEffortForHost('opencode', ...)`. Null means the key must be ABSENT —
|
|
1158
|
+
* #3533 (10d): an absent key is the correct state under `inherit`, and a
|
|
1159
|
+
* level OpenCode does not accept must never be written, so both collapse to
|
|
1160
|
+
* the same `target: null` and the same removal path. Result shape is
|
|
1161
|
+
* additive over the claude branch, matching the CODEX branch's
|
|
1162
|
+
* `{synced, skipped, changes, dry_run, agents_dir, write_failures}`.
|
|
1163
|
+
*/
|
|
1164
|
+
function cmdEffortSyncOpencode(cwd, raw, dryRun, configDir) {
|
|
1165
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
1166
|
+
const { getGlobalConfigDir } = require('./runtime-homes.cjs');
|
|
1167
|
+
const agentsDir = node_path_1.default.join(configDir || getGlobalConfigDir('opencode'), 'agents');
|
|
1168
|
+
if (!node_fs_1.default.existsSync(agentsDir)) {
|
|
1169
|
+
output({ synced: 0, skipped: 0, changes: [], dry_run: dryRun, agents_dir: agentsDir, reason: 'agents directory not found' }, raw, '');
|
|
1170
|
+
return;
|
|
1171
|
+
}
|
|
1172
|
+
// Skip symlinks — matches the claude branch's existing guard (only write
|
|
1173
|
+
// regular files, never follow a symlink into clobbering its target).
|
|
1174
|
+
const files = node_fs_1.default
|
|
1175
|
+
.readdirSync(agentsDir)
|
|
1176
|
+
.filter(f => {
|
|
1177
|
+
if (!f.startsWith('gsd-') || !f.endsWith('.md'))
|
|
1178
|
+
return false;
|
|
1179
|
+
try {
|
|
1180
|
+
return node_fs_1.default.lstatSync(node_path_1.default.join(agentsDir, f)).isFile();
|
|
1181
|
+
}
|
|
1182
|
+
catch {
|
|
1183
|
+
return false;
|
|
1184
|
+
}
|
|
1185
|
+
})
|
|
1186
|
+
.sort();
|
|
1187
|
+
// Use install-time resolvers: they merge ~/.gsd/defaults.json with project
|
|
1188
|
+
// config, matching the exact logic used when agents were originally
|
|
1189
|
+
// installed. Resolved once, outside the loop, like the claude branch.
|
|
1190
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
1191
|
+
const { readGsdEffectiveEffortConfig, resolveInstallTimeEffort } = require('./install-effort-resolver.cjs');
|
|
1192
|
+
const effortCfg = readGsdEffectiveEffortConfig(cwd);
|
|
1193
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/unbound-method
|
|
1194
|
+
const { clampEffortForHost } = require('./model-catalog.cjs');
|
|
1195
|
+
const changes = [];
|
|
1196
|
+
const writeFailures = [];
|
|
1197
|
+
const readFailures = [];
|
|
1198
|
+
let synced = 0;
|
|
1199
|
+
let skipped = 0;
|
|
1200
|
+
for (const file of files) {
|
|
1201
|
+
const agentName = file.replace(/\.md$/, '');
|
|
1202
|
+
const filePath = node_path_1.default.join(agentsDir, file);
|
|
1203
|
+
let content;
|
|
1204
|
+
try {
|
|
1205
|
+
content = node_fs_1.default.readFileSync(filePath, 'utf8');
|
|
1206
|
+
}
|
|
1207
|
+
catch (err) {
|
|
1208
|
+
// An unreadable agent file must not abort the whole sweep — degrade
|
|
1209
|
+
// like the write path below does, and report it under its own
|
|
1210
|
+
// `read_failures` key so a caller can tell "never read" apart from
|
|
1211
|
+
// "read but write failed".
|
|
1212
|
+
skipped++;
|
|
1213
|
+
readFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1214
|
+
continue;
|
|
1215
|
+
}
|
|
1216
|
+
// `target === null` covers both "no effort configured" (inherit) and "a
|
|
1217
|
+
// level OpenCode does not accept" — both must produce NO `variant:` key,
|
|
1218
|
+
// exactly what install writes.
|
|
1219
|
+
const universal = effortCfg ? resolveInstallTimeEffort(effortCfg, agentName) : null;
|
|
1220
|
+
const target = universal ? clampEffortForHost('opencode', universal) : null;
|
|
1221
|
+
const fmMatch = /^---\r?\n([\s\S]*?)^---\r?$/m.exec(content);
|
|
1222
|
+
if (!fmMatch) {
|
|
1223
|
+
skipped++;
|
|
1224
|
+
continue;
|
|
1225
|
+
}
|
|
1226
|
+
// Presence and value are distinct questions: `variant:` with an EMPTY
|
|
1227
|
+
// value is a key that IS present but whose captured value is null (the
|
|
1228
|
+
// `(.+?)` group requires at least one char). Deciding "already correct"
|
|
1229
|
+
// from a null-vs-null comparison alone is wrong when target is also
|
|
1230
|
+
// null — it would leave an unresolvable `variant: null` key on disk
|
|
1231
|
+
// forever. Test presence with its own regex, and only compare values
|
|
1232
|
+
// once presence is known.
|
|
1233
|
+
const variantPresent = /^variant:/m.test(fmMatch[1]);
|
|
1234
|
+
const variantMatch = /^variant:[ \t]*(.+?)[ \t]*$/m.exec(fmMatch[1]);
|
|
1235
|
+
// `null` (key absent) and `''` (key present, value empty) are distinct
|
|
1236
|
+
// states this code deliberately tracks via `variantPresent` above — a
|
|
1237
|
+
// reported `from` that collapses both to `null` would make "no key" and
|
|
1238
|
+
// "empty key" indistinguishable in the sync output, even though only one
|
|
1239
|
+
// of them actually has a `variant:` line to remove.
|
|
1240
|
+
const currentVariant = variantPresent ? (variantMatch ? variantMatch[1] : '') : null;
|
|
1241
|
+
if (target === null) {
|
|
1242
|
+
if (!variantPresent) {
|
|
1243
|
+
skipped++;
|
|
1244
|
+
continue;
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
else if (variantPresent && currentVariant === target) {
|
|
1248
|
+
skipped++;
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
changes.push({ agent: agentName, from: currentVariant, to: target });
|
|
1252
|
+
synced++;
|
|
1253
|
+
if (!dryRun) {
|
|
1254
|
+
// Atomic publish AND mode preservation, same discipline as
|
|
1255
|
+
// cmdEffortSyncCodex above: write to a sibling tmp file, chmod it to
|
|
1256
|
+
// match filePath's existing (masked) mode, then retryRenameSync it over
|
|
1257
|
+
// the target so filePath is either the old bytes or the new ones, never
|
|
1258
|
+
// half-written and never dropped to a default mode. On failure the
|
|
1259
|
+
// write is reported, not thrown, so the remaining agents still get
|
|
1260
|
+
// processed.
|
|
1261
|
+
const tmpPath = `${filePath}.tmp.${process.pid}`;
|
|
1262
|
+
// Stat filePath BEFORE the write so its mode can be passed at CREATION
|
|
1263
|
+
// time — a plain `writeFileSync(tmpPath, data)` creates the tmp file at
|
|
1264
|
+
// the default `0666 & ~umask` (world-readable under a typical 022
|
|
1265
|
+
// umask) even when filePath is e.g. 0600, exposing its contents for
|
|
1266
|
+
// the window between creation and the chmod below. Mask off the
|
|
1267
|
+
// file-type bits (e.g. S_IFREG 0o100000) that fs.statSync().mode
|
|
1268
|
+
// carries alongside the permission bits — POSIX leaves chmod's
|
|
1269
|
+
// handling of those bits unspecified, and the remote matrix runs Linux
|
|
1270
|
+
// only (Darwin tolerating the full mode is not evidence it is safe
|
|
1271
|
+
// there). Best-effort only: a stat failure must not abort the sync,
|
|
1272
|
+
// since the content write is what matters, not the mode.
|
|
1273
|
+
let originalMode;
|
|
1274
|
+
try {
|
|
1275
|
+
originalMode = node_fs_1.default.statSync(filePath).mode & 0o7777;
|
|
1276
|
+
}
|
|
1277
|
+
catch { /* non-fatal: fall back to writing without an explicit mode */ }
|
|
1278
|
+
try {
|
|
1279
|
+
node_fs_1.default.writeFileSync(tmpPath, target === null ? removeFrontmatterKeyLine(content, 'variant') : setFrontmatterKeyLine(content, 'variant', target), originalMode !== undefined ? { mode: originalMode } : undefined);
|
|
1280
|
+
// Not redundant with the `mode` option above: `mode` only applies
|
|
1281
|
+
// when the file is actually created (O_CREAT). A leftover tmp file
|
|
1282
|
+
// from an earlier crashed run would be reused (truncated) at its OLD
|
|
1283
|
+
// mode instead, and this chmod is what corrects that case.
|
|
1284
|
+
// Best-effort only: a chmod failure must not abort the sync, since
|
|
1285
|
+
// the content write is what matters, not the mode.
|
|
1286
|
+
try {
|
|
1287
|
+
if (originalMode !== undefined)
|
|
1288
|
+
node_fs_1.default.chmodSync(tmpPath, originalMode);
|
|
1289
|
+
}
|
|
1290
|
+
catch { /* non-fatal: proceed with default tmp-file mode */ }
|
|
1291
|
+
(0, shell_command_projection_cjs_1.retryRenameSync)(tmpPath, filePath);
|
|
1292
|
+
}
|
|
1293
|
+
catch (err) {
|
|
1294
|
+
try {
|
|
1295
|
+
node_fs_1.default.unlinkSync(tmpPath);
|
|
1296
|
+
}
|
|
1297
|
+
catch { /* already gone or never created */ }
|
|
1298
|
+
changes.pop();
|
|
1299
|
+
synced--;
|
|
1300
|
+
skipped++;
|
|
1301
|
+
writeFailures.push({ agent: agentName, file: filePath, error: err instanceof Error ? err.message : String(err) });
|
|
1302
|
+
continue;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
// Any failure — a write OR a read — must not report 'ok' or 'changed':
|
|
1307
|
+
// either would hide that at least one agent's on-disk state is now unknown
|
|
1308
|
+
// (unread) or unchanged despite being reported as a pending change (write
|
|
1309
|
+
// failed after being pushed onto `changes`/`synced`). `write_failures` and
|
|
1310
|
+
// `read_failures` take priority over the synced-count-derived summary below,
|
|
1311
|
+
// even when other agents in the same run succeeded.
|
|
1312
|
+
//
|
|
1313
|
+
// Known limitation, deliberately not fixed here: `output()` only honors its
|
|
1314
|
+
// third argument when `raw === true`, and this command's process always
|
|
1315
|
+
// exits 0 regardless of the summary string — so `if gsd-tools effort sync;
|
|
1316
|
+
// then` reads success in a shell even on a run where every write failed.
|
|
1317
|
+
// Making the exit code reflect failure would be a CLI-contract change
|
|
1318
|
+
// affecting all three cmdEffortSync* branches (claude, codex, opencode) and
|
|
1319
|
+
// is out of scope for this fix.
|
|
1320
|
+
output({ synced, skipped, changes, dry_run: dryRun, agents_dir: agentsDir, write_failures: writeFailures, read_failures: readFailures }, raw, writeFailures.length > 0 || readFailures.length > 0 ? 'failed' : synced > 0 ? 'changed' : 'ok');
|
|
642
1321
|
}
|
|
643
1322
|
/**
|
|
644
1323
|
* Detect the phase number for a commit from its `--files` path list.
|
|
@@ -693,6 +1372,71 @@ function detectPhaseNumberFromFiles(files) {
|
|
|
693
1372
|
}
|
|
694
1373
|
return null;
|
|
695
1374
|
}
|
|
1375
|
+
/**
|
|
1376
|
+
* #3587: resolve the `phase_commit_docs.<phase-id>` override for `phaseNum`
|
|
1377
|
+
* against `config['phase_commit_docs']` (a `{ "<phase-id>": boolean }` map, the
|
|
1378
|
+
* same shape `agent_skills`/`features` use for their dynamic key families).
|
|
1379
|
+
* Returns `undefined` — "no override applies" — when: no phase is known (B7),
|
|
1380
|
+
* the map carries no entry for THIS phase (B5: no cross-phase leak), or the
|
|
1381
|
+
* entry exists but is not a boolean (B6: never silently coerced). Both sides of
|
|
1382
|
+
* the comparison route through `normalizePhaseName` so `3`, `03`, and `PROJ-03`
|
|
1383
|
+
* all resolve to the same entry (B4/B9), reusing the single-owner phase-id
|
|
1384
|
+
* normalizer rather than a second, looser string-equality rule.
|
|
1385
|
+
*/
|
|
1386
|
+
function resolvePhaseCommitDocsOverride(config, phaseNum) {
|
|
1387
|
+
if (!phaseNum)
|
|
1388
|
+
return undefined;
|
|
1389
|
+
const overrides = config['phase_commit_docs'];
|
|
1390
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides))
|
|
1391
|
+
return undefined;
|
|
1392
|
+
const target = normalizePhaseName(phaseNum);
|
|
1393
|
+
for (const [key, value] of Object.entries(overrides)) {
|
|
1394
|
+
if (normalizePhaseName(key) === target) {
|
|
1395
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
1396
|
+
}
|
|
1397
|
+
}
|
|
1398
|
+
return undefined;
|
|
1399
|
+
}
|
|
1400
|
+
/**
|
|
1401
|
+
* #3587: the four-tier `commit_docs` precedence chain for a single commit —
|
|
1402
|
+
* `phase_commit_docs.<phase-id>` (tier 1, resolved HERE because this call site
|
|
1403
|
+
* is the one place that knows the phase — see 40-design.md "Rejected" §1: NOT
|
|
1404
|
+
* inside `loadConfig`, which has no phase context and is called by nearly every
|
|
1405
|
+
* command), then the pre-existing explicit `commit_docs` (tier 2), `.gitignore`
|
|
1406
|
+
* auto-detect (tier 3), and manifest default (tier 4). Tiers 2-4 are byte-for-
|
|
1407
|
+
* behaviour identical to the pre-#3587 inline checks (epic #2292 AC4): when no
|
|
1408
|
+
* phase override applies, `resolved` matches exactly what those checks computed
|
|
1409
|
+
* and `source` merely labels which of the three decided it.
|
|
1410
|
+
*
|
|
1411
|
+
* `isPlanningGitIgnored` is a thunk, not a plain boolean, so the pre-existing
|
|
1412
|
+
* short-circuit is preserved byte-for-behaviour: the original inline checks
|
|
1413
|
+
* only ever ran `isGitIgnored` (a real `git check-ignore` subprocess) when
|
|
1414
|
+
* `commit_docs` was truthy, and a phase override or an explicit `commit_docs:
|
|
1415
|
+
* false` must keep skipping that call entirely, not just its result. Passing
|
|
1416
|
+
* a thunk also keeps this function pure and directly property-testable
|
|
1417
|
+
* (test matrix F1) without spawning git.
|
|
1418
|
+
*/
|
|
1419
|
+
function resolveCommitDocsPolicy(config, phaseNum, isPlanningGitIgnored) {
|
|
1420
|
+
const phaseOverride = resolvePhaseCommitDocsOverride(config, phaseNum);
|
|
1421
|
+
if (phaseOverride !== undefined)
|
|
1422
|
+
return { resolved: phaseOverride, source: 'phase' };
|
|
1423
|
+
if (!config['commit_docs'])
|
|
1424
|
+
return { resolved: false, source: 'config' };
|
|
1425
|
+
if (isPlanningGitIgnored())
|
|
1426
|
+
return { resolved: false, source: 'gitignore' };
|
|
1427
|
+
return { resolved: true, source: 'default' };
|
|
1428
|
+
}
|
|
1429
|
+
// Reason string per commit_docs-resolution source, for the tier-1/tier-2 skip
|
|
1430
|
+
// envelope below. `phase` gets its OWN reason (`skipped_commit_docs_phase_false`)
|
|
1431
|
+
// rather than reusing `skipped_commit_docs_false` — telling a user "commit_docs
|
|
1432
|
+
// is false" when their project setting is actually `true` would be actively
|
|
1433
|
+
// misleading (design "Rejected" §3). `config` keeps the pre-existing string
|
|
1434
|
+
// unchanged: `agents/gsd-executor.md` pattern-matches on it (D2).
|
|
1435
|
+
const COMMIT_DOCS_SKIP_REASON = {
|
|
1436
|
+
phase: 'skipped_commit_docs_phase_false',
|
|
1437
|
+
config: 'skipped_commit_docs_false',
|
|
1438
|
+
gitignore: 'skipped_gitignored',
|
|
1439
|
+
};
|
|
696
1440
|
function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
697
1441
|
if (!message && !amend) {
|
|
698
1442
|
error('commit message required');
|
|
@@ -706,18 +1450,20 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
706
1450
|
sanitizedMessage = sanitizeForPrompt(sanitizedMessage);
|
|
707
1451
|
}
|
|
708
1452
|
const config = loadConfig(cwd);
|
|
709
|
-
// Check commit_docs config
|
|
1453
|
+
// Check commit_docs config — #3587: resolved through the tier 1
|
|
1454
|
+
// (phase_commit_docs.<phase-id>) → tier 2 (commit_docs) → tier 3 (.gitignore)
|
|
1455
|
+
// → tier 4 (default) precedence chain; see resolveCommitDocsPolicy above.
|
|
710
1456
|
// `skipped: true` is explicit so agent prompts can match on a first-class
|
|
711
1457
|
// success signal rather than inferring "skip" from "committed is missing"
|
|
712
1458
|
// and improvising raw git fallbacks (#3678).
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1459
|
+
const commitDocsPolicy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(files), () => isGitIgnored(cwd, '.planning'));
|
|
1460
|
+
if (!commitDocsPolicy.resolved) {
|
|
1461
|
+
const result = {
|
|
1462
|
+
committed: false,
|
|
1463
|
+
skipped: true,
|
|
1464
|
+
hash: null,
|
|
1465
|
+
reason: COMMIT_DOCS_SKIP_REASON[commitDocsPolicy.source],
|
|
1466
|
+
};
|
|
721
1467
|
output(result, raw, 'skipped');
|
|
722
1468
|
return;
|
|
723
1469
|
}
|
|
@@ -742,7 +1488,10 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
742
1488
|
// — see #2528 for the parallel drift problem in phase-locator/phase),
|
|
743
1489
|
// so this is the canonical path-segment-bound read, not a fourth copy.
|
|
744
1490
|
const phaseNum = detectPhaseNumberFromFiles(files);
|
|
745
|
-
|
|
1491
|
+
// #3734: a 999.x/0.x backlog sentinel is a parking-lot entry, not a real
|
|
1492
|
+
// phase — the phase arm must never branch-mutate for it (isSentinelPhaseId
|
|
1493
|
+
// is the invariant's single owner, src/phase-id.cts).
|
|
1494
|
+
if (phaseNum && !isSentinelPhaseId(phaseNum)) {
|
|
746
1495
|
const phaseInfo = findPhaseInternal(cwd, phaseNum);
|
|
747
1496
|
if (phaseInfo) {
|
|
748
1497
|
branchName = config['phase_branch_template']
|
|
@@ -752,7 +1501,19 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
752
1501
|
}
|
|
753
1502
|
}
|
|
754
1503
|
else if (branchingStrategy === 'milestone') {
|
|
755
|
-
const
|
|
1504
|
+
const milestoneInfo = getMilestoneInfo(cwd);
|
|
1505
|
+
// #3216 review Finding 3: explicit scope gate instead of plain truthiness.
|
|
1506
|
+
// COMPLETE and TRUNCATED both carry a real `version` (ADR-3180 §7.2 rule
|
|
1507
|
+
// 6 — TRUNCATED means the version resolved but the milestone's NAME did
|
|
1508
|
+
// not), so a TRUNCATED identity is acceptable here: `milestone.version`
|
|
1509
|
+
// only feeds a BRANCH NAME, and `generateSlugInternal(null) || 'milestone'`
|
|
1510
|
+
// already degrades the missing name to the literal "milestone" slug on
|
|
1511
|
+
// purpose. This differs from `archivePhaseDirectories` (milestone.cts),
|
|
1512
|
+
// which uses the same value as a DIRECTORY NAME and therefore demands
|
|
1513
|
+
// COMPLETE only — a real-but-unnamed version is not safe enough there.
|
|
1514
|
+
const milestone = milestoneInfo.scope === SCOPE.COMPLETE || milestoneInfo.scope === SCOPE.TRUNCATED
|
|
1515
|
+
? milestoneInfo.value
|
|
1516
|
+
: null;
|
|
756
1517
|
if (milestone && milestone.version) {
|
|
757
1518
|
branchName = config['milestone_branch_template']
|
|
758
1519
|
.replace('{milestone}', milestone.version)
|
|
@@ -762,22 +1523,28 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
762
1523
|
if (branchName) {
|
|
763
1524
|
const currentBranch = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--abbrev-ref', 'HEAD'], { cwd });
|
|
764
1525
|
if (currentBranch.exitCode === 0 && currentBranch.stdout.trim() !== branchName) {
|
|
765
|
-
// #2539/#3079:
|
|
766
|
-
//
|
|
767
|
-
//
|
|
768
|
-
//
|
|
769
|
-
//
|
|
770
|
-
//
|
|
771
|
-
//
|
|
772
|
-
//
|
|
773
|
-
//
|
|
774
|
-
//
|
|
775
|
-
//
|
|
1526
|
+
// #2539/#3079/#3207: two cases the prior (#3079) code collapsed into one.
|
|
1527
|
+
// #1278 intent: CREATE the phase/milestone branch before the FIRST commit
|
|
1528
|
+
// on it so the phase's work accumulates there. #3079/#2539 hazard: never
|
|
1529
|
+
// silently switch an already-checked-out working branch onto a DIFFERENT
|
|
1530
|
+
// EXISTING branch — that resurrects merged-and-deleted phase branches and
|
|
1531
|
+
// silently moves HEAD onto a stale ref (#2539 AC2: an auto-checkout
|
|
1532
|
+
// mid-commit must never happen silently).
|
|
1533
|
+
// Reconciliation (#3207): a brand-new branch has no resurrection target,
|
|
1534
|
+
// so create-and-switch is safe here and is exactly the #1278 intent; an
|
|
1535
|
+
// EXISTING branch is never switched to (the else arm logs + commits in
|
|
1536
|
+
// place). The fresh create is logged so the first phase-scoped commit is
|
|
1537
|
+
// not silent about where the work is landing (#3207 AC3).
|
|
776
1538
|
const verify = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--verify', `refs/heads/${branchName}`], { cwd });
|
|
777
1539
|
if (verify.exitCode !== 0) {
|
|
778
|
-
// Branch does not exist —
|
|
779
|
-
|
|
780
|
-
|
|
1540
|
+
// Branch does not exist — CREATE AND SWITCH (the #1278 first-commit
|
|
1541
|
+
// case). checkout -b cannot resurrect anything: the branch was just
|
|
1542
|
+
// verified absent, so it is created fresh at HEAD.
|
|
1543
|
+
const create = (0, shell_command_projection_cjs_1.execGit)(['checkout', '-b', branchName], { cwd });
|
|
1544
|
+
if (create.exitCode === 0) {
|
|
1545
|
+
process.stderr.write(`${branchingStrategy} branch "${branchName}" created; switched to it for this commit.\n`);
|
|
1546
|
+
}
|
|
1547
|
+
else {
|
|
781
1548
|
process.stderr.write(`Warning: could not create ${branchingStrategy} branch "${branchName}" ` +
|
|
782
1549
|
`(${create.stderr.trim()}); committing on the current branch "${currentBranch.stdout.trim()}".\n`);
|
|
783
1550
|
}
|
|
@@ -904,8 +1671,26 @@ function cmdCommit(cwd, message, files, raw, amend, noVerify) {
|
|
|
904
1671
|
if (canScope) {
|
|
905
1672
|
commitArgs.push('--', ...stagedPaths);
|
|
906
1673
|
}
|
|
907
|
-
|
|
1674
|
+
// #3886: `git commit` runs pre-commit hooks (husky/lint-staged routinely
|
|
1675
|
+
// idles ~4s on Windows before any task) — 10s is too tight, and a timeout
|
|
1676
|
+
// kill is NOT an ordinary failure. Same band as the push call below.
|
|
1677
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd, timeout: COMMIT_TIMEOUT_MS });
|
|
908
1678
|
if (commitResult.exitCode !== 0) {
|
|
1679
|
+
// #3886: a SIGTERM'd git commit is a timeout, not commit_failed — the
|
|
1680
|
+
// partial stderr it flushed (often incidental CRLF warnings) is noise,
|
|
1681
|
+
// and the kill can leave a stale index.lock that blocks the next
|
|
1682
|
+
// attempt. Report the distinct reason and surface the lock path.
|
|
1683
|
+
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
|
|
1684
|
+
const result = {
|
|
1685
|
+
committed: false,
|
|
1686
|
+
hash: null,
|
|
1687
|
+
reason: 'commit_timeout',
|
|
1688
|
+
timed_out: true,
|
|
1689
|
+
error: commitTimeoutMessage(cwd, commitResult.stderr, commitResult.stdout),
|
|
1690
|
+
};
|
|
1691
|
+
output(result, raw, 'failed');
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
909
1694
|
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
910
1695
|
const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
|
|
911
1696
|
output(result, raw, 'nothing');
|
|
@@ -1045,8 +1830,21 @@ function cmdCommitToSubrepo(cwd, message, files, raw) {
|
|
|
1045
1830
|
const commitArgs = canScopeSub
|
|
1046
1831
|
? ['commit', '-m', message, '--', ...stagedRelPaths]
|
|
1047
1832
|
: ['commit', '-m', message];
|
|
1048
|
-
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
|
|
1833
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
|
|
1049
1834
|
if (commitResult.exitCode !== 0) {
|
|
1835
|
+
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
|
|
1836
|
+
// #3886 (subrepo counterpart): timeout ≠ error; surface the stale-lock
|
|
1837
|
+
// path a killed commit can leave in the subrepo.
|
|
1838
|
+
repos[repo] = {
|
|
1839
|
+
committed: false,
|
|
1840
|
+
hash: null,
|
|
1841
|
+
files: repoFiles,
|
|
1842
|
+
reason: 'commit_timeout',
|
|
1843
|
+
timed_out: true,
|
|
1844
|
+
error: commitTimeoutMessage(repoCwd, commitResult.stderr, commitResult.stdout),
|
|
1845
|
+
};
|
|
1846
|
+
continue;
|
|
1847
|
+
}
|
|
1050
1848
|
if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
|
|
1051
1849
|
repos[repo] = { committed: false, hash: null, files: repoFiles, reason: 'nothing_to_commit' };
|
|
1052
1850
|
continue;
|
|
@@ -1176,9 +1974,15 @@ function cmdPrSubrepo(cwd, repo, branch, commitMessage, raw) {
|
|
|
1176
1974
|
const commitArgs = canScopePr
|
|
1177
1975
|
? ['commit', '-m', commitMessage, '--', ...changedFiles]
|
|
1178
1976
|
: ['commit', '-m', commitMessage];
|
|
1179
|
-
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd });
|
|
1977
|
+
const commitResult = (0, shell_command_projection_cjs_1.execGit)(commitArgs, { cwd: repoCwd, timeout: COMMIT_TIMEOUT_MS });
|
|
1180
1978
|
if (commitResult.exitCode !== 0) {
|
|
1181
1979
|
rollback();
|
|
1980
|
+
if ((0, shell_command_projection_cjs_1.isSpawnTimeout)(commitResult)) {
|
|
1981
|
+
// #3886 (PR-subrepo counterpart): name the timeout and the stale lock
|
|
1982
|
+
// instead of echoing the killed hook's partial stderr.
|
|
1983
|
+
error(`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s in ${repo} (killed mid-hook; ` +
|
|
1984
|
+
`a stale lock may remain at ${resolveIndexLockPath(repoCwd)} — remove it if no git process is running)`);
|
|
1985
|
+
}
|
|
1182
1986
|
error(`Failed to commit in ${repo}: ${commitResult.stderr}`);
|
|
1183
1987
|
}
|
|
1184
1988
|
// 6. Capture commit hash
|
|
@@ -1384,20 +2188,28 @@ async function cmdWebsearch(query, options, raw) {
|
|
|
1384
2188
|
}
|
|
1385
2189
|
function cmdProgressRender(cwd, format, raw) {
|
|
1386
2190
|
const phasesDir = planningPaths(cwd).phases;
|
|
1387
|
-
const milestone = getMilestoneInfo(cwd);
|
|
2191
|
+
const milestone = getMilestoneInfo(cwd).value;
|
|
1388
2192
|
const phases = [];
|
|
1389
2193
|
let totalPlans = 0;
|
|
1390
2194
|
let totalSummaries = 0;
|
|
2195
|
+
let phaseScope = null;
|
|
1391
2196
|
try {
|
|
1392
|
-
|
|
1393
|
-
|
|
2197
|
+
// #3185 (ADR-3180 Decision 1): the single owner applies the milestone
|
|
2198
|
+
// window AND the sentinel filter and returns dirs already sorted by
|
|
2199
|
+
// comparePhaseNum. This command previously read the phases directory
|
|
2200
|
+
// directly with neither, which is why `query progress` listed 999.*
|
|
2201
|
+
// backlog directories as current-milestone phases (#3167).
|
|
2202
|
+
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
2203
|
+
phaseScope = scope;
|
|
1394
2204
|
for (const dir of dirs) {
|
|
1395
2205
|
const dm = dir.match(/^(\d+(?:\.\d+)*)-?(.*)/);
|
|
1396
2206
|
const phaseNum = dm ? dm[1] : dir;
|
|
1397
2207
|
const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
const
|
|
2208
|
+
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
|
2209
|
+
// canonical pairing) from the single owner.
|
|
2210
|
+
const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
2211
|
+
const plans = phaseScan.planCount;
|
|
2212
|
+
const summaries = phaseScan.summaryCount;
|
|
1401
2213
|
totalPlans += plans;
|
|
1402
2214
|
totalSummaries += summaries;
|
|
1403
2215
|
const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Pending');
|
|
@@ -1405,14 +2217,24 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
1405
2217
|
}
|
|
1406
2218
|
}
|
|
1407
2219
|
catch { /* intentionally empty */ }
|
|
1408
|
-
|
|
2220
|
+
// #3217 (ADR-3180 §7.6 rule 4): `phaseScope` was already computed above
|
|
2221
|
+
// (Phase 3, #3222) but never consulted before rendering — a percentage was
|
|
2222
|
+
// rendered from counts the scope said were not answers (TRUNCATED /
|
|
2223
|
+
// UNSCOPED / UNREADABLE). Withhold the percentage itself (never `0` — a
|
|
2224
|
+
// real `0` under COMPLETE must still render, rule 2's territory) when the
|
|
2225
|
+
// scope is not COMPLETE. `phaseScope` stays `null` only if the try block
|
|
2226
|
+
// above threw before assigning it; treat that the same as non-COMPLETE.
|
|
2227
|
+
const percent = phaseScope === SCOPE.COMPLETE
|
|
2228
|
+
? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans)
|
|
2229
|
+
: null;
|
|
1409
2230
|
if (format === 'table') {
|
|
1410
2231
|
// Render markdown table
|
|
1411
2232
|
const barWidth = 10;
|
|
1412
|
-
const filled = Math.round((percent / 100) * barWidth);
|
|
2233
|
+
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
1413
2234
|
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1414
|
-
|
|
1415
|
-
out
|
|
2235
|
+
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2236
|
+
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n\n`;
|
|
2237
|
+
out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}\n\n`;
|
|
1416
2238
|
out += `| Phase | Name | Plans | Status |\n`;
|
|
1417
2239
|
out += `|-------|------|-------|--------|\n`;
|
|
1418
2240
|
for (const p of phases) {
|
|
@@ -1422,20 +2244,24 @@ function cmdProgressRender(cwd, format, raw) {
|
|
|
1422
2244
|
}
|
|
1423
2245
|
else if (format === 'bar') {
|
|
1424
2246
|
const barWidth = 20;
|
|
1425
|
-
const filled = Math.round((percent / 100) * barWidth);
|
|
2247
|
+
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
1426
2248
|
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1427
|
-
const
|
|
2249
|
+
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2250
|
+
const text = `[${bar}] ${totalSummaries}/${totalPlans} plans${percentSuffix}`;
|
|
1428
2251
|
output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
|
|
1429
2252
|
}
|
|
1430
2253
|
else {
|
|
1431
2254
|
// JSON format
|
|
1432
2255
|
output({
|
|
1433
|
-
milestone_version: milestone
|
|
1434
|
-
milestone_name: milestone
|
|
2256
|
+
milestone_version: milestone?.version ?? null,
|
|
2257
|
+
milestone_name: milestone?.name ?? null,
|
|
1435
2258
|
phases,
|
|
1436
2259
|
total_plans: totalPlans,
|
|
1437
2260
|
total_summaries: totalSummaries,
|
|
1438
2261
|
percent,
|
|
2262
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
2263
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
2264
|
+
phase_scope: phaseScope,
|
|
1439
2265
|
}, raw, undefined);
|
|
1440
2266
|
}
|
|
1441
2267
|
}
|
|
@@ -1492,7 +2318,9 @@ function cmdTodoMatchPhase(cwd, phase, raw) {
|
|
|
1492
2318
|
if (phaseInfoDisk && phaseInfoDisk['found']) {
|
|
1493
2319
|
try {
|
|
1494
2320
|
const phaseDir = node_path_1.default.join(cwd, phaseInfoDisk['directory']);
|
|
1495
|
-
|
|
2321
|
+
// #3183: canonical plan set (root+nested, superseded-excluded) from the
|
|
2322
|
+
// single owner, rather than a root-only hand-rolled readdirSync filter.
|
|
2323
|
+
const planFiles = scanPhasePlans(phaseDir).planFiles;
|
|
1496
2324
|
for (const pf of planFiles) {
|
|
1497
2325
|
const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(node_path_1.default.join(phaseDir, pf));
|
|
1498
2326
|
if (planContent === null)
|
|
@@ -1629,12 +2457,12 @@ function cmdStats(cwd, format, raw) {
|
|
|
1629
2457
|
const roadmapPath = planningPaths(cwd).roadmap;
|
|
1630
2458
|
const reqPath = planningPaths(cwd).requirements;
|
|
1631
2459
|
const statePath = planningPaths(cwd).state;
|
|
1632
|
-
const milestone = getMilestoneInfo(cwd);
|
|
1633
|
-
const isDirInMilestone = getMilestonePhaseFilter(cwd);
|
|
2460
|
+
const milestone = getMilestoneInfo(cwd).value;
|
|
1634
2461
|
// Phase & plan stats (reuse progress pattern)
|
|
1635
2462
|
const phasesByNumber = new Map();
|
|
1636
2463
|
let totalPlans = 0;
|
|
1637
2464
|
let totalSummaries = 0;
|
|
2465
|
+
let phaseScope = null;
|
|
1638
2466
|
try {
|
|
1639
2467
|
const roadmapRaw = (0, shell_command_projection_cjs_1.platformReadSync)(roadmapPath);
|
|
1640
2468
|
if (roadmapRaw === null)
|
|
@@ -1643,9 +2471,22 @@ function cmdStats(cwd, format, raw) {
|
|
|
1643
2471
|
// Matches both plain numeric (Phase 1:) and milestone-prefixed (Phase 2-01:) headings.
|
|
1644
2472
|
// Also tolerates optional [bracket-token] scope prefix on phase headings.
|
|
1645
2473
|
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE).
|
|
1646
|
-
|
|
2474
|
+
// #3569: the id capture is the canonical #3036 shape (digit REQUIRED — incl.
|
|
2475
|
+
// letter-prefixed B7, decimals, milestone 2-01), the same group roadmap.cts's
|
|
2476
|
+
// collectAnalyzePhases uses. The former `([\w][\w.-]*)` matched ANY word, so
|
|
2477
|
+
// prose mentioning `### Phase N:` inside an inline code span produced a phantom
|
|
2478
|
+
// Not-Started row and made phases_total disagree with roadmap analyze.
|
|
2479
|
+
// 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.
|
|
2480
|
+
const headingPattern = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([A-Za-z]?\d+[A-Z]?(?:[.-]\d+)*)(?:\s*\([^)\n]{0,200}\))?\s*:\s*([^\n]+)/gi;
|
|
1647
2481
|
let match;
|
|
1648
2482
|
while ((match = headingPattern.exec(roadmapContent)) !== null) {
|
|
2483
|
+
// #3185: the heading seed carried no sentinel filter, so a
|
|
2484
|
+
// `### Phase 999.1:` backlog heading produced a stats row even with no
|
|
2485
|
+
// directory on disk. Uses the canonical predicate (phase-id.cts), not a
|
|
2486
|
+
// local literal — the rule had five copies and three regex variants
|
|
2487
|
+
// before this phase, disagreeing about Phase 0.
|
|
2488
|
+
if (isSentinelPhaseId(match[1]))
|
|
2489
|
+
continue;
|
|
1649
2490
|
const key = normalizePhaseName(match[1]);
|
|
1650
2491
|
phasesByNumber.set(key, {
|
|
1651
2492
|
number: key,
|
|
@@ -1658,12 +2499,13 @@ function cmdStats(cwd, format, raw) {
|
|
|
1658
2499
|
}
|
|
1659
2500
|
catch { /* intentionally empty */ }
|
|
1660
2501
|
try {
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
2502
|
+
// #3185 (ADR-3180 Decision 1): route through the single owner. This
|
|
2503
|
+
// previously applied the milestone window but NOT a directory-level
|
|
2504
|
+
// sentinel filter — and getMilestonePhaseFilter degrades to a pass-all
|
|
2505
|
+
// predicate when its heading set is empty, at which point every directory
|
|
2506
|
+
// on disk passed, backlog included (#3167).
|
|
2507
|
+
const { value: dirs, scope } = listMilestonePhaseDirs(phasesDir, { cwd });
|
|
2508
|
+
phaseScope = scope;
|
|
1667
2509
|
for (const dir of dirs) {
|
|
1668
2510
|
// Use extractPhaseToken to correctly parse M-NN-style and code-prefixed dir names.
|
|
1669
2511
|
const phaseToken = extractPhaseToken(dir);
|
|
@@ -1671,9 +2513,11 @@ function cmdStats(cwd, format, raw) {
|
|
|
1671
2513
|
// phaseName is everything after the token (strip leading '-')
|
|
1672
2514
|
const afterToken = dir.slice(phaseToken ? phaseToken.length : 0).replace(/^-/, '');
|
|
1673
2515
|
const phaseName = afterToken ? afterToken.replace(/-/g, ' ') : '';
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
const
|
|
2516
|
+
// #3183: canonical plan/summary counts (root+nested, superseded-excluded,
|
|
2517
|
+
// canonical pairing) from the single owner.
|
|
2518
|
+
const phaseScan = scanPhasePlans(node_path_1.default.join(phasesDir, dir));
|
|
2519
|
+
const plans = phaseScan.planCount;
|
|
2520
|
+
const summaries = phaseScan.summaryCount;
|
|
1677
2521
|
totalPlans += plans;
|
|
1678
2522
|
totalSummaries += summaries;
|
|
1679
2523
|
const status = determinePhaseStatus(plans, summaries, node_path_1.default.join(phasesDir, dir), 'Not Started');
|
|
@@ -1696,8 +2540,12 @@ function cmdStats(cwd, format, raw) {
|
|
|
1696
2540
|
catch { /* intentionally empty */ }
|
|
1697
2541
|
const phases = [...phasesByNumber.values()].sort((a, b) => comparePhaseNum(a.number, b.number));
|
|
1698
2542
|
const completedPhases = phases.filter(p => p.status === 'Complete').length;
|
|
1699
|
-
|
|
1700
|
-
|
|
2543
|
+
// #3217 (ADR-3180 §7.6 rule 4): both percentages here are derived from the
|
|
2544
|
+
// same `phaseScope`-carrying directory enumeration above (Phase 3, #3222) —
|
|
2545
|
+
// withhold both when that scope is not COMPLETE, same rationale as
|
|
2546
|
+
// cmdProgressRender above. A real `0` under COMPLETE still renders.
|
|
2547
|
+
const planPercent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(totalSummaries, totalPlans) : null;
|
|
2548
|
+
const percent = phaseScope === SCOPE.COMPLETE ? (0, phase_lifecycle_cjs_1.clampPercent)(completedPhases, phases.length) : null;
|
|
1701
2549
|
// Requirements stats
|
|
1702
2550
|
let requirementsTotal = 0;
|
|
1703
2551
|
let requirementsComplete = 0;
|
|
@@ -1735,8 +2583,8 @@ function cmdStats(cwd, format, raw) {
|
|
|
1735
2583
|
}
|
|
1736
2584
|
}
|
|
1737
2585
|
const result = {
|
|
1738
|
-
milestone_version: milestone
|
|
1739
|
-
milestone_name: milestone
|
|
2586
|
+
milestone_version: milestone?.version ?? null,
|
|
2587
|
+
milestone_name: milestone?.name ?? null,
|
|
1740
2588
|
phases,
|
|
1741
2589
|
phases_completed: completedPhases,
|
|
1742
2590
|
phases_total: phases.length,
|
|
@@ -1749,14 +2597,18 @@ function cmdStats(cwd, format, raw) {
|
|
|
1749
2597
|
git_commits: gitCommits,
|
|
1750
2598
|
git_first_commit_date: gitFirstCommitDate,
|
|
1751
2599
|
last_activity: lastActivity,
|
|
2600
|
+
// #3185 (ADR-3180 Decision 2): the enumeration's scope, so a consumer
|
|
2601
|
+
// can tell a genuinely-empty milestone from one it could not scope.
|
|
2602
|
+
phase_scope: phaseScope,
|
|
1752
2603
|
};
|
|
1753
2604
|
if (format === 'table') {
|
|
1754
2605
|
const barWidth = 10;
|
|
1755
|
-
const filled = Math.round((percent / 100) * barWidth);
|
|
2606
|
+
const filled = percent === null ? 0 : Math.round((percent / 100) * barWidth);
|
|
1756
2607
|
const bar = '█'.repeat(filled) + '░'.repeat(barWidth - filled);
|
|
1757
|
-
let out = `# ${milestone
|
|
1758
|
-
|
|
1759
|
-
|
|
2608
|
+
let out = `# ${milestone?.version ?? ''} ${milestone?.name ?? ''} — Statistics\n\n`;
|
|
2609
|
+
const percentSuffix = percent === null ? '' : ` (${percent}%)`;
|
|
2610
|
+
out += `**Progress:** [${bar}] ${completedPhases}/${phases.length} phases${percentSuffix}\n`;
|
|
2611
|
+
if (totalPlans > 0 && planPercent !== null) {
|
|
1760
2612
|
out += `**Plans:** ${totalSummaries}/${totalPlans} complete (${planPercent}%)\n`;
|
|
1761
2613
|
}
|
|
1762
2614
|
out += `**Phases:** ${completedPhases}/${phases.length} complete\n`;
|
|
@@ -1784,30 +2636,234 @@ function cmdStats(cwd, format, raw) {
|
|
|
1784
2636
|
}
|
|
1785
2637
|
}
|
|
1786
2638
|
/**
|
|
1787
|
-
* Check whether a commit should be allowed based on commit_docs
|
|
1788
|
-
*
|
|
1789
|
-
*
|
|
2639
|
+
* Check whether a commit should be allowed based on the `commit_docs`
|
|
2640
|
+
* precedence chain, INCLUDING any `phase_commit_docs.<phase-id>` override
|
|
2641
|
+
* (#3587/#3601). Rejects commits that stage `.planning/` files when the
|
|
2642
|
+
* resolved policy is false. Intended for use as a pre-commit hook guard —
|
|
2643
|
+
* see `commit-docs-guard enable` above.
|
|
2644
|
+
*
|
|
2645
|
+
* The phase is derived from the STAGED `.planning/` paths via the single-
|
|
2646
|
+
* owner `detectPhaseNumberFromFiles` (the same helper `cmdCommit` uses), and
|
|
2647
|
+
* the policy itself is resolved via the single-owner `resolveCommitDocsPolicy`
|
|
2648
|
+
* (also shared with `cmdCommit`) — this function never re-derives phase
|
|
2649
|
+
* detection or precedence, so it cannot diverge from `cmdCommit`'s decision
|
|
2650
|
+
* for the same staged tree (#3588 Part 1: this guard was previously
|
|
2651
|
+
* phase-blind, reading only project-level `commit_docs` and directly
|
|
2652
|
+
* contradicting `gsd-tools query commit`'s phase-aware resolution).
|
|
2653
|
+
*
|
|
2654
|
+
* Staged paths are read via `git diff --cached --name-only -z`, NUL-
|
|
2655
|
+
* delimited, rather than the LF-delimited default. Without `-z`, git
|
|
2656
|
+
* C-style-quotes (wraps in double quotes, octal-escapes) any path containing
|
|
2657
|
+
* a non-ASCII byte, a space-adjacent special character, or a literal quote —
|
|
2658
|
+
* `.planning/café.md` is reported as `".planning/caf\303\251.md"`, which
|
|
2659
|
+
* does not start with `.planning/`, so the old LF-based filter silently
|
|
2660
|
+
* missed it and allowed the commit (#3588 F2: a false negative in the harm
|
|
2661
|
+
* direction this guard exists to prevent). `-z` disables that quoting
|
|
2662
|
+
* entirely and NUL-terminates each path instead, so every staged path is
|
|
2663
|
+
* read as literal, unquoted bytes and no unquoting logic is needed.
|
|
1790
2664
|
*/
|
|
1791
2665
|
function cmdCheckCommit(cwd, raw) {
|
|
1792
2666
|
const config = loadConfig(cwd);
|
|
1793
|
-
|
|
1794
|
-
if (config['commit_docs'] !== false) {
|
|
1795
|
-
output({ allowed: true, reason: 'commit_docs_enabled' }, raw, 'allowed');
|
|
1796
|
-
return;
|
|
1797
|
-
}
|
|
1798
|
-
// commit_docs is false — check if any .planning/ files are staged
|
|
1799
|
-
const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only'], { cwd });
|
|
2667
|
+
const stagedResult = (0, shell_command_projection_cjs_1.execGit)(['diff', '--cached', '--name-only', '-z'], { cwd });
|
|
1800
2668
|
if (stagedResult.exitCode === 0) {
|
|
1801
|
-
const
|
|
2669
|
+
const files = stagedResult.stdout.split('\0').filter(Boolean);
|
|
2670
|
+
const planningFiles = files.filter(f => f.startsWith('.planning/'));
|
|
1802
2671
|
if (planningFiles.length > 0) {
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
2672
|
+
const policy = resolveCommitDocsPolicy(config, detectPhaseNumberFromFiles(planningFiles), () => isGitIgnored(cwd, '.planning'));
|
|
2673
|
+
if (!policy.resolved) {
|
|
2674
|
+
error(`commit_docs is false but ${planningFiles.length} .planning/ file(s) are staged:\n` +
|
|
2675
|
+
planningFiles.map(f => ` ${f}`).join('\n') +
|
|
2676
|
+
`\n\nTo unstage: git reset HEAD ${planningFiles.join(' ')}`);
|
|
2677
|
+
return;
|
|
2678
|
+
}
|
|
2679
|
+
output({ allowed: true, reason: policy.source === 'phase' ? 'phase_commit_docs_true' : 'commit_docs_enabled' }, raw, 'allowed');
|
|
2680
|
+
return;
|
|
1806
2681
|
}
|
|
1807
2682
|
}
|
|
1808
|
-
// exitCode !== 0
|
|
2683
|
+
// exitCode !== 0 (no staged files / not a git repo) or no .planning/ files staged — allow
|
|
1809
2684
|
output({ allowed: true, reason: 'no_planning_files_staged' }, raw, 'allowed');
|
|
1810
2685
|
}
|
|
2686
|
+
// ─── commit-docs-guard: opt-in pre-commit hook (#3588) ─────────────────────
|
|
2687
|
+
/**
|
|
2688
|
+
* Stable sentinel line identifying a `.git/hooks/pre-commit` file as ours.
|
|
2689
|
+
* Detection is by PRESENCE of this line, not byte-equality (design "Identifying
|
|
2690
|
+
* 'our' hook") — a user who appends a line to a GSD-written hook must not make
|
|
2691
|
+
* it unrecognizable, and a hook lacking this line must never be overwritten or
|
|
2692
|
+
* deleted by `commit-docs-guard enable`/`disable`.
|
|
2693
|
+
*/
|
|
2694
|
+
const COMMIT_DOCS_GUARD_MARKER = '# gsd-core:commit-docs-guard';
|
|
2695
|
+
/**
|
|
2696
|
+
* Locate `gsd-core/workflows/_runtime-launcher.snippet.sh` — the SAME
|
|
2697
|
+
* gsd-tools-resolution chain every shipped workflow/agent bash block uses
|
|
2698
|
+
* (scripts/sync-runtime-launcher.cjs) — by walking up from this module's own
|
|
2699
|
+
* compiled location rather than a fixed literal `../..` join, so the walk
|
|
2700
|
+
* tolerates the module living at a different depth under an alternate build
|
|
2701
|
+
* or bundling layout (same defensive shape as
|
|
2702
|
+
* runtime-artifact-layout.cts#findInstallSourceRoot).
|
|
2703
|
+
*/
|
|
2704
|
+
function findRuntimeLauncherSnippet() {
|
|
2705
|
+
let dir = __dirname;
|
|
2706
|
+
for (let i = 0; i < 8; i++) {
|
|
2707
|
+
const candidate = node_path_1.default.join(dir, 'workflows', '_runtime-launcher.snippet.sh');
|
|
2708
|
+
if (node_fs_1.default.existsSync(candidate))
|
|
2709
|
+
return candidate;
|
|
2710
|
+
const parent = node_path_1.default.dirname(dir);
|
|
2711
|
+
if (parent === dir)
|
|
2712
|
+
break;
|
|
2713
|
+
dir = parent;
|
|
2714
|
+
}
|
|
2715
|
+
throw new Error(`commit-docs-guard: could not locate workflows/_runtime-launcher.snippet.sh from ${__dirname}`);
|
|
2716
|
+
}
|
|
2717
|
+
/**
|
|
2718
|
+
* Build the literal `.git/hooks/pre-commit` content `commit-docs-guard enable`
|
|
2719
|
+
* writes. Reuses the canonical gsd_run resolution preamble byte-for-byte
|
|
2720
|
+
* (read from disk, never hand-copied — see findRuntimeLauncherSnippet) so this
|
|
2721
|
+
* hook resolves `gsd-tools` exactly the way every other shipped workflow bash
|
|
2722
|
+
* block does, and cannot drift from it.
|
|
2723
|
+
*
|
|
2724
|
+
* LF-only (#3588 A2): the snippet file and every literal line here are joined
|
|
2725
|
+
* with `\n`; platformWriteSync additionally normalizes CRLF→LF on write, so a
|
|
2726
|
+
* CRLF shebang — which is not executable under Git Bash — cannot reach disk.
|
|
2727
|
+
*/
|
|
2728
|
+
function buildCommitDocsGuardHookScript() {
|
|
2729
|
+
const snippetPath = findRuntimeLauncherSnippet();
|
|
2730
|
+
const preamble = (0, text_lines_cjs_1.normalizeEol)(node_fs_1.default.readFileSync(snippetPath, 'utf8')).replace(/\n+$/, '');
|
|
2731
|
+
const lines = [
|
|
2732
|
+
'#!/usr/bin/env bash',
|
|
2733
|
+
COMMIT_DOCS_GUARD_MARKER,
|
|
2734
|
+
'# Refuses a commit that stages .planning/ files when `commit_docs` resolves',
|
|
2735
|
+
'# false (honoring any per-phase override). Installed by',
|
|
2736
|
+
'# `gsd-tools commit-docs-guard enable`; remove with',
|
|
2737
|
+
'# `gsd-tools commit-docs-guard disable`. See',
|
|
2738
|
+
'# docs/how-to/keep-planning-docs-private.md.',
|
|
2739
|
+
'set -euo pipefail',
|
|
2740
|
+
'',
|
|
2741
|
+
preamble,
|
|
2742
|
+
'',
|
|
2743
|
+
'gsd_run check-commit --raw',
|
|
2744
|
+
];
|
|
2745
|
+
return lines.join('\n') + '\n';
|
|
2746
|
+
}
|
|
2747
|
+
/**
|
|
2748
|
+
* #3886: the timeout band for `git commit` — pre-commit hooks (husky +
|
|
2749
|
+
* lint-staged idles ~4s on Windows before any task) routinely exceed the 10s
|
|
2750
|
+
* plumbing default; 30s is the same band the push call uses. Shared by all
|
|
2751
|
+
* three commit sites AND their timeout messages, so the number and the text
|
|
2752
|
+
* cannot drift apart.
|
|
2753
|
+
*/
|
|
2754
|
+
const COMMIT_TIMEOUT_MS = 30_000;
|
|
2755
|
+
/**
|
|
2756
|
+
* #3886: resolve where a killed `git commit` would leave its stale
|
|
2757
|
+
* index.lock — via `git rev-parse --git-path index.lock`, never a literal
|
|
2758
|
+
* `.git/index.lock` join (#3588 row 8's class: a linked worktree's `.git` is
|
|
2759
|
+
* a FILE pointing at `<gitdir>/worktrees/<name>/`, so the literal path
|
|
2760
|
+
* cannot exist there while the real lock blocks the next commit). Best
|
|
2761
|
+
* effort: any resolution failure falls back to the literal join, and the
|
|
2762
|
+
* message already hedges with "may remain".
|
|
2763
|
+
*/
|
|
2764
|
+
function resolveIndexLockPath(cwd) {
|
|
2765
|
+
const result = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'index.lock'], { cwd });
|
|
2766
|
+
if (result.exitCode !== 0)
|
|
2767
|
+
return node_path_1.default.join(cwd, '.git', 'index.lock');
|
|
2768
|
+
const raw = result.stdout.trim();
|
|
2769
|
+
return raw ? (node_path_1.default.isAbsolute(raw) ? raw : node_path_1.default.join(cwd, raw)) : node_path_1.default.join(cwd, '.git', 'index.lock');
|
|
2770
|
+
}
|
|
2771
|
+
/** #3886: shared timeout message shape for all three commit sites. */
|
|
2772
|
+
function commitTimeoutMessage(cwd, stderr, stdout) {
|
|
2773
|
+
return (`git commit timed out after ${COMMIT_TIMEOUT_MS / 1000}s (killed mid-hook; a stale lock may remain at ` +
|
|
2774
|
+
`${resolveIndexLockPath(cwd)} — remove it if no git process is running). ` +
|
|
2775
|
+
`Partial stderr: ${stderr || stdout || '(none)'}`);
|
|
2776
|
+
}
|
|
2777
|
+
/**
|
|
2778
|
+
* Resolve the real git hooks directory for `cwd` via `git rev-parse
|
|
2779
|
+
* --git-path hooks` — never a literal `.git/hooks` join (#3588 row 8: a
|
|
2780
|
+
* linked worktree or submodule's `.git` is a FILE pointing elsewhere, and
|
|
2781
|
+
* this is the one git-native call that already resolves that correctly).
|
|
2782
|
+
*/
|
|
2783
|
+
function resolveCommitDocsGuardHooksDir(cwd) {
|
|
2784
|
+
const gitDirResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-dir'], { cwd });
|
|
2785
|
+
if (gitDirResult.exitCode !== 0) {
|
|
2786
|
+
return { ok: false, reason: 'not_a_git_repo' };
|
|
2787
|
+
}
|
|
2788
|
+
const hooksPathResult = (0, shell_command_projection_cjs_1.execGit)(['rev-parse', '--git-path', 'hooks'], { cwd });
|
|
2789
|
+
if (hooksPathResult.exitCode !== 0) {
|
|
2790
|
+
return { ok: false, reason: 'not_a_git_repo' };
|
|
2791
|
+
}
|
|
2792
|
+
const hooksDirRaw = hooksPathResult.stdout.trim();
|
|
2793
|
+
const hooksDir = node_path_1.default.isAbsolute(hooksDirRaw) ? hooksDirRaw : node_path_1.default.join(cwd, hooksDirRaw);
|
|
2794
|
+
return { ok: true, dir: hooksDir };
|
|
2795
|
+
}
|
|
2796
|
+
/** Marker presence, not byte-equality (#3588 row B10). */
|
|
2797
|
+
function isCommitDocsGuardHook(content) {
|
|
2798
|
+
return content.includes(COMMIT_DOCS_GUARD_MARKER);
|
|
2799
|
+
}
|
|
2800
|
+
/**
|
|
2801
|
+
* `gsd-tools commit-docs-guard enable` — write `.git/hooks/pre-commit`.
|
|
2802
|
+
* Behavior table (40-design.md rows 1-3, 8-9): refuses to clobber a foreign
|
|
2803
|
+
* hook, refuses when `core.hooksPath` would make our own write inert, and is
|
|
2804
|
+
* idempotent when already enabled.
|
|
2805
|
+
*/
|
|
2806
|
+
function cmdCommitDocsGuardEnable(cwd, raw) {
|
|
2807
|
+
const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
|
|
2808
|
+
if (!hooksDir.ok || !hooksDir.dir) {
|
|
2809
|
+
error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
|
|
2810
|
+
return;
|
|
2811
|
+
}
|
|
2812
|
+
// core.hooksPath already set: our .git/hooks/pre-commit would be inert —
|
|
2813
|
+
// git would never invoke it. Silently writing an ignored file is worse
|
|
2814
|
+
// than refusing (design row 9).
|
|
2815
|
+
const hooksPathConfig = (0, shell_command_projection_cjs_1.execGit)(['config', '--get', 'core.hooksPath'], { cwd });
|
|
2816
|
+
if (hooksPathConfig.exitCode === 0 && hooksPathConfig.stdout.trim() !== '') {
|
|
2817
|
+
const configuredPath = hooksPathConfig.stdout.trim();
|
|
2818
|
+
error(`core.hooksPath is set to "${configuredPath}"; a hook written to ${node_path_1.default.join(hooksDir.dir, 'pre-commit')} ` +
|
|
2819
|
+
`would never run. Wire commit-docs-guard into "${configuredPath}" manually, or unset core.hooksPath first.`, ERROR_REASON.COMMIT_DOCS_GUARD_HOOKS_PATH_SET);
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
|
|
2823
|
+
const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
|
|
2824
|
+
if (existing !== null) {
|
|
2825
|
+
if (!isCommitDocsGuardHook(existing)) {
|
|
2826
|
+
error(`refusing to overwrite an existing pre-commit hook at ${hookPath} that GSD did not write. ` +
|
|
2827
|
+
`Remove or rename it, or wire commit-docs-guard into it by hand.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
|
|
2828
|
+
}
|
|
2829
|
+
// Already ours — idempotent no-op (row 3). Leave any user edits intact;
|
|
2830
|
+
// just make sure the executable bit survived.
|
|
2831
|
+
try {
|
|
2832
|
+
node_fs_1.default.chmodSync(hookPath, 0o755);
|
|
2833
|
+
}
|
|
2834
|
+
catch { /* best-effort */ }
|
|
2835
|
+
output({ enabled: true, action: 'already_enabled', path: hookPath }, raw, 'already_enabled');
|
|
2836
|
+
return;
|
|
2837
|
+
}
|
|
2838
|
+
(0, shell_command_projection_cjs_1.platformWriteSync)(hookPath, buildCommitDocsGuardHookScript());
|
|
2839
|
+
node_fs_1.default.chmodSync(hookPath, 0o755);
|
|
2840
|
+
output({ enabled: true, action: 'written', path: hookPath }, raw, 'enabled');
|
|
2841
|
+
}
|
|
2842
|
+
/**
|
|
2843
|
+
* `gsd-tools commit-docs-guard disable` — remove `.git/hooks/pre-commit`
|
|
2844
|
+
* ONLY when it is the hook we wrote (marker presence). Never deletes a
|
|
2845
|
+
* foreign hook (design row 5); a missing hook is a no-op success, not an
|
|
2846
|
+
* error (row 6).
|
|
2847
|
+
*/
|
|
2848
|
+
function cmdCommitDocsGuardDisable(cwd, raw) {
|
|
2849
|
+
const hooksDir = resolveCommitDocsGuardHooksDir(cwd);
|
|
2850
|
+
if (!hooksDir.ok || !hooksDir.dir) {
|
|
2851
|
+
error('not a git repository (or any of the parent directories)', ERROR_REASON.COMMIT_DOCS_GUARD_NOT_A_REPO);
|
|
2852
|
+
return;
|
|
2853
|
+
}
|
|
2854
|
+
const hookPath = node_path_1.default.join(hooksDir.dir, 'pre-commit');
|
|
2855
|
+
const existing = (0, shell_command_projection_cjs_1.platformReadSync)(hookPath);
|
|
2856
|
+
if (existing === null) {
|
|
2857
|
+
output({ disabled: true, action: 'noop', path: hookPath }, raw, 'noop');
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
if (!isCommitDocsGuardHook(existing)) {
|
|
2861
|
+
error(`refusing to remove the pre-commit hook at ${hookPath}: it does not carry the ` +
|
|
2862
|
+
`${COMMIT_DOCS_GUARD_MARKER} marker, so GSD did not write it.`, ERROR_REASON.COMMIT_DOCS_GUARD_FOREIGN_HOOK);
|
|
2863
|
+
}
|
|
2864
|
+
node_fs_1.default.unlinkSync(hookPath);
|
|
2865
|
+
output({ disabled: true, action: 'removed', path: hookPath }, raw, 'disabled');
|
|
2866
|
+
}
|
|
1811
2867
|
module.exports = {
|
|
1812
2868
|
groupFilesBySubrepo,
|
|
1813
2869
|
determinePhaseStatus,
|
|
@@ -1824,6 +2880,10 @@ module.exports = {
|
|
|
1824
2880
|
cmdResolveGranularity,
|
|
1825
2881
|
cmdResolveExecution,
|
|
1826
2882
|
cmdEffortSync,
|
|
2883
|
+
detectPhaseNumberFromFiles,
|
|
2884
|
+
resolvePhaseCommitDocsOverride,
|
|
2885
|
+
resolveCommitDocsPolicy,
|
|
2886
|
+
COMMIT_DOCS_SKIP_REASON,
|
|
1827
2887
|
cmdCommit,
|
|
1828
2888
|
cmdCommitToSubrepo,
|
|
1829
2889
|
cmdPrSubrepo,
|
|
@@ -1835,5 +2895,9 @@ module.exports = {
|
|
|
1835
2895
|
cmdScaffold,
|
|
1836
2896
|
cmdStats,
|
|
1837
2897
|
cmdCheckCommit,
|
|
2898
|
+
COMMIT_DOCS_GUARD_MARKER,
|
|
2899
|
+
buildCommitDocsGuardHookScript,
|
|
2900
|
+
cmdCommitDocsGuardEnable,
|
|
2901
|
+
cmdCommitDocsGuardDisable,
|
|
1838
2902
|
_wsParseRetryAfter,
|
|
1839
2903
|
};
|