@opengsd/gsd-core 1.14.0 → 1.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.ja-JP.md +3 -3
- package/README.ko-KR.md +3 -3
- package/README.pt-BR.md +3 -3
- package/README.zh-CN.md +3 -3
- package/agents/gsd-code-fixer.compact.md +7 -6
- package/agents/gsd-code-fixer.md +9 -8
- package/agents/gsd-debug-session-manager.compact.md +17 -2
- package/agents/gsd-debug-session-manager.md +17 -2
- package/agents/gsd-debugger.md +2 -2
- package/agents/gsd-eval-auditor.compact.md +1 -1
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +13 -8
- package/agents/gsd-intel-updater.compact.md +1 -1
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-phase-researcher.md +19 -11
- package/agents/gsd-plan-checker.md +8 -7
- package/agents/gsd-planner.md +12 -8
- package/agents/gsd-project-researcher.compact.md +1 -1
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.compact.md +1 -1
- package/agents/gsd-research-synthesizer.md +1 -1
- package/agents/gsd-ui-auditor.md +155 -17
- package/agents/gsd-ui-researcher.compact.md +1 -1
- package/agents/gsd-ui-researcher.md +1 -1
- package/agents/gsd-verifier.md +10 -9
- package/bin/install.js +642 -95
- package/commands/gsd/autonomous.md +2 -2
- package/commands/gsd/capture.md +1 -1
- package/commands/gsd/mempalace-capture.md +7 -3
- package/commands/gsd/plan-review-convergence.md +6 -6
- package/commands/gsd/progress.md +1 -1
- package/commands/gsd/quick-batch.md +1 -1
- package/commands/gsd/review.md +2 -3
- package/gsd-core/bin/gsd-tools.cjs +335 -22
- package/gsd-core/bin/lib/adr-parser.cjs +3 -1
- package/gsd-core/bin/lib/audit.cjs +81 -13
- package/gsd-core/bin/lib/capability-registry.cjs +82 -187
- package/gsd-core/bin/lib/capability-validator.cjs +0 -1
- package/gsd-core/bin/lib/check-command-router.cjs +101 -14
- package/gsd-core/bin/lib/codex-agent-toml.cjs +21 -25
- package/gsd-core/bin/lib/commands.cjs +175 -42
- package/gsd-core/bin/lib/config-loader.cjs +65 -4
- package/gsd-core/bin/lib/config.cjs +33 -7
- package/gsd-core/bin/lib/decisions.cjs +30 -14
- package/gsd-core/bin/lib/frontmatter.cjs +13 -0
- package/gsd-core/bin/lib/graphify.cjs +10 -2
- package/gsd-core/bin/lib/host-runtime-detection.cjs +9 -0
- package/gsd-core/bin/lib/init.cjs +207 -41
- package/gsd-core/bin/lib/install-engine.cjs +13 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +8 -1
- package/gsd-core/bin/lib/milestone.cjs +18 -5
- package/gsd-core/bin/lib/model-resolver.cjs +159 -50
- package/gsd-core/bin/lib/phase-command-router.cjs +9 -1
- package/gsd-core/bin/lib/phase-id-card.cjs +32 -0
- package/gsd-core/bin/lib/phase-id-display.cjs +78 -0
- package/gsd-core/bin/lib/phase-id.cjs +109 -7
- package/gsd-core/bin/lib/phase-locator.cjs +29 -10
- package/gsd-core/bin/lib/phase.cjs +227 -26
- package/gsd-core/bin/lib/plan-document.cjs +49 -1
- package/gsd-core/bin/lib/planning-document.cjs +459 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +18 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +8 -3
- package/gsd-core/bin/lib/pr-branch-patterns.cjs +57 -0
- package/gsd-core/bin/lib/probe-core.cjs +7 -1
- package/gsd-core/bin/lib/project-root.cjs +41 -2
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +10 -30
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +2 -2
- package/gsd-core/bin/lib/roadmap-command-router.cjs +12 -4
- package/gsd-core/bin/lib/roadmap-parser.cjs +163 -3
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +1539 -13
- package/gsd-core/bin/lib/roadmap.cjs +251 -31
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +283 -31
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +3 -1
- package/gsd-core/bin/lib/runtime-homes.cjs +4 -0
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -33
- package/gsd-core/bin/lib/runtime-name-policy.cjs +111 -1
- package/gsd-core/bin/lib/shell-command-projection.cjs +10 -6
- package/gsd-core/bin/lib/state-transition.cjs +39 -2
- package/gsd-core/bin/lib/state.cjs +42 -0
- package/gsd-core/bin/lib/surface.cjs +17 -1
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +78 -5
- package/gsd-core/bin/lib/uat-predicate.cjs +47 -4
- package/gsd-core/bin/lib/uat.cjs +8 -0
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +15 -2
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +100 -9
- package/gsd-core/bin/lib/undo-commit-selection.cjs +131 -0
- package/gsd-core/bin/lib/verification.cjs +268 -15
- package/gsd-core/bin/lib/verify-command-grounding.cjs +46 -2
- package/gsd-core/bin/lib/verify.cjs +132 -25
- package/gsd-core/bin/lib/worktree-base-ref.cjs +482 -73
- package/gsd-core/bin/lib/worktree-safety.cjs +784 -51
- package/gsd-core/bin/shared/config-defaults.manifest.json +3 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -0
- package/gsd-core/references/checkpoints.md +5 -3
- package/gsd-core/references/edge-probe-fixtures/01-round-half-even/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/02-merge-intervals/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/03-truncate-graphemes/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/04-money-rounding/expected-coverage.json +28 -3
- package/gsd-core/references/edge-probe-fixtures/05-list-dedupe/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe-fixtures/06-resolved-mixed/expected-coverage.json +37 -4
- package/gsd-core/references/edge-probe.md +195 -21
- package/gsd-core/references/execute-phase-between-wave-reset.md +7 -6
- package/gsd-core/references/execute-phase-wave-guard.md +22 -11
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/model-profiles.md +1 -1
- package/gsd-core/references/phase-argument-parsing.md +9 -7
- package/gsd-core/references/phase-id-convention.md +28 -0
- package/gsd-core/references/planner-gap-closure.md +2 -0
- package/gsd-core/references/planner-load-graph-context.md +24 -13
- package/gsd-core/references/planner-verify-command-grounding.md +14 -0
- package/gsd-core/references/planning-config.md +11 -2
- package/gsd-core/references/tdd.md +27 -4
- package/gsd-core/references/ui-consideration-probe.md +10 -5
- package/gsd-core/references/verify-command-path-resolvability.md +10 -2
- package/gsd-core/references/worktree-path-safety.md +321 -0
- package/gsd-core/templates/verification-report.md +1 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +1 -1
- package/gsd-core/workflows/add-tests.md +2 -2
- package/gsd-core/workflows/add-todo.md +3 -3
- package/gsd-core/workflows/ai-integration-phase.md +11 -3
- package/gsd-core/workflows/audit-fix.md +1 -1
- package/gsd-core/workflows/audit-milestone.md +1 -1
- package/gsd-core/workflows/audit-uat.md +1 -1
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +9 -18
- package/gsd-core/workflows/autonomous.md +16 -6
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +4 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +108 -22
- package/gsd-core/workflows/code-review.md +63 -46
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +1 -1
- package/gsd-core/workflows/complete-milestone.md +2 -2
- package/gsd-core/workflows/debug.md +3 -3
- package/gsd-core/workflows/diagnose-issues.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions.md +1 -1
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +2 -2
- package/gsd-core/workflows/docs-update.md +3 -3
- package/gsd-core/workflows/edit-phase.md +1 -1
- package/gsd-core/workflows/eval-review.md +10 -3
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/code-review-disposition.md +1017 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +3 -3
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +37 -3
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +46 -9
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/ready-wave-gate.md +37 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/stale-reverification.md +24 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/threat-id-gate.md +28 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/worktree-base-check.md +25 -0
- package/gsd-core/workflows/execute-phase.md +36 -26
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +3 -3
- package/gsd-core/workflows/extract-learnings.md +2 -1
- package/gsd-core/workflows/fast.md +1 -1
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +2 -2
- package/gsd-core/workflows/help/modes/full.compact.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +5 -5
- package/gsd-core/workflows/help/modes/topic.md +15 -5
- package/gsd-core/workflows/import.md +2 -2
- package/gsd-core/workflows/inbox.md +2 -2
- package/gsd-core/workflows/ingest-docs.md +3 -3
- package/gsd-core/workflows/insert-phase.md +1 -1
- package/gsd-core/workflows/list-seeds.md +1 -1
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +2 -2
- package/gsd-core/workflows/map-codebase.md +2 -2
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +2 -2
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +3 -3
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +1 -1
- package/gsd-core/workflows/new-project.md +7 -7
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +1 -1
- package/gsd-core/workflows/note.md +1 -1
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +17 -5
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +23 -5
- package/gsd-core/workflows/plan-phase.md +24 -7
- package/gsd-core/workflows/plan-review-convergence.md +21 -5
- package/gsd-core/workflows/plant-seed.md +62 -20
- package/gsd-core/workflows/pr-branch.md +113 -13
- package/gsd-core/workflows/profile-user.md +2 -2
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +25 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +1 -1
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/completion.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +1 -1
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +1 -1
- package/gsd-core/workflows/quick-batch.md +1 -1
- package/gsd-core/workflows/quick.md +21 -9
- package/gsd-core/workflows/reapply-patches.md +9 -3
- package/gsd-core/workflows/remove-phase.md +1 -1
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +1 -1
- package/gsd-core/workflows/review.md +31 -16
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -2
- package/gsd-core/workflows/settings-advanced.md +30 -10
- package/gsd-core/workflows/settings-integrations.md +2 -3
- package/gsd-core/workflows/settings.md +4 -4
- package/gsd-core/workflows/ship.md +3 -2
- package/gsd-core/workflows/sketch-wrap-up.md +1 -1
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +2 -2
- package/gsd-core/workflows/spec-phase.md +15 -5
- package/gsd-core/workflows/spike-wrap-up.md +1 -1
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +1 -1
- package/gsd-core/workflows/sync-skills.md +5 -5
- package/gsd-core/workflows/thread.md +1 -1
- package/gsd-core/workflows/transition.md +1 -1
- package/gsd-core/workflows/ui-phase.md +44 -8
- package/gsd-core/workflows/ui-review.md +18 -4
- package/gsd-core/workflows/ultraplan-phase.md +1 -1
- package/gsd-core/workflows/undo.md +339 -20
- package/gsd-core/workflows/update.md +7 -7
- package/gsd-core/workflows/validate-phase.md +3 -2
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +1 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +81 -16
- package/hooks/dist/gsd-agent-isolation-guard.js +24 -0
- package/hooks/dist/gsd-secret-read-guard.js +27 -1
- package/hooks/dist/gsd-statusline.js +70 -13
- package/hooks/dist/gsd-validate-commit.sh +63 -4
- package/hooks/gsd-agent-isolation-guard.js +24 -0
- package/hooks/gsd-secret-read-guard.js +27 -1
- package/hooks/gsd-statusline.js +70 -13
- package/hooks/gsd-validate-commit.sh +63 -4
- package/package.json +3 -2
- package/scripts/build-hooks.js +15 -6
- package/scripts/check-contract-drift.cjs +127 -11
- package/scripts/command-contract-helpers.cjs +3 -0
- package/scripts/docs-guard-registry.cjs +28 -0
- package/scripts/gen-loop-host-contract.cjs +69 -0
- package/scripts/lib/macos-conformance-tier.generated.cjs +14 -0
- package/scripts/lib/ndjson-reporter.cjs +3 -2
- package/scripts/lib/platform-conformance-tier.generated.cjs +11 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +28 -1
- package/scripts/lint-phase-arg-assignment.cjs +257 -0
- package/scripts/lint-phase-id-drift.cjs +290 -5
- package/scripts/lint-pr-branch-pattern-drift.cjs +148 -0
- package/scripts/lint-retired-runtime-name.cjs +619 -0
- package/scripts/lint-state-write-path-drift.cjs +93 -0
- package/scripts/lint-test-file-count.allowlist.json +28 -9
- package/scripts/lint-workflow-shellcheck-baseline.json +15 -0
- package/scripts/prompt-injection-scan.sh +4 -0
- package/scripts/release-tarball-smoke.cjs +194 -1
- package/skills/gsd-autonomous/SKILL.md +2 -2
- package/skills/gsd-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +7 -3
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -5
- package/skills/gsd-progress/SKILL.md +1 -1
- package/skills/gsd-quick-batch/SKILL.md +1 -1
- package/skills/gsd-review/SKILL.md +2 -3
- package/vscode/package.json +1 -1
|
@@ -18,8 +18,11 @@ exports.shortSha = shortSha;
|
|
|
18
18
|
exports.readBaseRefFromSettings = readBaseRefFromSettings;
|
|
19
19
|
exports.applyWorktreeBaseRef = applyWorktreeBaseRef;
|
|
20
20
|
exports.resolveEffectiveBaseRef = resolveEffectiveBaseRef;
|
|
21
|
+
exports.findWorktreeCreateHook = findWorktreeCreateHook;
|
|
21
22
|
exports.cmdWorktreeBaseCheck = cmdWorktreeBaseCheck;
|
|
22
23
|
exports.cmdWorktreeSetBaseRef = cmdWorktreeSetBaseRef;
|
|
24
|
+
exports.classifyGitHead = classifyGitHead;
|
|
25
|
+
exports.observeHarnessForkFromHead = observeHarnessForkFromHead;
|
|
23
26
|
exports.evaluateWorktreeBaseDegrade = evaluateWorktreeBaseDegrade;
|
|
24
27
|
const node_fs_1 = __importDefault(require("node:fs"));
|
|
25
28
|
const node_path_1 = __importDefault(require("node:path"));
|
|
@@ -93,14 +96,56 @@ function parseJsonc(text) {
|
|
|
93
96
|
}
|
|
94
97
|
}
|
|
95
98
|
// ─── Message constants (verbatim — downstream docs/tests depend on these) ─────
|
|
99
|
+
// The fork side of the comparison is either an inferred ref (`origin/HEAD`,
|
|
100
|
+
// `origin/next`, …) or — when the caller supplies `observedForkBase` — the
|
|
101
|
+
// literal label below, meaning "the base a worktree this host created was
|
|
102
|
+
// measured to have" (#4588). Messages read the label to phrase the remedy.
|
|
103
|
+
const FORK_REF_OBSERVED = 'observed';
|
|
104
|
+
function describeForkRef(forkRef) {
|
|
105
|
+
return forkRef === FORK_REF_OBSERVED ? 'the observed fork base' : String(forkRef);
|
|
106
|
+
}
|
|
107
|
+
// An observation is a fixed measurement of one past dispatch: pushing cannot change it,
|
|
108
|
+
// so the remedy for an observed mismatch is a fresh dispatch (a new observation), never
|
|
109
|
+
// "push until the observation matches". The inferred fork base (origin/HEAD) does move
|
|
110
|
+
// with a push, so that remedy stays for the inferred case.
|
|
96
111
|
function buildMsgDiverged(headSha, forkRef, forkSha) {
|
|
97
|
-
|
|
112
|
+
const fork = describeForkRef(forkRef);
|
|
113
|
+
const remedy = forkRef === FORK_REF_OBSERVED
|
|
114
|
+
? 'Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it'
|
|
115
|
+
: `Parallel worktrees return once HEAD is merged/pushed so ${fork} matches it`;
|
|
116
|
+
return `⚠ Worktree base mismatch: HEAD (${shortSha(headSha)}) differs from ${fork} (${shortSha(forkSha)}). Running this phase sequentially on the main working tree. ${remedy}, or set worktree.baseRef:"head" to fork worktrees from HEAD instead (honored by GSD-created worktrees and by the Claude Code harness; #683, #4588).`;
|
|
98
117
|
}
|
|
99
118
|
const MSG_UNKNOWN = `⚠ Cannot determine the worktree fork base (origin/HEAD unresolved). Running this phase sequentially on the main working tree to avoid a base mismatch. Parallel worktrees return once origin/HEAD resolves and matches HEAD. See #683, #3659.`;
|
|
100
|
-
|
|
101
|
-
|
|
119
|
+
// Mode-neutral on purpose: the observation can come from a harness-created OR a
|
|
120
|
+
// GSD-created worktree, and the message must not attribute the miss to "the harness"
|
|
121
|
+
// when GSD's own `git worktree add` was the creator (P4.6 review, 2026-09-14).
|
|
122
|
+
function buildMsgBaserefHeadIgnored(headSha, forkSha) {
|
|
123
|
+
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but a worktree created for this dispatch was observed to fork from ${shortSha(forkSha)} while HEAD is ${shortSha(headSha)} — the worktree was not forked from HEAD despite the setting. Running this phase sequentially on the main working tree. Parallel worktrees return once a fresh dispatch is observed to fork from HEAD, or once HEAD is merged/pushed so the default fork base matches it. See #3659, #4588.`;
|
|
124
|
+
}
|
|
125
|
+
// Names the hook and its file, never "the harness": the user configured the hook, so the
|
|
126
|
+
// actionable remedy is theirs. An unparseable layer is phrased as "cannot be ruled out",
|
|
127
|
+
// because the check does not know a hook is there — it only cannot prove one is not.
|
|
128
|
+
// It deliberately promises nothing about pushing: neither HEAD nor the inferred fork base
|
|
129
|
+
// says where a hook forks, so the only measured way back to a trusted verdict is an
|
|
130
|
+
// observation (--observed-fork-base) or removing the cause (#4588 round review).
|
|
131
|
+
function buildMsgBaserefHeadHookBypass(headSha, forkRef, forkSha, finding) {
|
|
132
|
+
const fork = describeForkRef(forkRef);
|
|
133
|
+
const cause = finding.kind === 'hook'
|
|
134
|
+
? `a Claude Code WorktreeCreate hook is configured in ${finding.file}`
|
|
135
|
+
: `${finding.file} could not be parsed, so a Claude Code WorktreeCreate hook in it cannot be ruled out`;
|
|
136
|
+
const remove = finding.kind === 'hook'
|
|
137
|
+
? 'remove the hook'
|
|
138
|
+
: `fix ${finding.file} so it parses`;
|
|
139
|
+
return `⚠ Worktree base mismatch: worktree.baseRef:"head" is set, but ${cause}. A WorktreeCreate hook creates Claude Code's agent worktrees itself and Claude Code does not apply worktree.baseRef to them, so the setting is not trusted. Without it the check can only compare HEAD (${shortSha(headSha)}) against ${fork} (${shortSha(forkSha)}), and they differ. Running this phase sequentially on the main working tree. Neither ref says where the hook forks: for a measured verdict, pass the commit a hook-created worktree starts at as --observed-fork-base, or ${remove}. See #4588.`;
|
|
102
140
|
}
|
|
103
|
-
|
|
141
|
+
// A commit sha as `git rev-parse HEAD` prints it: 40 hex (SHA-1) or 64 hex (SHA-256).
|
|
142
|
+
// Abbreviated shas are refused rather than prefix-matched — the comparison below is
|
|
143
|
+
// exact, and an abbreviation that can never equal the full HEAD would silently always
|
|
144
|
+
// degrade (P4.6 review, 2026-09-14). Case is folded because the comparison is exact.
|
|
145
|
+
const FULL_SHA_RE = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
|
|
146
|
+
const MSG_OBSERVED_FORK_BASE_INVALID = 'observedForkBase must be a full 40- or 64-hex commit sha (git rev-parse HEAD inside the worktree, before any commit)';
|
|
147
|
+
const MSG_HEAD_UNRESOLVABLE = `⚠ Cannot determine the worktree base (git rev-parse HEAD did not return a definitive answer). Running this phase sequentially on the main working tree to avoid an unverified base mismatch. Retry; if it persists, check for a stalled filesystem mount or a stale git index lock (.git/index.lock). See #683, #3050.`;
|
|
148
|
+
const MSG_NO_GIT_REPOSITORY = `⚠ No worktree base exists here (git resolved no HEAD — the root is not a git repository, or the repository has no commits), so a harness worktree cannot be created. Running this dispatch sequentially on the main working tree instead — no isolation flag is required. See #4734.`;
|
|
104
149
|
/**
|
|
105
150
|
* Returns true when an execGit result indicates the subprocess was killed by
|
|
106
151
|
* a timeout. A timeout means the command genuinely could not complete — it
|
|
@@ -221,6 +266,69 @@ function resolveEffectiveBaseRef(claudeDir, deps, userClaudeDir) {
|
|
|
221
266
|
}
|
|
222
267
|
return null;
|
|
223
268
|
}
|
|
269
|
+
/**
|
|
270
|
+
* Looks for a Claude Code `WorktreeCreate` hook in the settings layers that
|
|
271
|
+
* resolveEffectiveBaseRef reads (#4588). Such a hook replaces the harness's own worktree
|
|
272
|
+
* creation: the agent worktree is whatever directory the hook emits, and Claude Code does
|
|
273
|
+
* not consult `worktree.baseRef` on that path. So on a host that configures one, `"head"`
|
|
274
|
+
* says nothing about where a harness-created worktree forks from.
|
|
275
|
+
*
|
|
276
|
+
* Claude Code merges hooks across layers, so every layer is checked — not only the one
|
|
277
|
+
* that supplied `baseRef`. Layers, in the same order and with the same user/global
|
|
278
|
+
* de-duplication as resolveEffectiveBaseRef:
|
|
279
|
+
* 1. <claudeDir>/settings.local.json
|
|
280
|
+
* 2. <claudeDir>/settings.json
|
|
281
|
+
* 3. <userClaudeDir>/settings.json (only when provided and a different directory)
|
|
282
|
+
*
|
|
283
|
+
* Returns the first finding in that order, or null:
|
|
284
|
+
* - kind 'hook' — `hooks.WorktreeCreate` is present and not an empty list.
|
|
285
|
+
* - kind 'unparseable' — the file exists but is not valid JSON/JSONC. Fails closed: a hook
|
|
286
|
+
* in it cannot be ruled out, and a false degrade costs a sequential
|
|
287
|
+
* wave where false trust costs every executor halting at exit 42.
|
|
288
|
+
* A layer deps.readFile reports as null (absent or unreadable) is skipped, exactly as in
|
|
289
|
+
* resolveEffectiveBaseRef, and so is a whitespace-only file, which cannot declare a hook.
|
|
290
|
+
*
|
|
291
|
+
* Settings files are the only hook sources readable from here. Claude Code also takes
|
|
292
|
+
* hooks from managed policy settings, a --settings file, plugins, agent frontmatter and
|
|
293
|
+
* SDK registrations; those stay invisible to this check, and the spawn-time exit-42 guard
|
|
294
|
+
* remains the backstop for them.
|
|
295
|
+
*/
|
|
296
|
+
function findWorktreeCreateHook(claudeDir, deps, userClaudeDir) {
|
|
297
|
+
const readFile = deps?.readFile ?? ((p) => {
|
|
298
|
+
try {
|
|
299
|
+
return node_fs_1.default.readFileSync(p, 'utf8');
|
|
300
|
+
}
|
|
301
|
+
catch {
|
|
302
|
+
return null;
|
|
303
|
+
}
|
|
304
|
+
});
|
|
305
|
+
const layers = [node_path_1.default.join(claudeDir, 'settings.local.json'), node_path_1.default.join(claudeDir, 'settings.json')];
|
|
306
|
+
if (userClaudeDir && node_path_1.default.resolve(userClaudeDir) !== node_path_1.default.resolve(claudeDir)) {
|
|
307
|
+
layers.push(node_path_1.default.join(userClaudeDir, 'settings.json'));
|
|
308
|
+
}
|
|
309
|
+
for (const file of layers) {
|
|
310
|
+
const contents = readFile(file);
|
|
311
|
+
if (contents == null || contents.trim() === '')
|
|
312
|
+
continue;
|
|
313
|
+
let parsed;
|
|
314
|
+
try {
|
|
315
|
+
parsed = parseJsonc(contents);
|
|
316
|
+
}
|
|
317
|
+
catch {
|
|
318
|
+
return { file, kind: 'unparseable' };
|
|
319
|
+
}
|
|
320
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
321
|
+
continue;
|
|
322
|
+
const hooks = parsed.hooks;
|
|
323
|
+
if (hooks === null || typeof hooks !== 'object' || Array.isArray(hooks))
|
|
324
|
+
continue;
|
|
325
|
+
const entry = hooks.WorktreeCreate;
|
|
326
|
+
if (entry == null || (Array.isArray(entry) && entry.length === 0))
|
|
327
|
+
continue;
|
|
328
|
+
return { file, kind: 'hook' };
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
224
332
|
/**
|
|
225
333
|
* CLI command: check current worktree base-ref degradation status.
|
|
226
334
|
*
|
|
@@ -245,16 +353,38 @@ function cmdWorktreeBaseCheck(cwd, args, deps) {
|
|
|
245
353
|
}
|
|
246
354
|
isolationMode = value;
|
|
247
355
|
}
|
|
356
|
+
// --observed-fork-base <sha> threads a measured fork base through to the
|
|
357
|
+
// evaluation (#4588): what `git rev-parse HEAD` returned inside a worktree
|
|
358
|
+
// this host created, before any commit. Same fail-closed shape as --mode —
|
|
359
|
+
// a malformed or missing value throws rather than silently falling back to
|
|
360
|
+
// the inference the flag exists to replace.
|
|
361
|
+
let observedForkBase = null;
|
|
362
|
+
const observedIdx = args.indexOf('--observed-fork-base');
|
|
363
|
+
if (observedIdx !== -1) {
|
|
364
|
+
const value = args[observedIdx + 1];
|
|
365
|
+
if (typeof value !== 'string' || !FULL_SHA_RE.test(value.trim().toLowerCase())) {
|
|
366
|
+
throw new Error(`worktree base-check: --observed-fork-base: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${JSON.stringify(value ?? null)}`);
|
|
367
|
+
}
|
|
368
|
+
observedForkBase = value.trim().toLowerCase();
|
|
369
|
+
}
|
|
248
370
|
const claudeDir = node_path_1.default.join(cwd, '.claude');
|
|
249
371
|
const userClaudeDir = Object.prototype.hasOwnProperty.call(deps ?? {}, 'userClaudeDir')
|
|
250
372
|
? deps.userClaudeDir
|
|
251
373
|
: (0, runtime_homes_cjs_1.getGlobalConfigDir)('claude');
|
|
252
374
|
const effectiveBaseRef = resolveEffectiveBaseRef(claudeDir, deps?.readFile ? { readFile: deps.readFile } : undefined, userClaudeDir);
|
|
375
|
+
// The WorktreeCreate-hook interlock (#4588) only matters where the evaluation would
|
|
376
|
+
// otherwise trust "head" without comparing: harness-created worktrees and no
|
|
377
|
+
// observation. Skip the settings reads everywhere else.
|
|
378
|
+
const worktreeCreateHook = effectiveBaseRef === 'head' && isolationMode === 'harness-worktree' && observedForkBase === null
|
|
379
|
+
? findWorktreeCreateHook(claudeDir, deps?.readFile ? { readFile: deps.readFile } : undefined, userClaudeDir)
|
|
380
|
+
: null;
|
|
253
381
|
const result = evaluateWorktreeBaseDegrade({
|
|
254
382
|
cwd,
|
|
255
383
|
effectiveBaseRef,
|
|
256
384
|
execGit: deps?.execGit,
|
|
257
385
|
isolationMode,
|
|
386
|
+
observedForkBase,
|
|
387
|
+
worktreeCreateHook,
|
|
258
388
|
});
|
|
259
389
|
// Default emit goes through fs.writeSync(1, …), NOT process.stdout.write:
|
|
260
390
|
// the CLI's --pick capture intercepts writeSync, and command substitution
|
|
@@ -342,10 +472,181 @@ function cmdWorktreeSetBaseRef(cwd, _args, deps) {
|
|
|
342
472
|
write(JSON.stringify(output, null, 2) + '\n');
|
|
343
473
|
return output;
|
|
344
474
|
}
|
|
475
|
+
/**
|
|
476
|
+
* One classification of `git rev-parse HEAD`, shared by every surface that
|
|
477
|
+
* must tell git's definitive "no repository here" answer apart from ambiguous
|
|
478
|
+
* or failed resolutions (#4734 — before this, evaluateWorktreeBaseDegrade was
|
|
479
|
+
* the only owner and the isolation guard's fallback simply had no check).
|
|
480
|
+
* Exit 128 covers BOTH "not a git repository" and "a repository with no
|
|
481
|
+
* commits" (`ambiguous argument 'HEAD'`); stderr text is localized, so the
|
|
482
|
+
* exit code is the stable contract — and in neither case can a harness
|
|
483
|
+
* worktree be created, which is the only question every consumer asks.
|
|
484
|
+
*
|
|
485
|
+
* - `present`: exit 0 with a non-empty sha (`headSha` carries it, trimmed).
|
|
486
|
+
* - `definitive-absence`: exit 128 — git completed and definitively answered.
|
|
487
|
+
* - `ambiguous-absence`: exit 0 with empty stdout — git completed without a
|
|
488
|
+
* definitive answer (#3057 B8).
|
|
489
|
+
* - `indeterminate`: timeout or any other non-success — never evidence of
|
|
490
|
+
* anything; consumers must fail closed (#3050).
|
|
491
|
+
*/
|
|
492
|
+
function classifyGitHead(deps) {
|
|
493
|
+
const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
494
|
+
const cwdOpts = deps?.cwd ? { cwd: deps.cwd } : {};
|
|
495
|
+
const headResult = execGit(['rev-parse', 'HEAD'], cwdOpts);
|
|
496
|
+
if (isExecGitTimeout(headResult)) {
|
|
497
|
+
return { status: 'indeterminate', headSha: null };
|
|
498
|
+
}
|
|
499
|
+
const headStdout = headResult.stdout ? headResult.stdout.trim() : '';
|
|
500
|
+
if (headResult.exitCode === 128) {
|
|
501
|
+
return { status: 'definitive-absence', headSha: null };
|
|
502
|
+
}
|
|
503
|
+
if (headResult.exitCode === 0 && !headStdout) {
|
|
504
|
+
return { status: 'ambiguous-absence', headSha: null };
|
|
505
|
+
}
|
|
506
|
+
if (headResult.exitCode !== 0) {
|
|
507
|
+
return { status: 'indeterminate', headSha: null };
|
|
508
|
+
}
|
|
509
|
+
return { status: 'present', headSha: headStdout };
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* #4588 (decision A2) — observe, from documented git metadata, whether the
|
|
513
|
+
* harness forks its worktrees from the orchestrator HEAD.
|
|
514
|
+
*
|
|
515
|
+
* Substrate: the harness's own prior worktrees. TWO LEGS answer the question —
|
|
516
|
+
* a cache read, then a live probe on a miss — and they are not the same kind
|
|
517
|
+
* of evidence.
|
|
518
|
+
*
|
|
519
|
+
* The LIVE PROBE looks for a linked worktree under
|
|
520
|
+
* `<repo>/.claude/worktrees/agent-*` that is still CLEAN (no tracked
|
|
521
|
+
* modifications; untracked review notes are fine) and whose HEAD equals the
|
|
522
|
+
* current orchestrator HEAD. That is POSITIVE evidence of fork-from-HEAD: an
|
|
523
|
+
* origin/HEAD fork would ordinarily have landed on an older commit once the
|
|
524
|
+
* orchestrator advanced. It is the only configuration that counts — every
|
|
525
|
+
* other observation (dirty worktree, different HEAD, no worktrees, git
|
|
526
|
+
* failure) is inconclusive and fails closed to the caller's existing flow.
|
|
527
|
+
*
|
|
528
|
+
* The CACHE leg looks at no worktree at all. It replays this function's OWN
|
|
529
|
+
* earlier conclusion for the same orchestrator HEAD, so a hit inherits whatever
|
|
530
|
+
* that earlier probe was worth and re-examines nothing — not the worktree, not
|
|
531
|
+
* even whether one still exists.
|
|
532
|
+
*
|
|
533
|
+
* EVIDENCE, NOT PROOF, AND THE LIVE PROBE FAILS IN TWO INDEPENDENT WAYS
|
|
534
|
+
* (#4921). What it reads is a worktree's PRESENT state — is it clean, where is
|
|
535
|
+
* its HEAD — and none of `worktree list --porcelain`, `status --porcelain` or
|
|
536
|
+
* `rev-parse HEAD` carries provenance.
|
|
537
|
+
*
|
|
538
|
+
* 1. It cannot say WHICH CREATOR. Where the harness is the only creator that
|
|
539
|
+
* is a distinction without a difference; where a `WorktreeCreate` hook is
|
|
540
|
+
* configured it is not, because a worktree the plain harness left behind
|
|
541
|
+
* BEFORE the hook existed — with HEAD unmoved since — is
|
|
542
|
+
* indistinguishable from one the hook made.
|
|
543
|
+
* 2. It cannot say WHAT IT WAS FORKED FROM either. A worktree created from an
|
|
544
|
+
* older base and since `git checkout --detach`ed onto the orchestrator
|
|
545
|
+
* HEAD is clean, sits at HEAD, and satisfies the probe identically. Note
|
|
546
|
+
* the shape of this one precisely: the worktree's own reflog DOES retain
|
|
547
|
+
* that original checkout, so the information is not lost — the probe just
|
|
548
|
+
* does not consult it, and #4868 did not design it to. Inherited from
|
|
549
|
+
* #4868 rather than introduced here, and accepted there for the no-hook
|
|
550
|
+
* case; stated so the strength of the signal is not overread.
|
|
551
|
+
*
|
|
552
|
+
* Both are reasons the observation is inadmissible once a hook is in the
|
|
553
|
+
* creation path, and the cache leg is a third, since it re-examines nothing at
|
|
554
|
+
* all. So the caller withholds it entirely under that interlock rather than
|
|
555
|
+
* re-keying it; see `hookWithheldHeadTrust` in evaluateWorktreeBaseDegrade.
|
|
556
|
+
*
|
|
557
|
+
* The verdict is cached at `<cwd>/.gsd/harness-fork-probe.json` keyed by the
|
|
558
|
+
* orchestrator HEAD (the decision's keying): trusted only while the
|
|
559
|
+
* orchestrator HEAD is unchanged; any HEAD move re-probes. Cache I/O failures
|
|
560
|
+
* are swallowed — the probe re-runs instead (#3659 fail-closed posture).
|
|
561
|
+
*/
|
|
562
|
+
function observeHarnessForkFromHead(deps) {
|
|
563
|
+
const execGit = deps.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
564
|
+
const cwd = deps.cwd ?? '.';
|
|
565
|
+
const cacheFile = node_path_1.default.join(cwd, '.gsd', 'harness-fork-probe.json');
|
|
566
|
+
const stateRead = deps.stateRead ?? ((file) => {
|
|
567
|
+
try {
|
|
568
|
+
return node_fs_1.default.readFileSync(file, 'utf8');
|
|
569
|
+
}
|
|
570
|
+
catch {
|
|
571
|
+
return null;
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
const stateWrite = deps.stateWrite ?? ((file, content) => {
|
|
575
|
+
try {
|
|
576
|
+
node_fs_1.default.mkdirSync(node_path_1.default.dirname(file), { recursive: true });
|
|
577
|
+
node_fs_1.default.writeFileSync(file, content, 'utf8');
|
|
578
|
+
}
|
|
579
|
+
catch {
|
|
580
|
+
// A cache write must never break the check that produced the verdict.
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
// 1. Cache — trusted only while the orchestrator HEAD is unchanged.
|
|
584
|
+
try {
|
|
585
|
+
const raw = stateRead(cacheFile);
|
|
586
|
+
if (raw) {
|
|
587
|
+
const cached = JSON.parse(raw);
|
|
588
|
+
if (cached.headSha === deps.headSha && cached.verdict === 'fork-from-head-confirmed') {
|
|
589
|
+
return { confirmed: true, source: 'cache', worktreePath: cached.worktreePath ?? null, worktreeHead: cached.worktreeHead ?? null };
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
catch {
|
|
594
|
+
// Corrupt cache — re-probe (fail open INTO the probe, which itself fails closed).
|
|
595
|
+
}
|
|
596
|
+
// 2. Probe: list linked worktrees, keep the harness's own, require a clean
|
|
597
|
+
// one whose HEAD equals the orchestrator HEAD. ANY failure — timeout,
|
|
598
|
+
// non-zero exit, or a throwing execGit — is an inconclusive observation,
|
|
599
|
+
// never a crash: the caller falls through to its existing flow (#4588).
|
|
600
|
+
try {
|
|
601
|
+
const list = execGit(['worktree', 'list', '--porcelain'], { cwd });
|
|
602
|
+
if (isExecGitTimeout(list) || list.exitCode !== 0) {
|
|
603
|
+
return { confirmed: false, source: 'none', worktreePath: null, worktreeHead: null };
|
|
604
|
+
}
|
|
605
|
+
const candidates = String(list.stdout || '')
|
|
606
|
+
.split('\n\n')
|
|
607
|
+
.map((block) => block.split('\n').find((l) => l.startsWith('worktree '))?.slice('worktree '.length).trim())
|
|
608
|
+
.filter((p) => !!p && p.includes('/.claude/worktrees/agent-'));
|
|
609
|
+
for (const wtPath of candidates) {
|
|
610
|
+
const status = execGit(['-C', wtPath, 'status', '--porcelain'], { cwd });
|
|
611
|
+
if (isExecGitTimeout(status) || status.exitCode !== 0)
|
|
612
|
+
continue;
|
|
613
|
+
const trackedDirty = String(status.stdout || '')
|
|
614
|
+
.split('\n')
|
|
615
|
+
.some((l) => l.trim() !== '' && !l.startsWith('?? '));
|
|
616
|
+
if (trackedDirty)
|
|
617
|
+
continue;
|
|
618
|
+
const wtHeadResult = execGit(['-C', wtPath, 'rev-parse', 'HEAD'], { cwd });
|
|
619
|
+
if (isExecGitTimeout(wtHeadResult) || wtHeadResult.exitCode !== 0)
|
|
620
|
+
continue;
|
|
621
|
+
const wtHead = wtHeadResult.stdout ? wtHeadResult.stdout.trim() : '';
|
|
622
|
+
if (wtHead && wtHead === deps.headSha) {
|
|
623
|
+
try {
|
|
624
|
+
stateWrite(cacheFile, `${JSON.stringify({
|
|
625
|
+
headSha: deps.headSha,
|
|
626
|
+
worktreePath: wtPath,
|
|
627
|
+
worktreeHead: wtHead,
|
|
628
|
+
verdict: 'fork-from-head-confirmed',
|
|
629
|
+
probedAt: new Date().toISOString(),
|
|
630
|
+
})}\n`);
|
|
631
|
+
}
|
|
632
|
+
catch {
|
|
633
|
+
// Cache write is best-effort; the verdict stands for this dispatch.
|
|
634
|
+
}
|
|
635
|
+
return { confirmed: true, source: 'probe', worktreePath: wtPath, worktreeHead: wtHead };
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// A throwing execGit (unexpected stub shape, seam surprise) is an
|
|
641
|
+
// inconclusive observation, not a crash — the #3659 comparison governs.
|
|
642
|
+
}
|
|
643
|
+
return { confirmed: false, source: 'none', worktreePath: null, worktreeHead: null };
|
|
644
|
+
}
|
|
345
645
|
/**
|
|
346
646
|
* Evaluates whether the current worktree HEAD has diverged from the fork base
|
|
347
|
-
*
|
|
348
|
-
*
|
|
647
|
+
* a 'fresh' parallel worktree would be created from — `origin/HEAD` when the
|
|
648
|
+
* fork base is inferred, or the base a worktree was actually observed to have
|
|
649
|
+
* when the caller supplies one (#4588).
|
|
349
650
|
*
|
|
350
651
|
* Returns a structured result with shouldDegrade, reason, and a user-visible
|
|
351
652
|
* message when degradation is warranted.
|
|
@@ -354,81 +655,171 @@ function evaluateWorktreeBaseDegrade(deps) {
|
|
|
354
655
|
const execGit = deps?.execGit ?? shell_command_projection_cjs_1.execGit;
|
|
355
656
|
const cwd = deps?.cwd;
|
|
356
657
|
const cwdOpts = cwd ? { cwd } : {};
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
//
|
|
360
|
-
//
|
|
361
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
658
|
+
const baseRefHead = deps?.effectiveBaseRef === 'head';
|
|
659
|
+
const observedRaw = deps?.observedForkBase;
|
|
660
|
+
// Only a string or an explicit absence is a legal observation. A number, object or
|
|
661
|
+
// boolean is a programmer error and must not be read as "no observation" — the same
|
|
662
|
+
// TypeError shape applyWorktreeBaseRef uses for a non-object (P4.6 review, round 2).
|
|
663
|
+
if (observedRaw != null && typeof observedRaw !== 'string') {
|
|
664
|
+
throw new TypeError(`evaluateWorktreeBaseDegrade: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${typeof observedRaw}`);
|
|
665
|
+
}
|
|
666
|
+
const observedTrimmed = typeof observedRaw === 'string' ? observedRaw.trim().toLowerCase() : '';
|
|
667
|
+
if (observedTrimmed && !FULL_SHA_RE.test(observedTrimmed)) {
|
|
668
|
+
throw new TypeError(`evaluateWorktreeBaseDegrade: ${MSG_OBSERVED_FORK_BASE_INVALID}, got ${JSON.stringify(observedRaw)}`);
|
|
669
|
+
}
|
|
670
|
+
const observedForkBase = observedTrimmed || null;
|
|
671
|
+
// a. baseRef 'head' with no observation: the fork base IS the orchestrator
|
|
672
|
+
// HEAD, in both isolation modes. orchestrator-worktree: GSD runs
|
|
673
|
+
// `git worktree add <path> <start-point>` with the orchestrator HEAD, so it
|
|
674
|
+
// holds by construction (#3659). harness-worktree: the harness honors the
|
|
675
|
+
// setting — measured on current Claude Code from the project-local,
|
|
676
|
+
// project-shared and user/global layers on macOS, Windows and Linux (#4588).
|
|
677
|
+
// The former harness-mode fall-through rested on #48's finding that the
|
|
678
|
+
// harness did not read the setting; that was true of the harness at the time
|
|
679
|
+
// and was fixed upstream (claude-code#54940), but the check inferred the
|
|
680
|
+
// fork base from the setting's value alone and so could not notice. It is
|
|
681
|
+
// not replaced with a version cutover: a host that does not honor `head`
|
|
682
|
+
// forks from somewhere else, and the spawn-time `worktree_branch_check`
|
|
683
|
+
// guard halts that executor at exit 42 before it commits — the
|
|
684
|
+
// observation-based check that already exists. A caller holding that
|
|
685
|
+
// observation passes it as `observedForkBase` and lands in c/d below, where
|
|
686
|
+
// a mismatch under `head` degrades with `baseref-head-ignored-by-harness`.
|
|
687
|
+
// Any non-"head" value (including "fresh" and absent/null) keeps
|
|
688
|
+
// fresh/origin-HEAD semantics and is evaluated against origin/HEAD as
|
|
689
|
+
// before — with the setting absent the harness does fork from origin/HEAD,
|
|
690
|
+
// so that degrade is a correct reading, not this bug. Measured on Claude
|
|
691
|
+
// Code only: Cursor also declares `harness-worktree` and is unmeasured, so
|
|
692
|
+
// there the trust rests on the exit-42 backstop alone until someone reads a
|
|
693
|
+
// worktree's HEAD on that host. (#683, #48, #3659, #4588.)
|
|
694
|
+
//
|
|
695
|
+
// The one exception is a Claude Code `WorktreeCreate` hook under harness-worktree
|
|
696
|
+
// (#4588): the hook creates the agent worktree from whatever directory it emits and the
|
|
697
|
+
// harness does not apply `worktree.baseRef` on that path, so the measurement above does
|
|
698
|
+
// not cover it. The short-circuit is withheld and the origin/HEAD inference below runs,
|
|
699
|
+
// as for a host without the setting; a mismatch degrades with
|
|
700
|
+
// `baseref-head-bypassed-by-hook`, and the ONLY thing that restores a trusted verdict
|
|
701
|
+
// there is an explicit `--observed-fork-base` measurement of this dispatch. b2's
|
|
702
|
+
// prior-worktree observation deliberately does NOT restore it — see
|
|
703
|
+
// `hookWithheldHeadTrust` below (#4868, #4881, #4921). orchestrator-worktree is
|
|
704
|
+
// unaffected — GSD runs `git worktree add` itself and no Claude Code hook is in that
|
|
705
|
+
// path.
|
|
706
|
+
const hookFinding = deps?.worktreeCreateHook ?? null;
|
|
707
|
+
const hookBypassesBaseRef = hookFinding !== null && (deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree';
|
|
708
|
+
// The interlock's fail-closed half, scoped to exactly the case branch a. declined to
|
|
709
|
+
// trust: `"head"` is set AND a hook (or an unparseable layer) is in the harness's
|
|
710
|
+
// worktree-creation path. It is deliberately NOT `hookBypassesBaseRef` alone — with no
|
|
711
|
+
// `"head"` setting, b2 is #4868's own arm and this PR does not re-scope it.
|
|
712
|
+
const hookWithheldHeadTrust = baseRefHead && hookBypassesBaseRef;
|
|
713
|
+
if (baseRefHead && observedForkBase === null && !hookBypassesBaseRef) {
|
|
370
714
|
return { shouldDegrade: false, reason: 'baseref-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
|
371
715
|
}
|
|
372
|
-
// b. Resolve HEAD sha.
|
|
373
|
-
const
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (isExecGitTimeout(headResult)) {
|
|
716
|
+
// b. Resolve HEAD sha — through the single classification owner (#4734).
|
|
717
|
+
const head = classifyGitHead({ execGit, cwd });
|
|
718
|
+
if (head.status === 'indeterminate') {
|
|
719
|
+
// A timeout, git missing (exit 127), or any other non-128 non-success is
|
|
720
|
+
// NOT a definitive answer from git — fail closed (#3050).
|
|
378
721
|
return { shouldDegrade: true, reason: 'head-unresolvable', message: MSG_HEAD_UNRESOLVABLE, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
|
379
722
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
if (headResult.exitCode === 0 && !headStdout) {
|
|
723
|
+
if (head.status === 'definitive-absence') {
|
|
724
|
+
// Exit 128 is git's definitive "no resolvable HEAD here" answer — not a
|
|
725
|
+
// git repository, or a repository with no commits. In either case a
|
|
726
|
+
// harness worktree can NEVER be created, so demanding worktree isolation
|
|
727
|
+
// blocked every dispatch from e.g. a multi-repo workspace root (#4734).
|
|
728
|
+
// This wires the verdict `headAbsenceVerified` (#3057 B8) was added to
|
|
729
|
+
// enable; the maintainer brief on #4734 answers the previously-open
|
|
730
|
+
// product question: the definitive case degrades.
|
|
731
|
+
return { shouldDegrade: true, reason: 'no-head', message: MSG_NO_GIT_REPOSITORY, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: true };
|
|
732
|
+
}
|
|
733
|
+
if (head.status === 'ambiguous-absence') {
|
|
734
|
+
// Exit 0 with empty stdout is pinned as benign no-degrade by an existing
|
|
735
|
+
// regression guard (tests/worktree-base-ref.test.cjs — "git rev-parse HEAD
|
|
736
|
+
// returns empty stdout"). Unlike the exit-128 case above, git did NOT
|
|
737
|
+
// give a definitive "no HEAD" answer here — `headAbsenceVerified:false`
|
|
738
|
+
// names that gap explicitly (#3057 B8; the product question of whether
|
|
739
|
+
// this SHOULD degrade is unchanged and still open).
|
|
398
740
|
return { shouldDegrade: false, reason: 'no-head', message: null, headSha: null, forkRef: null, forkSha: null, headAbsenceVerified: false };
|
|
399
741
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
742
|
+
const headSha = head.headSha;
|
|
743
|
+
// b2. #4868 (#4588 decision A2): OBSERVED fork-from-HEAD confirmation. A
|
|
744
|
+
// clean prior harness worktree sitting exactly at the orchestrator HEAD is
|
|
745
|
+
// positive evidence the harness forks from HEAD — in harness mode that
|
|
746
|
+
// supersedes the origin/HEAD comparison for this dispatch (the stale
|
|
747
|
+
// origin/HEAD the comparison would degrade on is not where the harness
|
|
748
|
+
// forks). Fail-closed: every non-confirming observation falls through to
|
|
749
|
+
// the comparison below. Since #4881 this step is reached only when branch a
|
|
750
|
+
// did NOT trust the setting: the setting is absent or not "head", or a
|
|
751
|
+
// WorktreeCreate hook withheld the trust — and in that second case the
|
|
752
|
+
// observation is NOT consulted at all (`hookWithheldHeadTrust`), because it
|
|
753
|
+
// cannot be attributed to the hook. The evidence is a worktree sitting at
|
|
754
|
+
// HEAD; nothing on disk records WHICH creator left it there, so a clean
|
|
755
|
+
// worktree the plain harness created BEFORE the hook was configured, with
|
|
756
|
+
// HEAD unmoved since, is indistinguishable from one the hook created. Keying
|
|
757
|
+
// the cache by hook configuration does not close that: the cache is only one
|
|
758
|
+
// of the two legs, and a cache miss falls through to the live probe, which
|
|
759
|
+
// re-finds the same stale worktree and re-confirms. Under a hook the only
|
|
760
|
+
// admissible positive signal is an explicit `--observed-fork-base`
|
|
761
|
+
// measurement of THIS dispatch, which lands in c/d below; absent one the
|
|
762
|
+
// inferred comparison runs and a mismatch degrades with
|
|
763
|
+
// `baseref-head-bypassed-by-hook`, leaving the spawn-time exit-42 guard as
|
|
764
|
+
// the backstop. That keeps the interlock fail-closed end to end.
|
|
765
|
+
// This step is skipped for a second, unrelated reason when the caller
|
|
766
|
+
// supplies `observedForkBase`: an explicit measurement of this dispatch's
|
|
767
|
+
// fork base outranks an inference from a prior worktree, and the two must
|
|
768
|
+
// not disagree silently. The mode gate excludes orchestrator-worktree (no
|
|
769
|
+
// harness, no hook, nothing to observe), and probeStateRead/Write default to
|
|
770
|
+
// the .gsd cache file under cwd. With no `"head"` setting the #4868 arm is
|
|
771
|
+
// unchanged, hook or not — that trust predates this PR and is not re-scoped
|
|
772
|
+
// here (#4921 round 1).
|
|
773
|
+
if (observedForkBase === null && !hookWithheldHeadTrust && (deps?.isolationMode ?? 'harness-worktree') === 'harness-worktree') {
|
|
774
|
+
const observed = observeHarnessForkFromHead({
|
|
775
|
+
execGit,
|
|
776
|
+
cwd: deps?.cwd,
|
|
777
|
+
headSha,
|
|
778
|
+
stateRead: deps?.probeStateRead,
|
|
779
|
+
stateWrite: deps?.probeStateWrite,
|
|
780
|
+
});
|
|
781
|
+
if (observed.confirmed) {
|
|
782
|
+
return {
|
|
783
|
+
shouldDegrade: false,
|
|
784
|
+
reason: 'fork-from-head-observed',
|
|
785
|
+
message: null,
|
|
786
|
+
headSha,
|
|
787
|
+
forkRef: null,
|
|
788
|
+
forkSha: null,
|
|
789
|
+
headAbsenceVerified: null,
|
|
790
|
+
};
|
|
791
|
+
}
|
|
408
792
|
}
|
|
409
|
-
|
|
410
|
-
//
|
|
793
|
+
// c. Resolve fork base. An observation wins outright: it is what a worktree
|
|
794
|
+
// this host created actually forked from, so there is nothing to infer
|
|
795
|
+
// (#4588). Otherwise infer origin/HEAD — what a 'fresh' worktree forks from.
|
|
411
796
|
let forkRef = null;
|
|
412
797
|
let forkSha = null;
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
if (directResult.exitCode === 0 && directStdout) {
|
|
417
|
-
forkRef = 'origin/HEAD';
|
|
418
|
-
forkSha = directStdout;
|
|
798
|
+
if (observedForkBase !== null) {
|
|
799
|
+
forkRef = FORK_REF_OBSERVED;
|
|
800
|
+
forkSha = observedForkBase;
|
|
419
801
|
}
|
|
420
802
|
else {
|
|
421
|
-
//
|
|
422
|
-
const
|
|
423
|
-
const
|
|
424
|
-
if (
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
803
|
+
// Try direct origin/HEAD rev-parse first.
|
|
804
|
+
const directResult = execGit(['rev-parse', '--verify', '--quiet', 'origin/HEAD'], cwdOpts);
|
|
805
|
+
const directStdout = directResult.stdout ? directResult.stdout.trim() : '';
|
|
806
|
+
if (directResult.exitCode === 0 && directStdout) {
|
|
807
|
+
forkRef = 'origin/HEAD';
|
|
808
|
+
forkSha = directStdout;
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
// Fall back via symbolic-ref → refs/remotes/origin/HEAD
|
|
812
|
+
const symResult = execGit(['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], cwdOpts);
|
|
813
|
+
const symStdout = symResult.stdout ? symResult.stdout.trim() : '';
|
|
814
|
+
if (symResult.exitCode === 0 && symStdout) {
|
|
815
|
+
const ref = symStdout;
|
|
816
|
+
const symShaResult = execGit(['rev-parse', '--verify', '--quiet', ref], cwdOpts);
|
|
817
|
+
const symShaStdout = symShaResult.stdout ? symShaResult.stdout.trim() : '';
|
|
818
|
+
if (symShaResult.exitCode === 0 && symShaStdout) {
|
|
819
|
+
// Strip leading 'refs/remotes/' to get e.g. 'origin/next'
|
|
820
|
+
forkRef = ref.replace(/^refs\/remotes\//, '');
|
|
821
|
+
forkSha = symShaStdout;
|
|
822
|
+
}
|
|
432
823
|
}
|
|
433
824
|
}
|
|
434
825
|
}
|
|
@@ -437,10 +828,28 @@ function evaluateWorktreeBaseDegrade(deps) {
|
|
|
437
828
|
return { shouldDegrade: true, reason: 'fork-ref-unknown', message: MSG_UNKNOWN, headSha, forkRef: null, forkSha: null, headAbsenceVerified: null };
|
|
438
829
|
}
|
|
439
830
|
if (forkSha === headSha) {
|
|
440
|
-
|
|
831
|
+
const reason = forkRef === FORK_REF_OBSERVED ? 'observed-fork-matches-head' : 'head-matches-fork';
|
|
832
|
+
return { shouldDegrade: false, reason, message: null, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
|
833
|
+
}
|
|
834
|
+
if (baseRefHead && observedForkBase === null && hookFinding !== null) {
|
|
835
|
+
// Reachable only through the hook interlock in a.: "head" was not trusted because a
|
|
836
|
+
// WorktreeCreate hook (or an unparseable settings layer) is in the harness's path, b2
|
|
837
|
+
// was withheld there (`hookWithheldHeadTrust` — a prior worktree cannot be attributed
|
|
838
|
+
// to the hook), and HEAD differs from the inferred fork base (#4588, #4881, #4921).
|
|
839
|
+
// So this is now the unconditional harness-mode verdict for a hook host with "head"
|
|
840
|
+
// set, a diverged HEAD and no `--observed-fork-base`. The inferred comparison is the one
|
|
841
|
+
// this check made in harness mode before #4588 — it is not a measurement of the hook, so
|
|
842
|
+
// a match above (head-matches-fork) does not prove the hook forks from HEAD either; the
|
|
843
|
+
// spawn-time exit-42 guard stays the backstop for that case, and it halts even in a
|
|
844
|
+
// hook-emitted directory that is not a git worktree (its branch check fails first).
|
|
845
|
+
const message = buildMsgBaserefHeadHookBypass(headSha, forkRef, forkSha, hookFinding);
|
|
846
|
+
return { shouldDegrade: true, reason: 'baseref-head-bypassed-by-hook', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
|
441
847
|
}
|
|
442
|
-
if (
|
|
443
|
-
|
|
848
|
+
if (baseRefHead) {
|
|
849
|
+
// Reachable only with an observation (a. returned otherwise): the setting
|
|
850
|
+
// asked for HEAD and the measured fork base is something else — the
|
|
851
|
+
// existing degrade-and-warn, now reporting a measurement (#4588).
|
|
852
|
+
const message = buildMsgBaserefHeadIgnored(headSha, forkSha);
|
|
444
853
|
return { shouldDegrade: true, reason: 'baseref-head-ignored-by-harness', message, headSha, forkRef, forkSha, headAbsenceVerified: null };
|
|
445
854
|
}
|
|
446
855
|
const message = buildMsgDiverged(headSha, forkRef, forkSha);
|