@opengsd/gsd-core 1.9.0 → 1.10.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 +2 -3
- package/.opencode/plugins/gsd-core.js +8 -1
- package/agents/gsd-code-fixer.md +131 -34
- package/agents/gsd-debugger.md +12 -246
- package/agents/gsd-executor.md +7 -5
- package/agents/gsd-integration-checker.md +3 -0
- package/agents/gsd-plan-checker.md +9 -0
- package/agents/gsd-planner.md +5 -8
- package/agents/gsd-roadmapper.md +21 -3
- package/agents/gsd-verifier.md +14 -70
- package/bin/install.js +503 -341
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/plan-phase.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +607 -63
- package/gsd-core/bin/lib/active-workstream-store.cjs +25 -0
- package/gsd-core/bin/lib/agent-install-check.cjs +38 -6
- package/gsd-core/bin/lib/api-coverage.cjs +120 -0
- package/gsd-core/bin/lib/audit.cjs +89 -1
- package/gsd-core/bin/lib/broken-windows.cjs +36 -6
- package/gsd-core/bin/lib/capability-registry.cjs +96 -110
- package/gsd-core/bin/lib/capability-validator.cjs +12 -2
- package/gsd-core/bin/lib/check-command-router.cjs +43 -1
- package/gsd-core/bin/lib/command-aliases.cjs +72 -0
- package/gsd-core/bin/lib/commands.cjs +26 -25
- package/gsd-core/bin/lib/commonjs-marker.cjs +136 -0
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +12 -1
- package/gsd-core/bin/lib/context-composer.cjs +278 -0
- package/gsd-core/bin/lib/context-predicates.cjs +506 -0
- package/gsd-core/bin/lib/core-utils.cjs +91 -12
- package/gsd-core/bin/lib/docs.cjs +3 -2
- package/gsd-core/bin/lib/external-job.cjs +19 -4
- package/gsd-core/bin/lib/frontmatter.cjs +84 -12
- package/gsd-core/bin/lib/gate-predicate-evaluator.cjs +57 -6
- package/gsd-core/bin/lib/git-base-branch.cjs +58 -15
- package/gsd-core/bin/lib/graphify.cjs +142 -27
- package/gsd-core/bin/lib/gsd2-import.cjs +27 -4
- package/gsd-core/bin/lib/host-integration.cjs +13 -1
- package/gsd-core/bin/lib/init-command-router.cjs +83 -8
- package/gsd-core/bin/lib/init.cjs +1021 -57
- package/gsd-core/bin/lib/install-engine.cjs +64 -10
- package/gsd-core/bin/lib/install-profiles.cjs +27 -1
- package/gsd-core/bin/lib/installer-migration-authoring.cjs +3 -1
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -0
- package/gsd-core/bin/lib/installer-migrations/007-retire-config-root-commonjs-marker.cjs +149 -0
- package/gsd-core/bin/lib/installer-migrations/008-cursor-retire-commands-surface.cjs +55 -0
- package/gsd-core/bin/lib/installer-migrations/009-pi-retire-reserved-hooks-dir.cjs +199 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +87 -1
- package/gsd-core/bin/lib/io.cjs +28 -3
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +6 -0
- package/gsd-core/bin/lib/mcp-catalog.cjs +518 -0
- package/gsd-core/bin/lib/mcp-server.cjs +135 -3
- package/gsd-core/bin/lib/milestone.cjs +106 -51
- package/gsd-core/bin/lib/phase-id.cjs +63 -0
- package/gsd-core/bin/lib/phase-locator.cjs +138 -45
- package/gsd-core/bin/lib/phase.cjs +260 -25
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +232 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +4 -0
- package/gsd-core/bin/lib/project-root.cjs +48 -0
- package/gsd-core/bin/lib/prompt-budget.cjs +128 -165
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +80 -0
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +99 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +30 -6
- package/gsd-core/bin/lib/roadmap-command-router.cjs +42 -9
- package/gsd-core/bin/lib/roadmap-parser.cjs +100 -18
- package/gsd-core/bin/lib/roadmap.cjs +37 -7
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +195 -62
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +15 -3
- package/gsd-core/bin/lib/runtime-homes.cjs +154 -41
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +105 -41
- package/gsd-core/bin/lib/section-manifest.cjs +209 -0
- package/gsd-core/bin/lib/shell-command-projection.cjs +113 -27
- package/gsd-core/bin/lib/smart-entry.cjs +12 -0
- package/gsd-core/bin/lib/state-transition.cjs +73 -8
- package/gsd-core/bin/lib/state.cjs +151 -62
- package/gsd-core/bin/lib/surface.cjs +12 -1
- package/gsd-core/bin/lib/uat-predicate.cjs +11 -1
- package/gsd-core/bin/lib/uat.cjs +320 -21
- package/gsd-core/bin/lib/unusable-input.cjs +9 -0
- package/gsd-core/bin/lib/verification.cjs +29 -12
- package/gsd-core/bin/lib/verify.cjs +29 -5
- package/gsd-core/bin/lib/workflow-fragments.cjs +557 -0
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +181 -18
- package/gsd-core/bin/lib/workstream-inventory.cjs +519 -27
- package/gsd-core/bin/lib/workstream.cjs +6 -0
- package/gsd-core/bin/lib/worktree-base-ref.cjs +50 -6
- package/gsd-core/bin/lib/worktree-safety.cjs +276 -118
- package/gsd-core/bin/shared/config-schema.manifest.json +2 -0
- package/gsd-core/references/artifact-types.md +10 -3
- package/gsd-core/references/autonomous-ui-design-contract.md +42 -0
- package/gsd-core/references/debugger-techniques.md +255 -0
- package/gsd-core/references/research-documentation-lookup.md +5 -3
- package/gsd-core/references/specless-probe-fallback.md +7 -6
- package/gsd-core/references/verifier-wiring-patterns.md +100 -0
- package/gsd-core/references/worktree-branch-check.md +2 -2
- package/gsd-core/templates/summary-complex.md +2 -0
- package/gsd-core/templates/summary-minimal.md +2 -0
- package/gsd-core/templates/summary-standard.md +2 -0
- package/gsd-core/templates/summary.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +3 -0
- package/gsd-core/workflows/autonomous/steps/converge-banner.md +1 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-bg.md +11 -0
- package/gsd-core/workflows/autonomous/steps/converge-dispatch-inline.md +7 -0
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +21 -0
- package/gsd-core/workflows/autonomous/steps/converge-loop.md +7 -0
- package/gsd-core/workflows/autonomous.md +32 -69
- package/gsd-core/workflows/code-review/steps/dispatch-fix.md +39 -0
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +83 -0
- package/gsd-core/workflows/code-review.md +42 -145
- package/gsd-core/workflows/complete-milestone/steps/git-tag.md +29 -0
- package/gsd-core/workflows/complete-milestone.md +23 -81
- package/gsd-core/workflows/debug.md +9 -12
- package/gsd-core/workflows/diagnose-issues.md +22 -0
- package/gsd-core/workflows/discovery-phase.md +4 -4
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +15 -0
- package/gsd-core/workflows/discuss-phase-assumptions.md +5 -16
- package/gsd-core/workflows/docs-update/steps/dispatch-monorepo-packages.md +51 -0
- package/gsd-core/workflows/docs-update.md +8 -51
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +34 -2
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +50 -0
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +31 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +19 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +42 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate.md +43 -37
- package/gsd-core/workflows/execute-phase.md +65 -137
- package/gsd-core/workflows/execute-plan.md +1 -1
- package/gsd-core/workflows/help/modes/full.md +6 -1
- package/gsd-core/workflows/ingest-docs.md +2 -1
- package/gsd-core/workflows/new-milestone/steps/project-md-milestone-write.md +16 -0
- package/gsd-core/workflows/new-milestone/steps/reset-phase-safety.md +19 -0
- package/gsd-core/workflows/new-milestone.md +21 -38
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +176 -0
- package/gsd-core/workflows/new-project/steps/auto-mode-detection.md +32 -0
- package/gsd-core/workflows/new-project/steps/codebase-map-offer.md +18 -0
- package/gsd-core/workflows/new-project.md +13 -226
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +15 -0
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +110 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-gate.md +8 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-early-exit.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/research-only-modifiers.md +16 -0
- package/gsd-core/workflows/plan-phase/steps/reviews-prerequisite.md +17 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +149 -0
- package/gsd-core/workflows/plan-phase.md +49 -193
- package/gsd-core/workflows/progress/steps/forensic-audit.md +125 -0
- package/gsd-core/workflows/progress/steps/mvp-display.md +18 -0
- package/gsd-core/workflows/progress.md +11 -153
- package/gsd-core/workflows/quick/steps/discussion-phase.md +124 -0
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +111 -0
- package/gsd-core/workflows/quick/steps/quick-verification.md +46 -0
- package/gsd-core/workflows/quick/steps/research-phase.md +72 -0
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +37 -0
- package/gsd-core/workflows/quick.md +20 -390
- package/gsd-core/workflows/resume-project.md +3 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-1.md +4 -0
- package/gsd-core/workflows/review/steps/reviewer-instances-note-2.md +3 -0
- package/gsd-core/workflows/review.md +15 -8
- package/gsd-core/workflows/section-manifest.json +219 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/spec-phase.md +17 -14
- package/gsd-core/workflows/spike-wrap-up.md +20 -5
- package/gsd-core/workflows/spike.md +50 -16
- package/gsd-core/workflows/sync-skills.md +49 -11
- package/gsd-core/workflows/transition/steps/workstream-collision-check.md +17 -0
- package/gsd-core/workflows/transition.md +8 -21
- package/gsd-core/workflows/ui-phase.md +8 -7
- package/gsd-core/workflows/update/steps/channel-banner.md +7 -0
- package/gsd-core/workflows/update.md +18 -7
- package/gsd-core/workflows/verify-phase.md +4 -7
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +36 -0
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +21 -0
- package/gsd-core/workflows/verify-work.md +8 -58
- package/hooks/dist/gsd-agent-isolation-guard.js +428 -0
- package/hooks/dist/gsd-check-update-worker.js +14 -5
- package/hooks/dist/gsd-cursor-subagent-start.js +532 -26
- package/hooks/dist/gsd-read-injection-scanner.js +7 -0
- package/hooks/dist/gsd-statusline.js +72 -6
- package/hooks/dist/gsd-worktree-path-guard.js +2 -1
- package/hooks/dist/gsd-write-guard.js +359 -0
- package/hooks/dist/lib/isolation-sentinel.js +268 -0
- package/hooks/dist/managed-hooks-registry.cjs +2 -0
- package/hooks/gsd-agent-isolation-guard.js +428 -0
- package/hooks/gsd-check-update-worker.js +14 -5
- package/hooks/gsd-cursor-subagent-start.js +532 -26
- package/hooks/gsd-read-injection-scanner.js +7 -0
- package/hooks/gsd-statusline.js +72 -6
- package/hooks/gsd-worktree-path-guard.js +2 -1
- package/hooks/gsd-write-guard.js +359 -0
- package/hooks/hooks.json +12 -0
- package/hooks/lib/isolation-sentinel.js +268 -0
- package/hooks/managed-hooks-registry.cjs +2 -0
- package/package.json +14 -5
- package/pi/gsd.cjs +57 -12
- package/scripts/build-hooks.js +9 -0
- package/scripts/changeset/lint.cjs +9 -2
- package/scripts/changeset/serialize.cjs +5 -1
- package/scripts/gen-capability-matrix.cjs +1 -1
- package/scripts/gen-context-index.cjs +448 -0
- package/scripts/gen-inventory-manifest.cjs +101 -1
- package/scripts/gen-prompt-budget-parity-corpus.cjs +645 -0
- package/scripts/gen-registry.cjs +39 -15
- package/scripts/gen-section-manifest.cjs +638 -0
- package/scripts/generate-package-identity.cjs +4 -2
- package/scripts/lint-allow-test-rule-refs.allowlist.json +17 -31
- package/scripts/lint-compiled-artifact-sync.cjs +6 -1
- package/scripts/lint-docs-command-form.cjs +195 -0
- package/scripts/lint-docs-required.cjs +9 -1
- package/scripts/lint-emitted-drift-ack.cjs +215 -20
- package/scripts/lint-example-parser-parity.cjs +395 -0
- package/scripts/lint-test-file-count.allowlist.json +27 -1
- package/scripts/mutation-matrix.cjs +13 -0
- package/scripts/prompt-injection-scan.sh +27 -6
- package/scripts/registry-schema.cjs +323 -94
- package/scripts/run-tests.cjs +3 -2
- package/scripts/validate-registry.cjs +10 -6
- package/skills/gsd-autonomous/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-plan-phase/SKILL.md +2 -2
- package/vscode/package.json +1 -1
- package/scripts/gen-emitted-baseline.cjs +0 -145
|
@@ -19,20 +19,20 @@ const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs")
|
|
|
19
19
|
// providing a deterministic failure path when git stalls (locked index, hung
|
|
20
20
|
// remote, stalled NFS mount, etc.). Callers can override via deps.timeout.
|
|
21
21
|
const DEFAULT_GIT_TIMEOUT_MS = 10000;
|
|
22
|
-
|
|
22
|
+
// #3021: accept the Workflow tool's worktree-wf_<runid>-<n> naming convention
|
|
23
|
+
// (claude-orchestration's isolation:"worktree" emission) alongside the
|
|
24
|
+
// existing agent-<id> / worktree-agent-<id> shapes.
|
|
25
|
+
const WORKTREE_AGENT_BRANCH_RE = /^((worktree-)?agent-|worktree-wf_)[A-Za-z0-9._/-]+$/;
|
|
23
26
|
const WORKTREE_AGENT_BRANCH_PATTERN = WORKTREE_AGENT_BRANCH_RE.source;
|
|
24
27
|
/**
|
|
25
|
-
* Execute a git command via the shell-projection seam,
|
|
26
|
-
* `timedOut`
|
|
28
|
+
* Execute a git command via the shell-projection seam, applying the module's
|
|
29
|
+
* default timeout. `timedOut` is now derived by the seam itself
|
|
30
|
+
* (shell-command-projection.cts's `_spawnResult`), so this is a thin
|
|
31
|
+
* passthrough. Tests inject mocks via deps.execGit using the same
|
|
27
32
|
* (args, opts) shape — see worktree-safety-policy.test.cjs.
|
|
28
|
-
*
|
|
29
|
-
* Return shape: { exitCode, stdout, stderr, timedOut, error, signal }
|
|
30
|
-
* - timedOut: true when spawnSync reports SIGTERM + ETIMEDOUT
|
|
31
33
|
*/
|
|
32
34
|
function execGitDefault(args, opts = {}) {
|
|
33
|
-
|
|
34
|
-
const timedOut = result.signal === 'SIGTERM' && result.error?.code === 'ETIMEDOUT';
|
|
35
|
-
return { ...result, timedOut };
|
|
35
|
+
return (0, shell_command_projection_cjs_1.execGit)(args, { ...opts, timeout: opts.timeout ?? DEFAULT_GIT_TIMEOUT_MS });
|
|
36
36
|
}
|
|
37
37
|
function parseWorktreePorcelain(porcelain) {
|
|
38
38
|
return parseWorktreeEntries(porcelain).filter((entry) => entry.branch !== null).map((entry) => ({
|
|
@@ -92,19 +92,39 @@ function readWorktreeList(repoRoot, deps = {}) {
|
|
|
92
92
|
entries: parseWorktreeEntries(listResult.stdout),
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Shortcut-free git-dir-vs-git-common-dir comparison: the actual primitive
|
|
97
|
+
* that distinguishes a linked worktree from the main worktree.
|
|
98
|
+
*
|
|
99
|
+
* Deliberately factored out of `resolveWorktreeContext` (#3045). That
|
|
100
|
+
* function's `has_local_planning` shortcut answers a DIFFERENT question ("is
|
|
101
|
+
* there already a usable project root right here") and must NOT be consulted
|
|
102
|
+
* for isolation detection: a git worktree created specifically to isolate an
|
|
103
|
+
* executor is a full checkout, so it normally has its OWN checked-out
|
|
104
|
+
* `.planning/` too. A caller that ran the shortcut first would read that
|
|
105
|
+
* correctly-isolated worktree as `current_directory`/`has_local_planning` —
|
|
106
|
+
* i.e. "not isolated" — a false positive that defeats the very isolation
|
|
107
|
+
* guard that needs this check (see `hooks/gsd-cursor-subagent-start.js`,
|
|
108
|
+
* #3045). `resolveWorktreeLinkage` always performs the real git-dir
|
|
109
|
+
* comparison, independent of whether `.planning` exists locally.
|
|
110
|
+
*/
|
|
111
|
+
function resolveWorktreeLinkage(cwd, deps = {}) {
|
|
96
112
|
const execGit = deps.execGit || execGitDefault;
|
|
97
|
-
const
|
|
98
|
-
|
|
99
|
-
|
|
113
|
+
const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
|
|
114
|
+
const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
|
|
115
|
+
// A TIMEOUT means the command never completed — it is not evidence of "not a
|
|
116
|
+
// git repository" (which completes fast, with a clean non-zero exit). Surface
|
|
117
|
+
// it under a distinct reason so callers can tell "genuinely not a repo" apart
|
|
118
|
+
// from "could not determine" (#3050). effectiveRoot still degrades to cwd
|
|
119
|
+
// (there is no safer default without a resolved git-dir), but the reason is
|
|
120
|
+
// no longer indistinguishable from the benign case.
|
|
121
|
+
if (gitDir.timedOut || commonDir.timedOut) {
|
|
100
122
|
return {
|
|
101
123
|
effectiveRoot: cwd,
|
|
102
124
|
mode: 'current_directory',
|
|
103
|
-
reason: '
|
|
125
|
+
reason: 'git_timed_out',
|
|
104
126
|
};
|
|
105
127
|
}
|
|
106
|
-
const gitDir = execGit(['rev-parse', '--git-dir'], { cwd });
|
|
107
|
-
const commonDir = execGit(['rev-parse', '--git-common-dir'], { cwd });
|
|
108
128
|
if (gitDir.exitCode !== 0 || commonDir.exitCode !== 0) {
|
|
109
129
|
return {
|
|
110
130
|
effectiveRoot: cwd,
|
|
@@ -127,6 +147,18 @@ function resolveWorktreeContext(cwd, deps = {}) {
|
|
|
127
147
|
reason: 'main_worktree',
|
|
128
148
|
};
|
|
129
149
|
}
|
|
150
|
+
function resolveWorktreeContext(cwd, deps = {}) {
|
|
151
|
+
const existsSync = deps.existsSync || node_fs_1.default.existsSync;
|
|
152
|
+
// Local .planning takes precedence over linked-worktree remapping.
|
|
153
|
+
if (existsSync(node_path_1.default.join(cwd, '.planning'))) {
|
|
154
|
+
return {
|
|
155
|
+
effectiveRoot: cwd,
|
|
156
|
+
mode: 'current_directory',
|
|
157
|
+
reason: 'has_local_planning',
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
return resolveWorktreeLinkage(cwd, deps);
|
|
161
|
+
}
|
|
130
162
|
function planWorktreePrune(repoRoot, options = {}, deps = {}) {
|
|
131
163
|
const parsePorcelain = deps.parseWorktreePorcelain || parseWorktreePorcelain;
|
|
132
164
|
const destructiveModeRequested = Boolean(options.allowDestructive);
|
|
@@ -140,17 +172,26 @@ function planWorktreePrune(repoRoot, options = {}, deps = {}) {
|
|
|
140
172
|
};
|
|
141
173
|
}
|
|
142
174
|
let worktrees = [];
|
|
175
|
+
let parseFailed = false;
|
|
143
176
|
try {
|
|
144
177
|
worktrees = parsePorcelain(listed.porcelain);
|
|
145
178
|
}
|
|
146
179
|
catch {
|
|
147
180
|
// Keep historical behavior: still run metadata prune when parsing fails.
|
|
181
|
+
// #3050/#3057 (B6): but the reason must NOT collide with the
|
|
182
|
+
// genuinely-empty-list case below — a parser that could not read the
|
|
183
|
+
// porcelain output is not the same fact as "there are no worktrees", and
|
|
184
|
+
// this plan drives a PRUNE, so conflating them means a prune decision made
|
|
185
|
+
// on unread data would be indistinguishable from one made on real data.
|
|
148
186
|
worktrees = [];
|
|
187
|
+
parseFailed = true;
|
|
149
188
|
}
|
|
150
189
|
return {
|
|
151
190
|
repoRoot,
|
|
152
191
|
action: 'metadata_prune_only',
|
|
153
|
-
reason:
|
|
192
|
+
reason: parseFailed
|
|
193
|
+
? 'parse_failed'
|
|
194
|
+
: (worktrees.length === 0 ? 'no_worktrees' : 'worktrees_present'),
|
|
154
195
|
destructiveModeRequested,
|
|
155
196
|
};
|
|
156
197
|
}
|
|
@@ -220,13 +261,25 @@ function inspectWorktreeHealth(repoRoot, options = {}, deps = {}) {
|
|
|
220
261
|
}
|
|
221
262
|
const findings = [];
|
|
222
263
|
for (const entry of inventory.entries) {
|
|
223
|
-
if (
|
|
264
|
+
if (entry.exists === 'absent') {
|
|
224
265
|
findings.push({
|
|
225
266
|
kind: 'orphan',
|
|
226
267
|
path: entry.path,
|
|
227
268
|
});
|
|
228
269
|
continue;
|
|
229
270
|
}
|
|
271
|
+
if (entry.exists === 'unverified') {
|
|
272
|
+
// #3050/#3057 (B5): existsSync confirmed the path is present but statSync
|
|
273
|
+
// threw, so age/staleness could not be determined. This is neither
|
|
274
|
+
// "orphan" (existsSync says it IS there) nor "healthy" (we never verified
|
|
275
|
+
// it) — surface it as its own finding so a caller can't silently treat an
|
|
276
|
+
// unverifiable worktree as confirmed present-and-not-stale.
|
|
277
|
+
findings.push({
|
|
278
|
+
kind: 'unverified',
|
|
279
|
+
path: entry.path,
|
|
280
|
+
});
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
230
283
|
if (entry.isStale) {
|
|
231
284
|
findings.push({
|
|
232
285
|
kind: 'stale',
|
|
@@ -256,7 +309,7 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
|
|
|
256
309
|
}
|
|
257
310
|
const entries = [];
|
|
258
311
|
for (const worktreePath of listed.paths) {
|
|
259
|
-
let exists =
|
|
312
|
+
let exists = 'absent';
|
|
260
313
|
let isStale = false;
|
|
261
314
|
let ageMinutes = null;
|
|
262
315
|
if (!existsSync(worktreePath)) {
|
|
@@ -268,9 +321,9 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
|
|
|
268
321
|
});
|
|
269
322
|
continue;
|
|
270
323
|
}
|
|
271
|
-
exists = true;
|
|
272
324
|
try {
|
|
273
325
|
const stat = statSync(worktreePath);
|
|
326
|
+
exists = 'present';
|
|
274
327
|
const ageMs = nowMs - stat.mtimeMs;
|
|
275
328
|
ageMinutes = Math.round(ageMs / 60000);
|
|
276
329
|
if (ageMs > staleAfterMs) {
|
|
@@ -278,7 +331,12 @@ function snapshotWorktreeInventory(repoRoot, options = {}, deps = {}) {
|
|
|
278
331
|
}
|
|
279
332
|
}
|
|
280
333
|
catch {
|
|
281
|
-
//
|
|
334
|
+
// #3050/#3057 (B5): a statSync throw means presence could not be
|
|
335
|
+
// verified — do NOT report exists:'present' (a guard that could not
|
|
336
|
+
// check must not claim the worktree is confirmed present). Distinguish
|
|
337
|
+
// from the genuinely-absent case above with a third state ('unverified')
|
|
338
|
+
// rather than silently falling through to the pre-existing 'present' default.
|
|
339
|
+
exists = 'unverified';
|
|
282
340
|
}
|
|
283
341
|
entries.push({
|
|
284
342
|
path: worktreePath,
|
|
@@ -371,6 +429,38 @@ function planWorktreeWaveCleanup(repoRoot, manifest) {
|
|
|
371
429
|
function gitResultOk(result) {
|
|
372
430
|
return !!(result && result.exitCode === 0 && !result.timedOut);
|
|
373
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* #2852: after a failed `git merge` + a `git merge --abort` attempt, determine
|
|
434
|
+
* whether `repoRoot` is STILL mid-merge — the only condition that genuinely
|
|
435
|
+
* invalidates the rest of a cleanup wave.
|
|
436
|
+
*
|
|
437
|
+
* `git merge --abort`'s own exit code is NOT a reliable signal here: git refuses
|
|
438
|
+
* many merges (e.g. "your local changes to the following files would be
|
|
439
|
+
* overwritten by merge") WITHOUT ever creating a `MERGE_HEAD`, in which case
|
|
440
|
+
* `repoRoot`'s tree was never touched and `git merge --abort` correctly fails
|
|
441
|
+
* with "fatal: There is no merge to abort (MERGE_HEAD missing)?" — a SAFE
|
|
442
|
+
* outcome, not a broken one. Trusting that exit code alone would misclassify an
|
|
443
|
+
* ordinary per-entry merge failure as a repo-level one and strand the rest of
|
|
444
|
+
* the wave (caught in review).
|
|
445
|
+
*
|
|
446
|
+
* Checked directly via `git rev-parse --verify -q MERGE_HEAD` against the git
|
|
447
|
+
* ref itself rather than the filesystem: exit 0 means a merge is genuinely still
|
|
448
|
+
* in progress (unrecoverable — halt); exit 1 (the ref simply doesn't exist) means
|
|
449
|
+
* repoRoot is clean, whether because no merge state was ever entered or because
|
|
450
|
+
* abort successfully cleared it (safe — isolate and continue). Anything else
|
|
451
|
+
* (a timeout, or an unexpected git error) is treated conservatively as "still
|
|
452
|
+
* mid-merge" — degrade to the safe/halting answer rather than throw or guess.
|
|
453
|
+
*/
|
|
454
|
+
function repoRootStillMidMerge(execGit, repoRoot) {
|
|
455
|
+
const check = execGit(['rev-parse', '--verify', '-q', 'MERGE_HEAD'], { cwd: repoRoot });
|
|
456
|
+
if (check.timedOut)
|
|
457
|
+
return true; // fail closed — cannot confirm safety
|
|
458
|
+
if (check.exitCode === 0)
|
|
459
|
+
return true; // MERGE_HEAD exists — genuinely still mid-merge
|
|
460
|
+
if (check.exitCode === 1)
|
|
461
|
+
return false; // ref not found — repoRoot is not mid-merge
|
|
462
|
+
return true; // any other exit code (e.g. a fatal git error) — fail closed
|
|
463
|
+
}
|
|
374
464
|
/**
|
|
375
465
|
* Walk <worktreePath>/.planning/ recursively and collect absolute paths of
|
|
376
466
|
* all files whose names match *SUMMARY.md. Returns [] when the directory
|
|
@@ -513,6 +603,18 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
|
|
|
513
603
|
const results = [];
|
|
514
604
|
const pending = [];
|
|
515
605
|
let ok = true;
|
|
606
|
+
// #2852: every per-entry failure site marks the SAME shape — status='blocked',
|
|
607
|
+
// a reason code, the captured stderr, push to results, flip the overall `ok`
|
|
608
|
+
// flag — and then either `continue` (isolate, the default) or, for the one
|
|
609
|
+
// repo-level-failure carve-out, `break`. Factored out so the 8 call sites below
|
|
610
|
+
// don't repeat the assembly; each site still owns its own control-flow decision.
|
|
611
|
+
function blockEntry(result, reason, stderr) {
|
|
612
|
+
result.status = 'blocked';
|
|
613
|
+
result.reason = reason;
|
|
614
|
+
result.stderr = stderr;
|
|
615
|
+
results.push(result);
|
|
616
|
+
ok = false;
|
|
617
|
+
}
|
|
516
618
|
for (let i = 0; i < entries.length; i += 1) {
|
|
517
619
|
const entry = entries[i];
|
|
518
620
|
const result = {
|
|
@@ -523,68 +625,45 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
|
|
|
523
625
|
};
|
|
524
626
|
const branchCheck = execGit(['-C', entry.worktree_path, 'rev-parse', '--abbrev-ref', 'HEAD'], { cwd: plan.repoRoot });
|
|
525
627
|
if (!gitResultOk(branchCheck) || branchCheck.stdout.trim() !== entry.branch) {
|
|
526
|
-
result
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
pending.push(...entries.slice(i + 1));
|
|
531
|
-
ok = false;
|
|
532
|
-
break;
|
|
628
|
+
blockEntry(result, 'branch_mismatch', branchCheck?.stderr || '');
|
|
629
|
+
// #2852: isolate — this entry's problem does not touch repoRoot's git state,
|
|
630
|
+
// so every remaining entry is still independently evaluated.
|
|
631
|
+
continue;
|
|
533
632
|
}
|
|
534
633
|
const mergeBase = execGit(['merge-base', 'HEAD', entry.branch], { cwd: plan.repoRoot });
|
|
535
634
|
const allowedBases = Array.isArray(entry.allowed_bases) && entry.allowed_bases.length > 0
|
|
536
635
|
? entry.allowed_bases
|
|
537
636
|
: [entry.expected_base];
|
|
538
637
|
if (!gitResultOk(mergeBase) || !allowedBases.includes(mergeBase.stdout.trim())) {
|
|
539
|
-
result
|
|
540
|
-
|
|
541
|
-
result.stderr = mergeBase?.stderr || '';
|
|
542
|
-
results.push(result);
|
|
543
|
-
pending.push(...entries.slice(i + 1));
|
|
544
|
-
ok = false;
|
|
545
|
-
break;
|
|
638
|
+
blockEntry(result, 'base_mismatch', mergeBase?.stderr || '');
|
|
639
|
+
continue; // #2852: isolate
|
|
546
640
|
}
|
|
547
641
|
const deletions = execGit(['diff', '--diff-filter=D', '--name-only', `HEAD...${entry.branch}`], { cwd: plan.repoRoot });
|
|
548
642
|
if (!gitResultOk(deletions)) {
|
|
549
|
-
result
|
|
550
|
-
|
|
551
|
-
result.stderr = deletions?.stderr || '';
|
|
552
|
-
results.push(result);
|
|
553
|
-
pending.push(...entries.slice(i + 1));
|
|
554
|
-
ok = false;
|
|
555
|
-
break;
|
|
643
|
+
blockEntry(result, 'deletion_check_failed', deletions?.stderr || '');
|
|
644
|
+
continue; // #2852: isolate
|
|
556
645
|
}
|
|
557
646
|
if (deletions.stdout) {
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
647
|
+
// Unconditional: any deletion in this entry's branch blocks THIS entry. Whether
|
|
648
|
+
// that guard should have an opt-in for intentional deletions is a deferred
|
|
649
|
+
// product decision (issue #2852's own triage scoped it out — tracked in #3003);
|
|
650
|
+
// this fix only isolates the block to this one entry (#2852) instead of aborting
|
|
651
|
+
// the rest of the wave, same as every other block reason below.
|
|
652
|
+
blockEntry(result, 'branch_contains_deletions', deletions.stdout);
|
|
653
|
+
continue; // #2852: isolate
|
|
565
654
|
}
|
|
566
655
|
// Safety net: rescue uncommitted SUMMARY.md artifacts before the dirty check.
|
|
567
656
|
// The executor leaves <quick_id>-SUMMARY.md uncommitted by contract — the
|
|
568
657
|
// orchestrator commits it. Mirrors quick.md shell fallback (#2296, #2070, #2838, #3804).
|
|
569
658
|
const { rescuedRelPaths, failures: rescueFailures } = rescueSummaryArtifacts(entry.worktree_path, plan.repoRoot, deps);
|
|
570
659
|
if (rescueFailures.length > 0) {
|
|
571
|
-
result.
|
|
572
|
-
|
|
573
|
-
result.stderr = rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; ');
|
|
574
|
-
results.push(result);
|
|
575
|
-
pending.push(...entries.slice(i + 1));
|
|
576
|
-
ok = false;
|
|
577
|
-
break;
|
|
660
|
+
blockEntry(result, 'summary_rescue_failed', rescueFailures.map((f) => `${f.relPath}: ${f.error}`).join('; '));
|
|
661
|
+
continue; // #2852: isolate
|
|
578
662
|
}
|
|
579
663
|
const worktreeStatus = execGit(['-C', entry.worktree_path, 'status', '--porcelain', '--untracked-files=all'], { cwd: plan.repoRoot });
|
|
580
664
|
if (!gitResultOk(worktreeStatus)) {
|
|
581
|
-
result
|
|
582
|
-
|
|
583
|
-
result.stderr = worktreeStatus?.stderr || '';
|
|
584
|
-
results.push(result);
|
|
585
|
-
pending.push(...entries.slice(i + 1));
|
|
586
|
-
ok = false;
|
|
587
|
-
break;
|
|
665
|
+
blockEntry(result, 'worktree_dirty', worktreeStatus?.stderr || '');
|
|
666
|
+
continue; // #2852: isolate
|
|
588
667
|
}
|
|
589
668
|
// Filter rescued SUMMARY paths out of the porcelain output before deciding dirty.
|
|
590
669
|
// A line like "?? .planning/q1-SUMMARY.md" should not block when the SUMMARY
|
|
@@ -599,23 +678,31 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
|
|
|
599
678
|
return !rescuedRelPaths.has(filePath);
|
|
600
679
|
});
|
|
601
680
|
if (dirtyLines.length > 0) {
|
|
602
|
-
result
|
|
603
|
-
|
|
604
|
-
result.stderr = dirtyLines.join('\n');
|
|
605
|
-
results.push(result);
|
|
606
|
-
pending.push(...entries.slice(i + 1));
|
|
607
|
-
ok = false;
|
|
608
|
-
break;
|
|
681
|
+
blockEntry(result, 'worktree_dirty', dirtyLines.join('\n'));
|
|
682
|
+
continue; // #2852: isolate
|
|
609
683
|
}
|
|
610
684
|
const merge = execGit(['merge', entry.branch, '--no-ff', '--no-edit', '-m', `chore: merge executor worktree (${entry.branch})`], { cwd: plan.repoRoot });
|
|
611
685
|
if (!gitResultOk(merge)) {
|
|
612
|
-
result
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
686
|
+
blockEntry(result, 'merge_failed', merge?.stderr || merge?.stdout || '');
|
|
687
|
+
// #2852: a failed --no-ff merge MIGHT leave repoRoot itself mid-merge
|
|
688
|
+
// (MERGE_HEAD set, conflict markers in the tree) — unlike every other block
|
|
689
|
+
// reason above, that specific state is NOT scoped to this one entry: a second
|
|
690
|
+
// `git merge` cannot even start while one is in progress, so every remaining
|
|
691
|
+
// entry would be corrupted by it. But git also refuses many merges WITHOUT ever
|
|
692
|
+
// entering a merge state (e.g. "your local changes would be overwritten by
|
|
693
|
+
// merge") — in that case repoRoot's tree was never touched and this failure is
|
|
694
|
+
// scoped to this entry, same as everything else. Attempt the abort as a
|
|
695
|
+
// best-effort cleanup, then check repoRoot's ACTUAL state directly — not
|
|
696
|
+
// `git merge --abort`'s own exit code, which fails "There is no merge to abort"
|
|
697
|
+
// in the safe case too and would misclassify it as unrecoverable (caught in
|
|
698
|
+
// review). Only a repo genuinely still mid-merge afterward legitimately halts
|
|
699
|
+
// the rest of the wave (the brief's "infrastructure-level failure" carve-out).
|
|
700
|
+
execGit(['merge', '--abort'], { cwd: plan.repoRoot });
|
|
701
|
+
if (repoRootStillMidMerge(execGit, plan.repoRoot)) {
|
|
702
|
+
pending.push(...entries.slice(i + 1));
|
|
703
|
+
break;
|
|
704
|
+
}
|
|
705
|
+
continue; // #2852: isolate — repoRoot is not (or no longer) mid-merge
|
|
619
706
|
}
|
|
620
707
|
let remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
|
|
621
708
|
if (!gitResultOk(remove)) {
|
|
@@ -626,13 +713,10 @@ function executeWorktreeWaveCleanupPlan(plan, deps = {}) {
|
|
|
626
713
|
remove = execGit(['worktree', 'remove', entry.worktree_path, '--force'], { cwd: plan.repoRoot });
|
|
627
714
|
}
|
|
628
715
|
if (!gitResultOk(remove)) {
|
|
629
|
-
result
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
pending.push(...entries.slice(i + 1));
|
|
634
|
-
ok = false;
|
|
635
|
-
break;
|
|
716
|
+
blockEntry(result, 'worktree_remove_failed', remove?.stderr || '');
|
|
717
|
+
// #2852: isolate — the merge already landed on repoRoot; only this entry's
|
|
718
|
+
// worktree/branch teardown is affected.
|
|
719
|
+
continue;
|
|
636
720
|
}
|
|
637
721
|
const branchDelete = execGit(['branch', '-D', entry.branch], { cwd: plan.repoRoot });
|
|
638
722
|
if (!gitResultOk(branchDelete)) {
|
|
@@ -751,7 +835,7 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
|
|
|
751
835
|
return {
|
|
752
836
|
ok: false,
|
|
753
837
|
reason: 'invalid_entry',
|
|
754
|
-
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts
|
|
838
|
+
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
|
755
839
|
entry: null,
|
|
756
840
|
manifest: null,
|
|
757
841
|
};
|
|
@@ -935,7 +1019,7 @@ function planWorktreeCreate(fields) {
|
|
|
935
1019
|
return {
|
|
936
1020
|
ok: false,
|
|
937
1021
|
reason: 'invalid_entry',
|
|
938
|
-
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts
|
|
1022
|
+
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts agent-<id>, worktree-agent-<id>, and worktree-wf_<runid> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
|
939
1023
|
entry: null,
|
|
940
1024
|
};
|
|
941
1025
|
}
|
|
@@ -1056,7 +1140,7 @@ function executeWorktreeCreatePlan(plan, repoRoot, deps = {}) {
|
|
|
1056
1140
|
* validated manifest entry so the worktree is immediately manageable by
|
|
1057
1141
|
* `worktree cleanup-wave` / `worktree reap-orphans`.
|
|
1058
1142
|
*
|
|
1059
|
-
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
|
1143
|
+
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>
|
|
1060
1144
|
*
|
|
1061
1145
|
* #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
|
|
1062
1146
|
* plan step runs BEFORE the git side effect (step 5). The ONLY manifest
|
|
@@ -1076,7 +1160,7 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
|
|
|
1076
1160
|
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
1077
1161
|
const manifestPath = flag('--manifest');
|
|
1078
1162
|
if (!manifestPath) {
|
|
1079
|
-
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
|
1163
|
+
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> --root <dir>\n');
|
|
1080
1164
|
process.exitCode = 2;
|
|
1081
1165
|
return { ok: false, reason: 'usage' };
|
|
1082
1166
|
}
|
|
@@ -1148,25 +1232,38 @@ function cmdWorktreeCreate(cwd, args = [], deps = {}) {
|
|
|
1148
1232
|
process.exitCode = 1;
|
|
1149
1233
|
return { ok: false, reason: plan.reason, hint: plan.hint };
|
|
1150
1234
|
}
|
|
1151
|
-
// 3b.
|
|
1152
|
-
// deferred here from
|
|
1153
|
-
// planWorktreeCreate
|
|
1154
|
-
//
|
|
1155
|
-
//
|
|
1156
|
-
// unconfined --path is a write primitive aimed
|
|
1235
|
+
// 3b. Mandatory root confinement (#2627 Phase 3 introduced it; #3050 made it
|
|
1236
|
+
// mandatory — the confinement Phase 2 deferred here from
|
|
1237
|
+
// planWorktreeCreate's path-traversal guard). planWorktreeCreate rejects a
|
|
1238
|
+
// literal ".." SEGMENT, but a plain absolute path outside the project
|
|
1239
|
+
// contains no ".." and passes. The orchestrator SPAWNS executor processes
|
|
1240
|
+
// into these paths, so an unconfined --path is a write primitive aimed
|
|
1241
|
+
// anywhere on the filesystem.
|
|
1157
1242
|
//
|
|
1158
1243
|
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
|
|
1159
1244
|
// worktrees legitimately live outside the orchestrator's own root (a lane
|
|
1160
1245
|
// orchestrator creates siblings under the repo's .claude/worktrees/), so
|
|
1161
|
-
// there is no layout this module could derive without guessing.
|
|
1162
|
-
// `--root` the behavior is exactly as shipped in Phase 2 — the
|
|
1163
|
-
// orchestrator-worktree scheduler path always passes it.
|
|
1246
|
+
// there is no layout this module could derive without guessing.
|
|
1164
1247
|
//
|
|
1165
1248
|
// Lexical by design: the worktree does not exist yet, so there is nothing
|
|
1166
1249
|
// to realpath, and resolving only the root would not close a symlinked-leaf
|
|
1167
1250
|
// hole. Pairs with the leading-dash and ".."-segment guards above.
|
|
1251
|
+
//
|
|
1252
|
+
// #3050: confinement does not depend on the caller remembering to pass
|
|
1253
|
+
// `--root` — it used to be silently skippable, so a caller that forgot the
|
|
1254
|
+
// flag got an unconfined `--path` with no warning. Fail closed instead:
|
|
1255
|
+
// absent `--root`, this verb refuses to create anything. The one current
|
|
1256
|
+
// caller (execute-phase's orchestrator-worktree dispatch) always passes
|
|
1257
|
+
// `--root`, so this closes the gap without breaking it.
|
|
1168
1258
|
const rootFlag = flag('--root');
|
|
1169
|
-
if (rootFlag) {
|
|
1259
|
+
if (!rootFlag) {
|
|
1260
|
+
const hint = '--root is required (fail-closed root confinement, #3050). Pass --root <orchestrator-root-dir> so worktree.create can verify --path resolves inside it before creating anything.';
|
|
1261
|
+
writeErr(`[gsd] worktree.create: root_required — ${hint}\n`);
|
|
1262
|
+
write(`${JSON.stringify({ ok: false, reason: 'root_required', hint }, null, 2)}\n`);
|
|
1263
|
+
process.exitCode = 1;
|
|
1264
|
+
return { ok: false, reason: 'root_required', hint };
|
|
1265
|
+
}
|
|
1266
|
+
{
|
|
1170
1267
|
const absRoot = node_path_1.default.resolve(cwd, rootFlag);
|
|
1171
1268
|
const absWorktree = node_path_1.default.resolve(cwd, plan.entry.worktree_path);
|
|
1172
1269
|
const rel = node_path_1.default.relative(absRoot, absWorktree);
|
|
@@ -1350,8 +1447,21 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
|
|
|
1350
1447
|
// worktreePath may not exist yet (already removed); use as-is.
|
|
1351
1448
|
}
|
|
1352
1449
|
// 4a. Stale-lock guard: skip if lock is too fresh (PID recycling / race).
|
|
1450
|
+
//
|
|
1451
|
+
// The two causes are reported SEPARATELY (#3057). A lock whose mtime could
|
|
1452
|
+
// not be read is not "fresh" in any sense: `lock_too_fresh` tells an
|
|
1453
|
+
// operator that waiting will resolve the skip, and waiting never resolves a
|
|
1454
|
+
// stat failure — the lock could be seconds or months old and the sweep has
|
|
1455
|
+
// no way to tell. Conflating them is the same defect this module already
|
|
1456
|
+
// fixed for `parse_failed` vs `no_worktrees` in planWorktreePrune: a
|
|
1457
|
+
// decision made on unread data must not be indistinguishable from one made
|
|
1458
|
+
// on real data.
|
|
1353
1459
|
const lockMtime = mtimeSafe(lockedFile);
|
|
1354
|
-
if (!lockMtime
|
|
1460
|
+
if (!lockMtime) {
|
|
1461
|
+
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_age_unknown' });
|
|
1462
|
+
continue;
|
|
1463
|
+
}
|
|
1464
|
+
if (nowMs - lockMtime.getTime() < reapMtimeGuardMs) {
|
|
1355
1465
|
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_too_fresh' });
|
|
1356
1466
|
continue;
|
|
1357
1467
|
}
|
|
@@ -1362,9 +1472,26 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
|
|
|
1362
1472
|
continue;
|
|
1363
1473
|
}
|
|
1364
1474
|
const pid = parseInt(pidStr, 10);
|
|
1475
|
+
// Number.isFinite, not Number.isNaN: pidStr is captured by /^\d+/ above, so
|
|
1476
|
+
// pid can never be NaN. A 309-or-more-digit string parses to Infinity.
|
|
1477
|
+
//
|
|
1478
|
+
// NOT LOAD-BEARING FOR SAFETY — do not delete it as redundant. Fail-closed
|
|
1479
|
+
// liveness now lives in defaultIsPidAlive, which treats every non-ESRCH
|
|
1480
|
+
// outcome (including the TypeError process.kill throws for Infinity) as
|
|
1481
|
+
// ALIVE. This guard survives because it produces a more ACCURATE verdict
|
|
1482
|
+
// for garbage input: `lock_owner_unknown` says "the lock names a PID this
|
|
1483
|
+
// parse could not represent", whereas falling through would report
|
|
1484
|
+
// `pid_alive` — an assertion about an owner that was never probed.
|
|
1485
|
+
// Note this is an EARLIER, DIFFERENT gate than the process.kill range
|
|
1486
|
+
// limit: process.kill accepts up to 2147483647 and rejects 2147483648
|
|
1487
|
+
// (measured), far below the parse cliff this guard catches.
|
|
1488
|
+
if (!Number.isFinite(pid)) {
|
|
1489
|
+
results.push({ path: worktreePath, status: 'skipped', reason: 'lock_owner_unknown' });
|
|
1490
|
+
continue;
|
|
1491
|
+
}
|
|
1365
1492
|
let pidIsAlive;
|
|
1366
1493
|
try {
|
|
1367
|
-
pidIsAlive =
|
|
1494
|
+
pidIsAlive = isPidAliveCheck(pid);
|
|
1368
1495
|
}
|
|
1369
1496
|
catch {
|
|
1370
1497
|
pidIsAlive = true; // Cannot determine liveness — treat as alive, do not reap.
|
|
@@ -1420,15 +1547,29 @@ function reapOrphanWorktrees(repoRoot, deps = {}) {
|
|
|
1420
1547
|
return results;
|
|
1421
1548
|
}
|
|
1422
1549
|
// ─── reapOrphanWorktrees deps helpers ─────────────────────────────────────────
|
|
1550
|
+
/**
|
|
1551
|
+
* Liveness probe for a lock-owner PID — FAILS CLOSED (#3057).
|
|
1552
|
+
*
|
|
1553
|
+
* `ESRCH` ("no such process") is the ONLY outcome that proves the owner is
|
|
1554
|
+
* gone. Every other failure means the probe could not determine liveness:
|
|
1555
|
+
* - `EPERM` — the process exists, we just may not signal it;
|
|
1556
|
+
* - `TypeError` / `ERR_INVALID_ARG_TYPE` — `process.kill` accepts a pid up
|
|
1557
|
+
* to 2147483647 and REJECTS 2147483648 and above (measured), so a finite
|
|
1558
|
+
* but out-of-range pid never reaches the OS at all;
|
|
1559
|
+
* - anything else — an outcome this helper does not recognise.
|
|
1560
|
+
*
|
|
1561
|
+
* The return value feeds a DESTRUCTIVE decision (`git worktree remove
|
|
1562
|
+
* --force`), so an unrecognised failure must never read as "dead". Hence the
|
|
1563
|
+
* inversion: only ESRCH returns false; everything else returns true (alive,
|
|
1564
|
+
* do not reap).
|
|
1565
|
+
*/
|
|
1423
1566
|
function defaultIsPidAlive(pid) {
|
|
1424
1567
|
try {
|
|
1425
1568
|
process.kill(pid, 0);
|
|
1426
1569
|
return true;
|
|
1427
1570
|
}
|
|
1428
1571
|
catch (err) {
|
|
1429
|
-
|
|
1430
|
-
return true;
|
|
1431
|
-
return false;
|
|
1572
|
+
return err?.code !== 'ESRCH';
|
|
1432
1573
|
}
|
|
1433
1574
|
}
|
|
1434
1575
|
function defaultReadDirSafe(dir) {
|
|
@@ -1455,36 +1596,45 @@ function defaultMtimeSafe(file) {
|
|
|
1455
1596
|
return null;
|
|
1456
1597
|
}
|
|
1457
1598
|
}
|
|
1458
|
-
function cmdWorktreeReapOrphans(cwd) {
|
|
1599
|
+
function cmdWorktreeReapOrphans(cwd, deps = {}) {
|
|
1600
|
+
const write = deps.write || ((s) => process.stdout.write(s));
|
|
1601
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
1459
1602
|
let result;
|
|
1460
1603
|
try {
|
|
1461
|
-
result = reapOrphanWorktrees(cwd);
|
|
1604
|
+
result = reapOrphanWorktrees(cwd, deps);
|
|
1462
1605
|
}
|
|
1463
1606
|
catch (err) {
|
|
1464
1607
|
// Surface failure as a one-line warning; keep exit-zero so workflows don't break.
|
|
1465
|
-
|
|
1608
|
+
writeErr(`[gsd] worktree.reap-orphans failed: ${err && err.message ? err.message : String(err)}\n`);
|
|
1466
1609
|
result = [];
|
|
1467
1610
|
}
|
|
1468
1611
|
const skippedCount = result.filter((r) => r.status === 'skipped').length;
|
|
1469
1612
|
if (skippedCount > 0) {
|
|
1470
1613
|
// Surface skipped entries so operators are aware of unresolved orphans.
|
|
1471
|
-
|
|
1614
|
+
writeErr(`[gsd] worktree.reap-orphans: ${skippedCount} orphan(s) skipped (run with DEBUG=1 for details)\n`);
|
|
1472
1615
|
}
|
|
1473
|
-
|
|
1616
|
+
write(`${JSON.stringify({ ok: true, reaped: result.filter((r) => r.status === 'reaped').length, entries: result }, null, 2)}\n`);
|
|
1474
1617
|
}
|
|
1475
1618
|
// Unused exports kept for API compatibility
|
|
1476
1619
|
void parseWorktreeListPaths;
|
|
1477
1620
|
// ─── Moved from core.cjs (ADR-857 T0 #1268 rehome-core-squatters) ─────────────
|
|
1478
1621
|
/**
|
|
1479
|
-
* Resolve the main worktree root when running inside a git worktree
|
|
1480
|
-
*
|
|
1481
|
-
*
|
|
1622
|
+
* Resolve the main worktree root when running inside a git worktree, along
|
|
1623
|
+
* with the `reason` that produced it (#3050). Callers MUST inspect `reason`
|
|
1624
|
+
* before trusting `root` unconditionally — a `reason` of `git_timed_out`
|
|
1625
|
+
* means the git subprocess used to distinguish "linked worktree" from
|
|
1626
|
+
* "not a repo" never completed, so `root` is a best-effort fallback (cwd),
|
|
1627
|
+
* not a confirmed worktree root. Degrading to cwd rather than throwing is
|
|
1628
|
+
* intentional (return degraded result on timeout; do not throw) — but the
|
|
1629
|
+
* reason must still reach the caller so it can surface the risk instead of
|
|
1630
|
+
* silently trusting the wrong root.
|
|
1482
1631
|
*/
|
|
1483
|
-
function resolveWorktreeRoot(cwd) {
|
|
1632
|
+
function resolveWorktreeRoot(cwd, deps = {}) {
|
|
1484
1633
|
const context = resolveWorktreeContext(cwd, {
|
|
1485
|
-
existsSync: node_fs_1.default.existsSync,
|
|
1634
|
+
existsSync: deps.existsSync || node_fs_1.default.existsSync,
|
|
1635
|
+
execGit: deps.execGit,
|
|
1486
1636
|
});
|
|
1487
|
-
return context.effectiveRoot;
|
|
1637
|
+
return { root: context.effectiveRoot, reason: context.reason };
|
|
1488
1638
|
}
|
|
1489
1639
|
/**
|
|
1490
1640
|
* Clear stale worktree metadata references via `git worktree prune`.
|
|
@@ -1495,12 +1645,19 @@ function resolveWorktreeRoot(cwd) {
|
|
|
1495
1645
|
* the repository; used as `cwd` for git commands.
|
|
1496
1646
|
* @returns list of worktree paths that were removed (always empty)
|
|
1497
1647
|
*/
|
|
1498
|
-
function pruneOrphanedWorktrees(repoRoot) {
|
|
1648
|
+
function pruneOrphanedWorktrees(repoRoot, deps = {}) {
|
|
1649
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
1499
1650
|
try {
|
|
1500
|
-
|
|
1501
|
-
|
|
1651
|
+
// `...deps` comes LAST deliberately: `parseWorktreePorcelain` is a declared
|
|
1652
|
+
// member of WorktreeDeps and planWorktreePrune already reads
|
|
1653
|
+
// `deps.parseWorktreePorcelain` before falling back to the module function,
|
|
1654
|
+
// so a caller-supplied parser is an intended override, not an accident.
|
|
1655
|
+
// The hard-coded key is only a restatement of that same default. Do not
|
|
1656
|
+
// reorder the two — `tests/worktree-safety-reap.test.cjs` pins the override.
|
|
1657
|
+
const plan = planWorktreePrune(repoRoot, { allowDestructive: false }, { parseWorktreePorcelain, ...deps });
|
|
1658
|
+
const pruneResult = executeWorktreePrunePlan(plan, deps);
|
|
1502
1659
|
if (pruneResult && pruneResult.timedOut) {
|
|
1503
|
-
|
|
1660
|
+
writeErr('[gsd-tools] WARNING: worktree health check degraded' +
|
|
1504
1661
|
' — git worktree prune timed out after 10s.' +
|
|
1505
1662
|
' Orphaned worktree metadata may remain until the next successful run.\n');
|
|
1506
1663
|
}
|
|
@@ -1510,6 +1667,7 @@ function pruneOrphanedWorktrees(repoRoot) {
|
|
|
1510
1667
|
}
|
|
1511
1668
|
module.exports = {
|
|
1512
1669
|
resolveWorktreeContext,
|
|
1670
|
+
resolveWorktreeLinkage,
|
|
1513
1671
|
parseWorktreePorcelain,
|
|
1514
1672
|
planWorktreePrune,
|
|
1515
1673
|
executeWorktreePrunePlan,
|