@opengsd/gsd-core 1.8.0 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +31 -1
- package/agents/gsd-code-fixer.md +1 -1
- package/agents/gsd-codebase-mapper.md +1 -1
- package/agents/gsd-debug-session-manager.md +36 -0
- package/agents/gsd-executor.md +20 -7
- package/agents/gsd-intel-updater.md +3 -3
- package/agents/gsd-phase-researcher.md +4 -2
- package/agents/gsd-plan-checker.md +20 -0
- package/agents/gsd-planner.md +15 -23
- package/agents/gsd-project-researcher.md +2 -2
- package/agents/gsd-ui-auditor.md +0 -40
- package/bin/install.js +186 -55
- package/commands/gsd/plan-review-convergence.md +5 -1
- package/gsd-core/bin/gsd-tools.cjs +849 -2
- package/gsd-core/bin/lib/api-coverage.cjs +22 -8
- package/gsd-core/bin/lib/audit.cjs +8 -8
- package/gsd-core/bin/lib/capability-consent.cjs +40 -1
- package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
- package/gsd-core/bin/lib/capability-loader.cjs +23 -1
- package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
- package/gsd-core/bin/lib/capability-trust.cjs +468 -33
- package/gsd-core/bin/lib/capability-validator.cjs +882 -6
- package/gsd-core/bin/lib/check-command-router.cjs +12 -2
- package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
- package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
- package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
- package/gsd-core/bin/lib/commands.cjs +246 -18
- package/gsd-core/bin/lib/config-loader.cjs +200 -28
- package/gsd-core/bin/lib/config.cjs +90 -5
- package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
- package/gsd-core/bin/lib/frontmatter.cjs +125 -15
- package/gsd-core/bin/lib/host-integration.cjs +215 -8
- package/gsd-core/bin/lib/init.cjs +44 -19
- package/gsd-core/bin/lib/install-engine.cjs +1 -0
- package/gsd-core/bin/lib/milestone.cjs +5 -5
- package/gsd-core/bin/lib/model-catalog.cjs +51 -1
- package/gsd-core/bin/lib/observability/logger.cjs +7 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
- package/gsd-core/bin/lib/phase-id.cjs +278 -5
- package/gsd-core/bin/lib/phase.cjs +57 -5
- package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
- package/gsd-core/bin/lib/plan-scan.cjs +1 -1
- package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
- package/gsd-core/bin/lib/profile-output.cjs +34 -8
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
- package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
- package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
- package/gsd-core/bin/lib/roadmap.cjs +10 -4
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
- package/gsd-core/bin/lib/smart-entry.cjs +1 -1
- package/gsd-core/bin/lib/state-document.cjs +164 -20
- package/gsd-core/bin/lib/state-transition.cjs +28 -10
- package/gsd-core/bin/lib/state.cjs +141 -21
- package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
- package/gsd-core/bin/lib/uat.cjs +9 -7
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
- package/gsd-core/bin/lib/unusable-input.cjs +216 -0
- package/gsd-core/bin/lib/validate.cjs +32 -0
- package/gsd-core/bin/lib/verification.cjs +51 -14
- package/gsd-core/bin/lib/verify.cjs +128 -20
- package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
- package/gsd-core/bin/shared/model-catalog.json +5 -0
- package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
- package/gsd-core/references/context-budget.md +40 -0
- package/gsd-core/references/gate-prompts.md +6 -3
- package/gsd-core/references/model-profile-resolution.md +64 -13
- package/gsd-core/references/offer-next.md +88 -0
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/reviewer-instances.md +28 -21
- package/gsd-core/references/runtime-aware-dispatch.md +42 -0
- package/gsd-core/references/ui-consideration-probe.md +2 -2
- package/gsd-core/references/worktree-branch-check.md +4 -4
- package/gsd-core/templates/summary-minimal.md +4 -0
- package/gsd-core/templates/summary-standard.md +4 -0
- package/gsd-core/templates/summary.md +7 -0
- package/gsd-core/workflows/ai-integration-phase.md +4 -4
- package/gsd-core/workflows/audit-fix.md +4 -0
- package/gsd-core/workflows/audit-milestone.md +8 -0
- package/gsd-core/workflows/autonomous.md +19 -15
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/code-review-fix.md +14 -6
- package/gsd-core/workflows/code-review.md +76 -19
- package/gsd-core/workflows/debug.md +10 -2
- package/gsd-core/workflows/diagnose-issues.md +4 -0
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
- package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
- package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/docs-update.md +8 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
- package/gsd-core/workflows/execute-phase.md +85 -115
- package/gsd-core/workflows/execute-plan.md +5 -4
- package/gsd-core/workflows/explore.md +4 -0
- package/gsd-core/workflows/extract-learnings.md +21 -0
- package/gsd-core/workflows/help/modes/full.md +3 -3
- package/gsd-core/workflows/import.md +4 -1
- package/gsd-core/workflows/ingest-docs.md +4 -0
- package/gsd-core/workflows/map-codebase.md +13 -6
- package/gsd-core/workflows/new-milestone.md +10 -2
- package/gsd-core/workflows/new-project.md +11 -4
- package/gsd-core/workflows/next.md +5 -2
- package/gsd-core/workflows/plan-phase.md +42 -46
- package/gsd-core/workflows/plan-review-convergence.md +18 -14
- package/gsd-core/workflows/progress.md +1 -1
- package/gsd-core/workflows/quick.md +14 -3
- package/gsd-core/workflows/review.md +146 -575
- package/gsd-core/workflows/scan.md +9 -1
- package/gsd-core/workflows/secure-phase.md +10 -2
- package/gsd-core/workflows/ship.md +41 -11
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/ui-phase.md +8 -1
- package/gsd-core/workflows/ui-review.md +8 -1
- package/gsd-core/workflows/update.md +104 -5
- package/gsd-core/workflows/validate-phase.md +10 -2
- package/gsd-core/workflows/verify-work.md +8 -1
- package/hooks/dist/gsd-cursor-session-start.js +6 -2
- package/hooks/dist/gsd-cursor-stop.js +6 -2
- package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
- package/hooks/dist/gsd-graphify-update.sh +9 -0
- package/hooks/dist/gsd-phase-boundary.sh +14 -2
- package/hooks/dist/gsd-prompt-guard.js +101 -2
- package/hooks/dist/gsd-read-guard.js +100 -2
- package/hooks/dist/gsd-read-injection-scanner.js +109 -2
- package/hooks/dist/gsd-statusline.js +9 -6
- package/hooks/dist/gsd-workflow-guard.js +110 -6
- package/hooks/dist/gsd-worktree-path-guard.js +132 -8
- package/hooks/dist/lib/cursor-workspace.js +74 -0
- package/hooks/gsd-cursor-session-start.js +6 -2
- package/hooks/gsd-cursor-stop.js +6 -2
- package/hooks/gsd-cursor-subagent-start.js +6 -2
- package/hooks/gsd-graphify-update.sh +9 -0
- package/hooks/gsd-phase-boundary.sh +14 -2
- package/hooks/gsd-prompt-guard.js +101 -2
- package/hooks/gsd-read-guard.js +100 -2
- package/hooks/gsd-read-injection-scanner.js +109 -2
- package/hooks/gsd-statusline.js +9 -6
- package/hooks/gsd-workflow-guard.js +110 -6
- package/hooks/gsd-worktree-path-guard.js +132 -8
- package/hooks/lib/cursor-workspace.js +74 -0
- package/package.json +7 -7
- package/pi/gsd.cjs +26 -1
- package/scripts/check-coverage-gate.cjs +51 -0
- package/scripts/check-glossary-refs.cjs +24 -0
- package/scripts/ci-test-scope.cjs +67 -17
- package/scripts/gen-adr-index.cjs +6 -4
- package/scripts/gen-capability-matrix.cjs +26 -2
- package/scripts/gen-capability-registry.cjs +132 -34
- package/scripts/gen-emitted-baseline.cjs +145 -0
- package/scripts/lint-compiled-artifact-sync.cjs +146 -0
- package/scripts/lint-emitted-drift-ack.cjs +149 -0
- package/scripts/lint-fix-has-regression-test.cjs +131 -0
- package/scripts/lint-resolution-provenance.cjs +9 -0
- package/scripts/mutation-matrix.cjs +4 -0
- package/scripts/prompt-injection-scan.sh +6 -0
- package/scripts/registry-schema.cjs +57 -8
- package/scripts/release-notes/conventional-title.cjs +19 -1
- package/scripts/release-notes/format-github-release-notes.cjs +7 -3
- package/scripts/workflow-size.cjs +16 -8
- package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
- package/vscode/package.json +1 -1
- package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
- package/scripts/update-size-baseline.cjs +0 -68
|
@@ -63,14 +63,35 @@ const { MODEL_PROFILES } = modelProfilesMod;
|
|
|
63
63
|
// Unused but imported for structural parity
|
|
64
64
|
void stripShippedMilestones;
|
|
65
65
|
void schema_detect_cjs_1.detectSchemaFiles;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Pure core of `verify-summary` (#2572).
|
|
68
|
+
*
|
|
69
|
+
* Same artifact↔git checks the CLI verb has always run, lifted out of the
|
|
70
|
+
* `output()` wrapper so other verbs can consume the structured
|
|
71
|
+
* `{ passed, checks, errors }` contract directly instead of shelling out and
|
|
72
|
+
* re-parsing JSON. `cmdVerifySummary` is now a thin adapter over this.
|
|
73
|
+
*
|
|
74
|
+
* Never throws and never writes to stdout: a missing SUMMARY, a non-repo, or an
|
|
75
|
+
* unresolvable commit all come back as structured `false`/`missing` values.
|
|
76
|
+
*
|
|
77
|
+
* Caveat for callers surfacing `commits_exist`: the hash pattern is a loose
|
|
78
|
+
* `\b[0-9a-f]{7,40}\b`, so any hex-shaped token in the prose counts as a
|
|
79
|
+
* candidate. That is cheap as an advisory signal and unacceptable as a gate.
|
|
80
|
+
*
|
|
81
|
+
* @param checkFileCount How many extracted candidates to probe. Defaults to 2 —
|
|
82
|
+
* the value the CLI verb has always used. Pass `Infinity` to probe every
|
|
83
|
+
* candidate (see `cmdPhaseComplete`, which reports on all of them).
|
|
84
|
+
* @param opts.checkCommits When `false`, the `git cat-file` probes are skipped
|
|
85
|
+
* entirely and `commits_exist` comes back `false` meaning *not checked*.
|
|
86
|
+
* Callers that do not surface `commits_exist` should pass `false` so this
|
|
87
|
+
* stays a pure-filesystem check with no subprocess cost.
|
|
88
|
+
*/
|
|
89
|
+
function verifySummaryCore(cwd, summaryPath, checkFileCount, opts) {
|
|
70
90
|
const fullPath = node_path_1.default.join(cwd, summaryPath);
|
|
71
91
|
const checkCount = checkFileCount || 2;
|
|
92
|
+
const checkCommits = opts?.checkCommits !== false;
|
|
72
93
|
if (!node_fs_1.default.existsSync(fullPath)) {
|
|
73
|
-
|
|
94
|
+
return {
|
|
74
95
|
passed: false,
|
|
75
96
|
checks: {
|
|
76
97
|
summary_exists: false,
|
|
@@ -80,21 +101,75 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
|
|
|
80
101
|
},
|
|
81
102
|
errors: ['SUMMARY.md not found'],
|
|
82
103
|
};
|
|
83
|
-
output(result, raw, 'failed');
|
|
84
|
-
return;
|
|
85
104
|
}
|
|
86
105
|
const content = node_fs_1.default.readFileSync(fullPath, 'utf-8');
|
|
87
106
|
const errors = [];
|
|
107
|
+
const projectRoot = node_path_1.default.resolve(cwd);
|
|
108
|
+
/**
|
|
109
|
+
* Is `candidate` plausibly a repo-relative file this check should probe?
|
|
110
|
+
*
|
|
111
|
+
* Deliberately narrowing. This is an ADVISORY, so the two error directions are
|
|
112
|
+
* not symmetric: a false positive tells a user their healthy project is
|
|
113
|
+
* missing a file that was never claimed, while a false negative just means one
|
|
114
|
+
* reference goes unprobed. Every rejection below is a noise class confirmed on
|
|
115
|
+
* #2685; when in doubt, skip rather than warn.
|
|
116
|
+
*/
|
|
117
|
+
const isProbableProjectFile = (candidate) => {
|
|
118
|
+
// Only repo-relative paths — a bare filename is too ambiguous to locate.
|
|
119
|
+
if (!candidate.includes('/'))
|
|
120
|
+
return false;
|
|
121
|
+
// URLs, protocol-relative links, and any other scheme.
|
|
122
|
+
if (candidate.startsWith('http') || candidate.startsWith('//'))
|
|
123
|
+
return false;
|
|
124
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(candidate))
|
|
125
|
+
return false;
|
|
126
|
+
// Globs name a set, not a file: `src/**/*.cts` is never "missing".
|
|
127
|
+
if (/[*?]/.test(candidate))
|
|
128
|
+
return false;
|
|
129
|
+
// Bare hostnames (`docs.example.com/guide.html`). A repo-relative path's
|
|
130
|
+
// first segment is a directory name, which in practice contains a dot only
|
|
131
|
+
// when it is a dotfile directory (`.github/`, `.changeset/`, `.planning/`)
|
|
132
|
+
// — i.e. the dot is at index 0. A dot anywhere later marks a hostname.
|
|
133
|
+
const firstSegment = candidate.split('/')[0] || '';
|
|
134
|
+
if (firstSegment.indexOf('.') > 0)
|
|
135
|
+
return false;
|
|
136
|
+
// Containment guard: a `../`-bearing reference must not turn this advisory
|
|
137
|
+
// into a filesystem existence probe outside the project.
|
|
138
|
+
const resolved = node_path_1.default.resolve(projectRoot, candidate);
|
|
139
|
+
if (resolved !== projectRoot && !resolved.startsWith(projectRoot + node_path_1.default.sep))
|
|
140
|
+
return false;
|
|
141
|
+
return true;
|
|
142
|
+
};
|
|
143
|
+
// Pattern 2 excludes `[` and `]` from its path class (#2685 Blocker 1). All
|
|
144
|
+
// three SUMMARY templates prescribe a YAML flow sequence for `key-files`:
|
|
145
|
+
//
|
|
146
|
+
// key-files:
|
|
147
|
+
// created: [src/auth/login.ts, src/auth/session.ts]
|
|
148
|
+
//
|
|
149
|
+
// and the label matches `(?:Created|Modified|…):` case-insensitively. Without
|
|
150
|
+
// the bracket exclusion the class captures the literal `[` as part of the
|
|
151
|
+
// first path, yielding `[src/auth/login.ts` — a candidate that can never exist
|
|
152
|
+
// on disk. That fired on healthy projects built from GSD's own shipped
|
|
153
|
+
// template. The exclusion also stops a markdown list in the body from
|
|
154
|
+
// reintroducing the same artifact.
|
|
155
|
+
//
|
|
156
|
+
// Stripping frontmatter first was the other remedy offered on #2685. It is a
|
|
157
|
+
// verified no-op on top of this exclusion — measured identical extraction
|
|
158
|
+
// across all three shipped templates — because the exclusion already makes a
|
|
159
|
+
// flow-sequence line contribute nothing. Consequence worth naming: the
|
|
160
|
+
// `key-files` block, the most authoritative statement of what a phase created,
|
|
161
|
+
// is still not read. Recovering it needs a real frontmatter parse, which is
|
|
162
|
+
// deliberately left as a follow-up rather than smuggled in here.
|
|
88
163
|
const mentionedFiles = new Set();
|
|
89
164
|
const patterns = [
|
|
90
165
|
/`([^`]+\.[a-zA-Z]+)`/g,
|
|
91
|
-
/(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi,
|
|
166
|
+
/(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`[\]]+\.[a-zA-Z]+)`?/gi,
|
|
92
167
|
];
|
|
93
168
|
for (const pattern of patterns) {
|
|
94
169
|
let m;
|
|
95
170
|
while ((m = pattern.exec(content)) !== null) {
|
|
96
171
|
const filePath = m[1];
|
|
97
|
-
if (filePath &&
|
|
172
|
+
if (filePath && isProbableProjectFile(filePath)) {
|
|
98
173
|
mentionedFiles.add(filePath);
|
|
99
174
|
}
|
|
100
175
|
}
|
|
@@ -102,12 +177,12 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
|
|
|
102
177
|
const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
|
|
103
178
|
const missing = [];
|
|
104
179
|
for (const file of filesToCheck) {
|
|
105
|
-
if (!node_fs_1.default.existsSync(node_path_1.default.
|
|
180
|
+
if (!node_fs_1.default.existsSync(node_path_1.default.resolve(projectRoot, file))) {
|
|
106
181
|
missing.push(file);
|
|
107
182
|
}
|
|
108
183
|
}
|
|
109
184
|
const commitHashPattern = /\b[0-9a-f]{7,40}\b/g;
|
|
110
|
-
const hashes = content.match(commitHashPattern) || [];
|
|
185
|
+
const hashes = checkCommits ? content.match(commitHashPattern) || [] : [];
|
|
111
186
|
let commitsExist = false;
|
|
112
187
|
if (hashes.length > 0) {
|
|
113
188
|
for (const hash of hashes.slice(0, 3)) {
|
|
@@ -144,8 +219,15 @@ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
|
|
|
144
219
|
self_check: selfCheck,
|
|
145
220
|
};
|
|
146
221
|
const passed = missing.length === 0 && selfCheck !== 'failed';
|
|
147
|
-
|
|
148
|
-
|
|
222
|
+
return { passed, checks, errors };
|
|
223
|
+
}
|
|
224
|
+
/** CLI adapter over verifySummaryCore — arg guard + output shaping only. */
|
|
225
|
+
function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
|
|
226
|
+
if (!summaryPath) {
|
|
227
|
+
error('summary-path required');
|
|
228
|
+
}
|
|
229
|
+
const result = verifySummaryCore(cwd, summaryPath, checkFileCount);
|
|
230
|
+
output(result, raw, result.passed ? 'passed' : 'failed');
|
|
149
231
|
}
|
|
150
232
|
/**
|
|
151
233
|
* Issue #429 — negative-grep comment-text echo gate.
|
|
@@ -649,13 +731,24 @@ function cmdVerifyPlanStructure(cwd, filePath, raw) {
|
|
|
649
731
|
if (!filePath) {
|
|
650
732
|
error('file path required');
|
|
651
733
|
}
|
|
734
|
+
if (filePath.includes('\0')) {
|
|
735
|
+
error('file path contains null bytes');
|
|
736
|
+
}
|
|
652
737
|
const fullPath = node_path_1.default.isAbsolute(filePath) ? filePath : node_path_1.default.join(cwd, filePath);
|
|
653
738
|
const content = (0, shell_command_projection_cjs_1.platformReadSync)(fullPath);
|
|
654
739
|
if (!content) {
|
|
655
740
|
output({ error: 'File not found', path: filePath }, raw);
|
|
656
741
|
return;
|
|
657
742
|
}
|
|
658
|
-
|
|
743
|
+
// #2701: fail loud on NUL/binary corruption before structure checks. A
|
|
744
|
+
// structurally intact-but-NUL-corrupted plan otherwise passes as valid and is
|
|
745
|
+
// silently skipped by recursive/binary-skipping searchers downstream.
|
|
746
|
+
const encErr = (0, validate_cjs_2.textEncodingError)(content, filePath);
|
|
747
|
+
if (encErr) {
|
|
748
|
+
output({ valid: false, errors: [encErr] }, raw);
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
const fm = extractFrontmatter(content, fullPath);
|
|
659
752
|
const errors = [];
|
|
660
753
|
const warnings = [];
|
|
661
754
|
const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
|
|
@@ -909,7 +1002,7 @@ function collectPromisedFilesAtOrAfterWave(phaseDir, minWave) {
|
|
|
909
1002
|
const planContent = (0, shell_command_projection_cjs_1.platformReadSync)(planFullPath);
|
|
910
1003
|
if (!planContent)
|
|
911
1004
|
continue;
|
|
912
|
-
const fm = extractFrontmatter(planContent);
|
|
1005
|
+
const fm = extractFrontmatter(planContent, planFullPath);
|
|
913
1006
|
const waveRaw = fm['wave'];
|
|
914
1007
|
const wave = typeof waveRaw === 'string' ? parseInt(waveRaw, 10) : (typeof waveRaw === 'number' ? waveRaw : NaN);
|
|
915
1008
|
if (isNaN(wave) || wave < minWave)
|
|
@@ -944,7 +1037,7 @@ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
|
|
|
944
1037
|
}
|
|
945
1038
|
// Derive the current plan's wave number and phase directory for wave-aware
|
|
946
1039
|
// missing-file handling (fix #1202).
|
|
947
|
-
const currentFm = extractFrontmatter(content);
|
|
1040
|
+
const currentFm = extractFrontmatter(content, fullPath);
|
|
948
1041
|
const currentWaveRaw = currentFm['wave'];
|
|
949
1042
|
const currentWave = typeof currentWaveRaw === 'string'
|
|
950
1043
|
? parseInt(currentWaveRaw, 10)
|
|
@@ -1041,8 +1134,16 @@ function listMilestoneArchiveDirs(planBase) {
|
|
|
1041
1134
|
.map((e) => node_path_1.default.join(milestonesDir, e.name))
|
|
1042
1135
|
.sort((a, b) => node_path_1.default.basename(a).localeCompare(node_path_1.default.basename(b), undefined, { numeric: true }));
|
|
1043
1136
|
}
|
|
1044
|
-
catch {
|
|
1045
|
-
|
|
1137
|
+
catch (err) {
|
|
1138
|
+
// #1883: distinguish genuine absence from a permission/I-O failure. ENOENT
|
|
1139
|
+
// (no milestones/ dir yet) keeps the long-standing [] contract that
|
|
1140
|
+
// collectPhaseRoots / forEachArchivedPhaseToken depend on for "no archives";
|
|
1141
|
+
// every other error (EACCES, EIO, …) must propagate — otherwise an unreadable
|
|
1142
|
+
// milestones/ dir is silently reported as "no archives" and active-milestone
|
|
1143
|
+
// resolution / archived-phase filtering misbehaves.
|
|
1144
|
+
if (err.code === 'ENOENT')
|
|
1145
|
+
return [];
|
|
1146
|
+
throw err;
|
|
1046
1147
|
}
|
|
1047
1148
|
}
|
|
1048
1149
|
function forEachArchivedPhaseToken(planBase, onPhase) {
|
|
@@ -1215,8 +1316,9 @@ function cmdValidateConsistency(cwd, raw) {
|
|
|
1215
1316
|
}
|
|
1216
1317
|
}
|
|
1217
1318
|
for (const plan of plans) {
|
|
1218
|
-
const
|
|
1219
|
-
const
|
|
1319
|
+
const planFilePath = node_path_1.default.join(phasePath, plan);
|
|
1320
|
+
const content = node_fs_1.default.readFileSync(planFilePath, 'utf-8');
|
|
1321
|
+
const fmData = extractFrontmatter(content, planFilePath);
|
|
1220
1322
|
if (!fmData['wave']) {
|
|
1221
1323
|
warnings.push(`${phaseLabel}/${plan}: missing 'wave' in frontmatter`);
|
|
1222
1324
|
}
|
|
@@ -2147,6 +2249,7 @@ module.exports = {
|
|
|
2147
2249
|
scanNegativeGrepCommentEcho,
|
|
2148
2250
|
scanFileWideNegativeGateConflict,
|
|
2149
2251
|
cmdVerifySummary,
|
|
2252
|
+
verifySummaryCore,
|
|
2150
2253
|
cmdVerifyPlanStructure,
|
|
2151
2254
|
cmdVerifyPhaseCompleteness,
|
|
2152
2255
|
cmdVerifyReferences,
|
|
@@ -2158,4 +2261,9 @@ module.exports = {
|
|
|
2158
2261
|
cmdValidateAgents,
|
|
2159
2262
|
cmdVerifySchemaDrift,
|
|
2160
2263
|
cmdVerifyCodebaseDrift,
|
|
2264
|
+
// Test seam (#1883): listMilestoneArchiveDirs is private and exercised through
|
|
2265
|
+
// the validate command, which runs in a subprocess — an fs monkeypatch in the
|
|
2266
|
+
// test process cannot reach it. Exposed under a leading underscore so the
|
|
2267
|
+
// permission-error path can be unit-tested directly (no chmod 0o000).
|
|
2268
|
+
_listMilestoneArchiveDirs: listMilestoneArchiveDirs,
|
|
2161
2269
|
};
|
|
@@ -19,6 +19,8 @@ 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
|
+
const WORKTREE_AGENT_BRANCH_RE = /^(worktree-)?agent-[A-Za-z0-9._/-]+$/;
|
|
23
|
+
const WORKTREE_AGENT_BRANCH_PATTERN = WORKTREE_AGENT_BRANCH_RE.source;
|
|
22
24
|
/**
|
|
23
25
|
* Execute a git command via the shell-projection seam, with a derived
|
|
24
26
|
* `timedOut` field. Tests inject mocks via deps.execGit using the new
|
|
@@ -302,7 +304,7 @@ function normalizeCleanupManifestEntry(entry) {
|
|
|
302
304
|
const expectedBase = typeof e.expected_base === 'string' ? e.expected_base : '';
|
|
303
305
|
if (!worktreePath || !branch || !expectedBase)
|
|
304
306
|
return null;
|
|
305
|
-
if (
|
|
307
|
+
if (!WORKTREE_AGENT_BRANCH_RE.test(branch))
|
|
306
308
|
return null;
|
|
307
309
|
const rawAllowedBases = Array.isArray(e.allowed_bases) ? e.allowed_bases : [];
|
|
308
310
|
const allowedBases = Array.from(new Set([expectedBase, ...rawAllowedBases.filter((base) => typeof base === 'string' && base.length > 0)]));
|
|
@@ -449,20 +451,18 @@ function rescueSummaryArtifacts(worktreePath, repoRoot, deps) {
|
|
|
449
451
|
// the executor's content could be lost. cat-file -e HEAD:<path> returns
|
|
450
452
|
// exit 0 only when the object exists in the committed HEAD tree.
|
|
451
453
|
//
|
|
452
|
-
//
|
|
453
|
-
//
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
454
|
+
// #2556: rescue whenever the object is NOT confirmed committed (any non-zero
|
|
455
|
+
// exit). `git cat-file -e` returns 128 — NOT 1 — for an absent path (the
|
|
456
|
+
// normal uncommitted-SUMMARY state), so the previous `!== 1` check never
|
|
457
|
+
// rescued and the untracked file was silently discarded by `worktree remove
|
|
458
|
+
// --force`. Data safety wins: an un-rescued untracked SUMMARY is lost, while
|
|
459
|
+
// a spurious rescue is usually a no-op — the destination check below skips the
|
|
460
|
+
// copy when the main tree already holds identical content (which is also what
|
|
461
|
+
// guards the #706 merge collision). A divergent dest is overwritten, but only
|
|
462
|
+
// uncommitted main-tree content could be lost (committed content is git-recoverable).
|
|
457
463
|
const catFileResult = execGit(['-C', worktreePath, 'cat-file', '-e', `HEAD:${relPath}`], { cwd: repoRoot });
|
|
458
|
-
if (catFileResult.exitCode
|
|
459
|
-
//
|
|
460
|
-
// exit 0 → object exists (committed on HEAD) — merge will carry it, skip.
|
|
461
|
-
// exit 128 → fatal git error (corrupt store, unborn HEAD, etc.) — uncertain,
|
|
462
|
-
// fail-closed: do NOT rescue to avoid recreating the #706 collision.
|
|
463
|
-
// timedOut / null / other → unreliable result — same fail-closed policy.
|
|
464
|
-
// In all non-1 cases the merge will either succeed naturally (0) or surface
|
|
465
|
-
// the problem safely (128/timeout), which is the recoverable pre-fix behaviour.
|
|
464
|
+
if (catFileResult.exitCode === 0) {
|
|
465
|
+
// exit 0 → the SUMMARY is committed on HEAD; the merge will carry it, so skip rescue.
|
|
466
466
|
continue;
|
|
467
467
|
}
|
|
468
468
|
const dest = node_path_1.default.join(repoRoot, relPath);
|
|
@@ -751,7 +751,7 @@ function planWorktreeRecordAgent(manifestRaw, fields) {
|
|
|
751
751
|
return {
|
|
752
752
|
ok: false,
|
|
753
753
|
reason: 'invalid_entry',
|
|
754
|
-
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match
|
|
754
|
+
hint: `Entry failed cleanup-manifest validation: --path/--branch/--base must be non-empty and --branch must match ${WORKTREE_AGENT_BRANCH_PATTERN} (accepts both agent-<id> and worktree-agent-<id> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
|
755
755
|
entry: null,
|
|
756
756
|
manifest: null,
|
|
757
757
|
};
|
|
@@ -893,6 +893,348 @@ function cmdWorktreeRecordAgent(cwd, args = [], deps = {}) {
|
|
|
893
893
|
write(`${JSON.stringify({ ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved }, null, 2)}\n`);
|
|
894
894
|
return { ok: true, reason: 'ok', entry: plan.entry, manifest_path: resolved };
|
|
895
895
|
}
|
|
896
|
+
/**
|
|
897
|
+
* Pure planner for `worktree create`. Validates the four required fields
|
|
898
|
+
* (write-strict, same missing-field-hint style as `planWorktreeRecordAgent`),
|
|
899
|
+
* then runs the candidate entry through the SAME `normalizeCleanupManifestEntry`
|
|
900
|
+
* validation the cleanup-wave reader and record-agent use — so a worktree this
|
|
901
|
+
* verb creates is guaranteed manageable by cleanup-wave/reap-orphans, and an
|
|
902
|
+
* entry that would fail the reader's branch-namespace guard is rejected here,
|
|
903
|
+
* fail-closed, before any git command runs.
|
|
904
|
+
*/
|
|
905
|
+
function planWorktreeCreate(fields) {
|
|
906
|
+
const agentId = (fields.agentId || '').trim();
|
|
907
|
+
const worktreePath = (fields.worktreePath || '').trim();
|
|
908
|
+
const branch = (fields.branch || '').trim();
|
|
909
|
+
const base = (fields.base || '').trim();
|
|
910
|
+
const missing = [];
|
|
911
|
+
if (!agentId)
|
|
912
|
+
missing.push('--agent-id');
|
|
913
|
+
if (!worktreePath)
|
|
914
|
+
missing.push('--path');
|
|
915
|
+
if (!branch)
|
|
916
|
+
missing.push('--branch');
|
|
917
|
+
if (!base)
|
|
918
|
+
missing.push('--base');
|
|
919
|
+
if (missing.length > 0) {
|
|
920
|
+
return {
|
|
921
|
+
ok: false,
|
|
922
|
+
reason: 'missing_field',
|
|
923
|
+
hint: `worktree create requires ${missing.join(', ')}. Re-run with all of --agent-id, --path, --branch, --base set to non-empty (non-whitespace) values.`,
|
|
924
|
+
entry: null,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const candidate = {
|
|
928
|
+
agent_id: agentId,
|
|
929
|
+
worktree_path: worktreePath,
|
|
930
|
+
branch,
|
|
931
|
+
expected_base: base,
|
|
932
|
+
};
|
|
933
|
+
const entry = normalizeCleanupManifestEntry(candidate);
|
|
934
|
+
if (!entry) {
|
|
935
|
+
return {
|
|
936
|
+
ok: false,
|
|
937
|
+
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 both agent-<id> and worktree-agent-<id> namespaces; got branch="${branch}"). Fix the field and re-run.`,
|
|
939
|
+
entry: null,
|
|
940
|
+
};
|
|
941
|
+
}
|
|
942
|
+
// #2584 FIX 4 — git argument-injection guard: a value starting with '-'
|
|
943
|
+
// could be parsed by git as a FLAG rather than a positional argument (e.g.
|
|
944
|
+
// base="--upload-pack=x", path="-f"). `git worktree add` / `git rev-parse`
|
|
945
|
+
// support for a `--` end-of-options separator is inconsistent across git
|
|
946
|
+
// versions, so rejecting a leading dash outright — not relying on `--` — is
|
|
947
|
+
// the portable fix.
|
|
948
|
+
if (branch.startsWith('-') || base.startsWith('-') || worktreePath.startsWith('-')) {
|
|
949
|
+
return {
|
|
950
|
+
ok: false,
|
|
951
|
+
reason: 'unsafe_leading_dash',
|
|
952
|
+
hint: `--branch/--base/--path must not start with "-" (a leading dash would be parsed by git as a flag, not a value). Got branch="${branch}" base="${base}" path="${worktreePath}".`,
|
|
953
|
+
entry: null,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
// #2584 FIX 4 — path-traversal guard: reject a ".." path segment in --path.
|
|
957
|
+
// Absolute paths ARE allowed (the orchestrator legitimately uses them —
|
|
958
|
+
// Phase-3 root confinement is out of Phase-2 scope); only a literal ".."
|
|
959
|
+
// component is rejected. Split on BOTH separators so the guard is effective
|
|
960
|
+
// on a Windows-style path too.
|
|
961
|
+
if (worktreePath.split(/[/\\]/).includes('..')) {
|
|
962
|
+
return {
|
|
963
|
+
ok: false,
|
|
964
|
+
reason: 'unsafe_path_traversal',
|
|
965
|
+
hint: `--path must not contain a ".." path segment (got: "${worktreePath}").`,
|
|
966
|
+
entry: null,
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
return { ok: true, reason: 'ok', entry };
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Best-effort bounded rollback of a partial/orphaned worktree (#2584 FIX 3,
|
|
973
|
+
* scope narrowed by FIX 5). Invoked ONLY when a `git worktree add` TIMED OUT
|
|
974
|
+
* mid-operation — a SIGTERM'd `add` can leave a `.git/worktrees/<name>` admin
|
|
975
|
+
* entry / directory on disk that got past validation into the file checkout,
|
|
976
|
+
* so the partial is genuinely THIS call's own creation and is safe to
|
|
977
|
+
* best-effort remove immediately. It is deliberately NOT invoked on a clean
|
|
978
|
+
* non-zero `add` exit (see FIX 5) — the most common such failure is a
|
|
979
|
+
* COLLISION (the path/branch is already a registered worktree), git fails
|
|
980
|
+
* FAST there having created nothing, and the branch namespace this verb
|
|
981
|
+
* writes into (`worktree-agent-*`/`agent-*`) is exactly the concurrent-
|
|
982
|
+
* executor namespace, so a colliding path is very plausibly a LIVE PEER
|
|
983
|
+
* executor whose uncommitted work `--force` would destroy. Also invoked from
|
|
984
|
+
* `cmdWorktreeCreate` when a successful `add` is followed by a manifest-write
|
|
985
|
+
* failure (#2584 FIX 1) — that path proves THIS call created the worktree, so
|
|
986
|
+
* removing it is safe. This is immediate best-effort hygiene, not the only
|
|
987
|
+
* safety net: `reapOrphanWorktrees` scans the `.git/worktrees/` admin
|
|
988
|
+
* directory directly (a genuine directory-scan backstop, not manifest-only),
|
|
989
|
+
* so any partial this call cannot reach is still eventually discovered and
|
|
990
|
+
* reaped there. The result is intentionally ignored and a throw is
|
|
991
|
+
* swallowed: this is best-effort cleanup, never a new source of truth, and
|
|
992
|
+
* must never mask or block the caller's own degraded-but-honest return.
|
|
993
|
+
*/
|
|
994
|
+
function rollbackPartialWorktree(execGit, worktreePath, repoRoot) {
|
|
995
|
+
try {
|
|
996
|
+
execGit(['worktree', 'remove', '--force', worktreePath], { cwd: repoRoot });
|
|
997
|
+
}
|
|
998
|
+
catch {
|
|
999
|
+
// best-effort only — a throwing rollback must never mask the original failure.
|
|
1000
|
+
}
|
|
1001
|
+
}
|
|
1002
|
+
/**
|
|
1003
|
+
* Execute a `planWorktreeCreate` plan via bounded git. Fail-closed at every
|
|
1004
|
+
* step — a timeout or a non-zero exit degrades to a structured result rather
|
|
1005
|
+
* than throwing, and the base must resolve BEFORE any worktree is created (no
|
|
1006
|
+
* partial/orphaned worktree on a bad base). Returns `cwd` — the working
|
|
1007
|
+
* directory Phase 3's executor spawn will pass through.
|
|
1008
|
+
*/
|
|
1009
|
+
function executeWorktreeCreatePlan(plan, repoRoot, deps = {}) {
|
|
1010
|
+
const execGit = deps.execGit || execGitDefault;
|
|
1011
|
+
if (!plan || !plan.ok || !plan.entry) {
|
|
1012
|
+
return {
|
|
1013
|
+
ok: false,
|
|
1014
|
+
reason: plan ? plan.reason : 'missing_plan',
|
|
1015
|
+
};
|
|
1016
|
+
}
|
|
1017
|
+
const { worktree_path: worktreePath, branch, expected_base: base } = plan.entry;
|
|
1018
|
+
const normalizedPath = (0, shell_command_projection_cjs_1.posixNormalize)(worktreePath);
|
|
1019
|
+
// 1. Verify the base resolves BEFORE creating anything (fail-closed). Nothing
|
|
1020
|
+
// has been created on this path yet, so there is nothing to roll back.
|
|
1021
|
+
const baseCheck = execGit(['rev-parse', '--verify', '--quiet', `${base}^{commit}`], { cwd: repoRoot });
|
|
1022
|
+
if (baseCheck.timedOut) {
|
|
1023
|
+
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
|
1024
|
+
}
|
|
1025
|
+
if (!gitResultOk(baseCheck)) {
|
|
1026
|
+
return { ok: false, reason: 'base_unresolved', worktree_path: normalizedPath, branch, base, stderr: baseCheck.stderr || '' };
|
|
1027
|
+
}
|
|
1028
|
+
// 2. Create the worktree + branch together.
|
|
1029
|
+
const addResult = execGit(['worktree', 'add', '-b', branch, worktreePath, base], { cwd: repoRoot });
|
|
1030
|
+
if (addResult.timedOut) {
|
|
1031
|
+
// #2584 FIX 3: a SIGTERM'd `add` can leave a partial worktree on disk.
|
|
1032
|
+
rollbackPartialWorktree(execGit, worktreePath, repoRoot);
|
|
1033
|
+
return { ok: false, reason: 'git_timeout', worktree_path: normalizedPath, branch, base };
|
|
1034
|
+
}
|
|
1035
|
+
if (addResult.exitCode !== 0) {
|
|
1036
|
+
// #2584 FIX 5: deliberately NO rollback here. A clean non-zero exit is
|
|
1037
|
+
// most commonly a COLLISION (path/branch already a registered worktree),
|
|
1038
|
+
// and git fails FAST on that — it creates nothing. A colliding path in
|
|
1039
|
+
// this branch namespace is very plausibly a LIVE PEER executor;
|
|
1040
|
+
// `git worktree remove --force` on it would destroy real, uncommitted
|
|
1041
|
+
// work. The safe response to a clean failure is to fail loudly and leave
|
|
1042
|
+
// whatever is already on disk untouched.
|
|
1043
|
+
return { ok: false, reason: 'worktree_add_failed', worktree_path: normalizedPath, branch, base, stderr: addResult.stderr || '' };
|
|
1044
|
+
}
|
|
1045
|
+
return {
|
|
1046
|
+
ok: true,
|
|
1047
|
+
reason: 'created',
|
|
1048
|
+
worktree_path: normalizedPath,
|
|
1049
|
+
branch,
|
|
1050
|
+
base,
|
|
1051
|
+
cwd: normalizedPath,
|
|
1052
|
+
};
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* CLI command: create a git worktree + branch off `--base`, then append the
|
|
1056
|
+
* validated manifest entry so the worktree is immediately manageable by
|
|
1057
|
+
* `worktree cleanup-wave` / `worktree reap-orphans`.
|
|
1058
|
+
*
|
|
1059
|
+
* Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha>
|
|
1060
|
+
*
|
|
1061
|
+
* #2584 FIX 1 — ORDERING CONTRACT: every manifest read/parse/shape-validate/
|
|
1062
|
+
* plan step runs BEFORE the git side effect (step 5). The ONLY manifest
|
|
1063
|
+
* operation that can run AFTER `git worktree add` has succeeded is the final
|
|
1064
|
+
* guarded write (step 6), and a failure there triggers a best-effort rollback
|
|
1065
|
+
* of the just-created worktree — so a malformed/mis-shaped manifest, or a
|
|
1066
|
+
* `writeFile` IO error, can never leave a REAL worktree on disk with no
|
|
1067
|
+
* manifest entry (cleanup-wave/reap-orphans only discover worktrees via the
|
|
1068
|
+
* manifest, never a directory scan) or an uncaught throw.
|
|
1069
|
+
*/
|
|
1070
|
+
function cmdWorktreeCreate(cwd, args = [], deps = {}) {
|
|
1071
|
+
const flag = (name) => {
|
|
1072
|
+
const i = args.indexOf(name);
|
|
1073
|
+
return i >= 0 && i + 1 < args.length ? args[i + 1] : '';
|
|
1074
|
+
};
|
|
1075
|
+
const write = deps.write || ((s) => process.stdout.write(s));
|
|
1076
|
+
const writeErr = deps.writeErr || ((s) => process.stderr.write(s));
|
|
1077
|
+
const manifestPath = flag('--manifest');
|
|
1078
|
+
if (!manifestPath) {
|
|
1079
|
+
writeErr('Usage: worktree create --manifest <path> --agent-id <id> --path <worktree> --branch <branch> --base <sha> [--root <dir>]\n');
|
|
1080
|
+
process.exitCode = 2;
|
|
1081
|
+
return { ok: false, reason: 'usage' };
|
|
1082
|
+
}
|
|
1083
|
+
// 1. Read the manifest (no side effect yet).
|
|
1084
|
+
const resolved = node_path_1.default.resolve(cwd, manifestPath);
|
|
1085
|
+
const readFile = deps.readFile || ((p) => node_fs_1.default.readFileSync(p, 'utf8'));
|
|
1086
|
+
let manifestRaw;
|
|
1087
|
+
try {
|
|
1088
|
+
manifestRaw = readFile(resolved);
|
|
1089
|
+
}
|
|
1090
|
+
catch (err) {
|
|
1091
|
+
const hint = `Manifest not found or unreadable at ${manifestPath}. The orchestrator must initialize it ({"orchestrator_root": "...", "worktrees": []}) before creating agent worktrees.`;
|
|
1092
|
+
writeErr(`[gsd] worktree.create: manifest_read_failed — ${hint}\n`);
|
|
1093
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_read_failed', hint, error: err.message }, null, 2)}\n`);
|
|
1094
|
+
process.exitCode = 1;
|
|
1095
|
+
return { ok: false, reason: 'manifest_read_failed', hint };
|
|
1096
|
+
}
|
|
1097
|
+
// 2. Parse + shape-validate the manifest BEFORE any git command runs.
|
|
1098
|
+
// Mirrors planWorktreeRecordAgent's shell-acceptance rules (canonical
|
|
1099
|
+
// {worktrees:[]} object OR a bare top-level array).
|
|
1100
|
+
let parsed;
|
|
1101
|
+
try {
|
|
1102
|
+
parsed = JSON.parse(manifestRaw);
|
|
1103
|
+
}
|
|
1104
|
+
catch {
|
|
1105
|
+
const hint = 'Manifest is not valid JSON. The orchestrator must initialize it as {"orchestrator_root": "...", "worktrees": []} before creating agent worktrees.';
|
|
1106
|
+
writeErr(`[gsd] worktree.create: invalid_manifest_json — ${hint}\n`);
|
|
1107
|
+
write(`${JSON.stringify({ ok: false, reason: 'invalid_manifest_json', hint }, null, 2)}\n`);
|
|
1108
|
+
process.exitCode = 1;
|
|
1109
|
+
return { ok: false, reason: 'invalid_manifest_json', hint };
|
|
1110
|
+
}
|
|
1111
|
+
let worktrees;
|
|
1112
|
+
let writeBack;
|
|
1113
|
+
if (Array.isArray(parsed)) {
|
|
1114
|
+
worktrees = parsed;
|
|
1115
|
+
writeBack = worktrees;
|
|
1116
|
+
}
|
|
1117
|
+
else if (parsed && typeof parsed === 'object') {
|
|
1118
|
+
const container = parsed;
|
|
1119
|
+
if (container.worktrees === undefined)
|
|
1120
|
+
container.worktrees = [];
|
|
1121
|
+
if (!Array.isArray(container.worktrees)) {
|
|
1122
|
+
const hint = 'Manifest "worktrees" must be an array. Re-initialize as {"orchestrator_root": "...", "worktrees": []}.';
|
|
1123
|
+
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
|
1124
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
|
1125
|
+
process.exitCode = 1;
|
|
1126
|
+
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
|
1127
|
+
}
|
|
1128
|
+
worktrees = container.worktrees;
|
|
1129
|
+
writeBack = container;
|
|
1130
|
+
}
|
|
1131
|
+
else {
|
|
1132
|
+
const hint = 'Manifest must be a JSON object {"worktrees": []} or a top-level array.';
|
|
1133
|
+
writeErr(`[gsd] worktree.create: manifest_shape_invalid — ${hint}\n`);
|
|
1134
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_shape_invalid', hint }, null, 2)}\n`);
|
|
1135
|
+
process.exitCode = 1;
|
|
1136
|
+
return { ok: false, reason: 'manifest_shape_invalid', hint };
|
|
1137
|
+
}
|
|
1138
|
+
// 3. Plan (pure, no I/O) — still before any git command.
|
|
1139
|
+
const plan = planWorktreeCreate({
|
|
1140
|
+
agentId: flag('--agent-id'),
|
|
1141
|
+
worktreePath: flag('--path'),
|
|
1142
|
+
branch: flag('--branch'),
|
|
1143
|
+
base: flag('--base'),
|
|
1144
|
+
});
|
|
1145
|
+
if (!plan.ok || !plan.entry) {
|
|
1146
|
+
writeErr(`[gsd] worktree.create: ${plan.reason} — ${plan.hint || ''}\n`);
|
|
1147
|
+
write(`${JSON.stringify({ ok: false, reason: plan.reason, hint: plan.hint }, null, 2)}\n`);
|
|
1148
|
+
process.exitCode = 1;
|
|
1149
|
+
return { ok: false, reason: plan.reason, hint: plan.hint };
|
|
1150
|
+
}
|
|
1151
|
+
// 3b. Optional root confinement (#2627, Phase 3 — the confinement Phase 2
|
|
1152
|
+
// deferred here from planWorktreeCreate's path-traversal guard).
|
|
1153
|
+
// planWorktreeCreate rejects a literal ".." SEGMENT, but a plain absolute
|
|
1154
|
+
// path outside the project contains no ".." and passes. Phase 3 makes the
|
|
1155
|
+
// orchestrator SPAWN executor processes into these paths, so an
|
|
1156
|
+
// unconfined --path is a write primitive aimed anywhere on the filesystem.
|
|
1157
|
+
//
|
|
1158
|
+
// The root is DECLARED by the caller (`--root`) rather than inferred: agent
|
|
1159
|
+
// worktrees legitimately live outside the orchestrator's own root (a lane
|
|
1160
|
+
// orchestrator creates siblings under the repo's .claude/worktrees/), so
|
|
1161
|
+
// there is no layout this module could derive without guessing. Absent
|
|
1162
|
+
// `--root` the behavior is exactly as shipped in Phase 2 — the
|
|
1163
|
+
// orchestrator-worktree scheduler path always passes it.
|
|
1164
|
+
//
|
|
1165
|
+
// Lexical by design: the worktree does not exist yet, so there is nothing
|
|
1166
|
+
// to realpath, and resolving only the root would not close a symlinked-leaf
|
|
1167
|
+
// hole. Pairs with the leading-dash and ".."-segment guards above.
|
|
1168
|
+
const rootFlag = flag('--root');
|
|
1169
|
+
if (rootFlag) {
|
|
1170
|
+
const absRoot = node_path_1.default.resolve(cwd, rootFlag);
|
|
1171
|
+
const absWorktree = node_path_1.default.resolve(cwd, plan.entry.worktree_path);
|
|
1172
|
+
const rel = node_path_1.default.relative(absRoot, absWorktree);
|
|
1173
|
+
// rel === '' → the worktree IS the root (would clobber the checkout)
|
|
1174
|
+
// rel === '..' / '../…' → escapes the root
|
|
1175
|
+
// path.isAbsolute(rel) → a different Windows drive or UNC root
|
|
1176
|
+
if (rel === '' || rel === '..' || rel.startsWith(`..${node_path_1.default.sep}`) || node_path_1.default.isAbsolute(rel)) {
|
|
1177
|
+
const hint = `--path must resolve INSIDE --root (root="${absRoot}", path="${absWorktree}"). A worktree outside the declared root is unreachable by manifest-scoped cleanup and would let a spawned executor write outside the project.`;
|
|
1178
|
+
writeErr(`[gsd] worktree.create: path_outside_root — ${hint}\n`);
|
|
1179
|
+
write(`${JSON.stringify({ ok: false, reason: 'path_outside_root', hint }, null, 2)}\n`);
|
|
1180
|
+
process.exitCode = 1;
|
|
1181
|
+
return { ok: false, reason: 'path_outside_root', hint };
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
// 4. Compute the deduped final manifest STRING in memory now — the ONLY
|
|
1185
|
+
// manifest work left is the guarded write in step 6, after git succeeds.
|
|
1186
|
+
// #2584 FIX 2: the on-disk entry is the SAME minimal 4-field shape
|
|
1187
|
+
// `cmdWorktreeRecordAgent` writes — never the full normalized entry with
|
|
1188
|
+
// the derived `allowed_bases` — so the two verbs never write divergent
|
|
1189
|
+
// shapes into the same manifest (the reader re-derives `allowed_bases`
|
|
1190
|
+
// from `expected_base` on load, exactly as it does for record-agent).
|
|
1191
|
+
const recorded = {
|
|
1192
|
+
agent_id: plan.entry.agent_id,
|
|
1193
|
+
worktree_path: plan.entry.worktree_path,
|
|
1194
|
+
branch: plan.entry.branch,
|
|
1195
|
+
expected_base: plan.entry.expected_base,
|
|
1196
|
+
};
|
|
1197
|
+
const dedupeKey = `${recorded.worktree_path}\0${recorded.branch}`;
|
|
1198
|
+
const alreadyPresent = worktrees.some((existing) => {
|
|
1199
|
+
const normalized = normalizeCleanupManifestEntry(existing);
|
|
1200
|
+
return normalized !== null && `${normalized.worktree_path}\0${normalized.branch}` === dedupeKey;
|
|
1201
|
+
});
|
|
1202
|
+
if (!alreadyPresent) {
|
|
1203
|
+
worktrees.push(recorded);
|
|
1204
|
+
}
|
|
1205
|
+
const manifestToWrite = `${JSON.stringify(writeBack, null, 2)}\n`;
|
|
1206
|
+
// 5. NOW run the git side effect. Every manifest problem above is caught
|
|
1207
|
+
// before this point, so a malformed/mis-shaped manifest can never leave
|
|
1208
|
+
// an unmanifested worktree on disk. `executeWorktreeCreatePlan` itself
|
|
1209
|
+
// best-effort rolls back a partial worktree on its own add-timeout /
|
|
1210
|
+
// add-failed paths (#2584 FIX 3).
|
|
1211
|
+
const result = executeWorktreeCreatePlan(plan, cwd, deps);
|
|
1212
|
+
if (!result.ok) {
|
|
1213
|
+
writeErr(`[gsd] worktree.create: ${result.reason} — ${result.stderr || ''}\n`);
|
|
1214
|
+
write(`${JSON.stringify({ ok: false, reason: result.reason, stderr: result.stderr }, null, 2)}\n`);
|
|
1215
|
+
process.exitCode = 1;
|
|
1216
|
+
return { ok: false, reason: result.reason, stderr: result.stderr };
|
|
1217
|
+
}
|
|
1218
|
+
// 6. Write the pre-computed manifest string, guarded. A write failure here
|
|
1219
|
+
// means a REAL worktree now exists with NO manifest entry — roll it back
|
|
1220
|
+
// (best-effort) rather than leaving an orphan cleanup-wave/reap-orphans
|
|
1221
|
+
// can never reach (#2584 FIX 1). Never throws past this function.
|
|
1222
|
+
const writeFile = deps.writeFile || ((p, content) => node_fs_1.default.writeFileSync(p, content, 'utf8'));
|
|
1223
|
+
try {
|
|
1224
|
+
writeFile(resolved, manifestToWrite);
|
|
1225
|
+
}
|
|
1226
|
+
catch (err) {
|
|
1227
|
+
const execGit = deps.execGit || execGitDefault;
|
|
1228
|
+
rollbackPartialWorktree(execGit, plan.entry.worktree_path, cwd);
|
|
1229
|
+
const hint = `The worktree was created but the manifest write failed (${err.message}); rolled back the worktree via a best-effort 'git worktree remove --force'.`;
|
|
1230
|
+
writeErr(`[gsd] worktree.create: manifest_write_failed — ${hint}\n`);
|
|
1231
|
+
write(`${JSON.stringify({ ok: false, reason: 'manifest_write_failed', hint, error: err.message }, null, 2)}\n`);
|
|
1232
|
+
process.exitCode = 1;
|
|
1233
|
+
return { ok: false, reason: 'manifest_write_failed', hint };
|
|
1234
|
+
}
|
|
1235
|
+
write(`${JSON.stringify({ ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved }, null, 2)}\n`);
|
|
1236
|
+
return { ok: true, reason: 'created', entry: recorded, cwd: result.cwd, manifest_path: resolved };
|
|
1237
|
+
}
|
|
896
1238
|
/**
|
|
897
1239
|
* Reap orphaned linked worktrees whose lock owner process is dead, whose
|
|
898
1240
|
* branch tip is fully merged into the default branch, and whose lock file
|
|
@@ -1180,6 +1522,9 @@ module.exports = {
|
|
|
1180
1522
|
cmdWorktreeCleanupWave,
|
|
1181
1523
|
planWorktreeRecordAgent,
|
|
1182
1524
|
cmdWorktreeRecordAgent,
|
|
1525
|
+
planWorktreeCreate,
|
|
1526
|
+
executeWorktreeCreatePlan,
|
|
1527
|
+
cmdWorktreeCreate,
|
|
1183
1528
|
reapOrphanWorktrees,
|
|
1184
1529
|
cmdWorktreeReapOrphans,
|
|
1185
1530
|
resolveWorktreeRoot,
|