@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.
Files changed (174) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +31 -1
  4. package/agents/gsd-code-fixer.md +1 -1
  5. package/agents/gsd-codebase-mapper.md +1 -1
  6. package/agents/gsd-debug-session-manager.md +36 -0
  7. package/agents/gsd-executor.md +20 -7
  8. package/agents/gsd-intel-updater.md +3 -3
  9. package/agents/gsd-phase-researcher.md +4 -2
  10. package/agents/gsd-plan-checker.md +20 -0
  11. package/agents/gsd-planner.md +15 -23
  12. package/agents/gsd-project-researcher.md +2 -2
  13. package/agents/gsd-ui-auditor.md +0 -40
  14. package/bin/install.js +186 -55
  15. package/commands/gsd/plan-review-convergence.md +5 -1
  16. package/gsd-core/bin/gsd-tools.cjs +849 -2
  17. package/gsd-core/bin/lib/api-coverage.cjs +22 -8
  18. package/gsd-core/bin/lib/audit.cjs +8 -8
  19. package/gsd-core/bin/lib/capability-consent.cjs +40 -1
  20. package/gsd-core/bin/lib/capability-lifecycle.cjs +58 -0
  21. package/gsd-core/bin/lib/capability-loader.cjs +23 -1
  22. package/gsd-core/bin/lib/capability-registry.cjs +1353 -132
  23. package/gsd-core/bin/lib/capability-trust.cjs +468 -33
  24. package/gsd-core/bin/lib/capability-validator.cjs +882 -6
  25. package/gsd-core/bin/lib/check-command-router.cjs +12 -2
  26. package/gsd-core/bin/lib/cjs-command-router-adapter.cjs +15 -0
  27. package/gsd-core/bin/lib/claude-orchestration-command-router.cjs +102 -12
  28. package/gsd-core/bin/lib/claude-orchestration.cjs +125 -22
  29. package/gsd-core/bin/lib/commands.cjs +246 -18
  30. package/gsd-core/bin/lib/config-loader.cjs +200 -28
  31. package/gsd-core/bin/lib/config.cjs +90 -5
  32. package/gsd-core/bin/lib/estimate-cli.cjs +336 -0
  33. package/gsd-core/bin/lib/frontmatter.cjs +125 -15
  34. package/gsd-core/bin/lib/host-integration.cjs +215 -8
  35. package/gsd-core/bin/lib/init.cjs +44 -19
  36. package/gsd-core/bin/lib/install-engine.cjs +1 -0
  37. package/gsd-core/bin/lib/milestone.cjs +5 -5
  38. package/gsd-core/bin/lib/model-catalog.cjs +51 -1
  39. package/gsd-core/bin/lib/observability/logger.cjs +7 -2
  40. package/gsd-core/bin/lib/phase-command-router.cjs +10 -1
  41. package/gsd-core/bin/lib/phase-estimation.cjs +398 -0
  42. package/gsd-core/bin/lib/phase-id.cjs +278 -5
  43. package/gsd-core/bin/lib/phase.cjs +57 -5
  44. package/gsd-core/bin/lib/plan-drift-guard.cjs +1 -1
  45. package/gsd-core/bin/lib/plan-scan.cjs +1 -1
  46. package/gsd-core/bin/lib/planning-workspace.cjs +9 -2
  47. package/gsd-core/bin/lib/profile-output.cjs +34 -8
  48. package/gsd-core/bin/lib/review-lane-descriptor.cjs +927 -0
  49. package/gsd-core/bin/lib/review-lane-invocation.cjs +348 -0
  50. package/gsd-core/bin/lib/review-lane-runner.cjs +594 -0
  51. package/gsd-core/bin/lib/review-reviewer-selection.cjs +114 -32
  52. package/gsd-core/bin/lib/roadmap-parser.cjs +54 -6
  53. package/gsd-core/bin/lib/roadmap.cjs +10 -4
  54. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +31 -4
  55. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +1 -1
  56. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +140 -0
  57. package/gsd-core/bin/lib/runtime-name-policy.cjs +15 -2
  58. package/gsd-core/bin/lib/smart-entry.cjs +1 -1
  59. package/gsd-core/bin/lib/state-document.cjs +164 -20
  60. package/gsd-core/bin/lib/state-transition.cjs +28 -10
  61. package/gsd-core/bin/lib/state.cjs +141 -21
  62. package/gsd-core/bin/lib/uat-predicate.cjs +6 -4
  63. package/gsd-core/bin/lib/uat.cjs +9 -7
  64. package/gsd-core/bin/lib/ui-consideration-probe.cjs +2 -2
  65. package/gsd-core/bin/lib/unusable-input.cjs +216 -0
  66. package/gsd-core/bin/lib/validate.cjs +32 -0
  67. package/gsd-core/bin/lib/verification.cjs +51 -14
  68. package/gsd-core/bin/lib/verify.cjs +128 -20
  69. package/gsd-core/bin/lib/worktree-safety.cjs +360 -15
  70. package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
  71. package/gsd-core/bin/shared/config-schema.manifest.json +1 -13
  72. package/gsd-core/bin/shared/model-catalog.json +5 -0
  73. package/gsd-core/bin/shared/runtime-aliases.manifest.json +5 -0
  74. package/gsd-core/references/context-budget.md +40 -0
  75. package/gsd-core/references/gate-prompts.md +6 -3
  76. package/gsd-core/references/model-profile-resolution.md +64 -13
  77. package/gsd-core/references/offer-next.md +88 -0
  78. package/gsd-core/references/planning-config.md +2 -1
  79. package/gsd-core/references/reviewer-instances.md +28 -21
  80. package/gsd-core/references/runtime-aware-dispatch.md +42 -0
  81. package/gsd-core/references/ui-consideration-probe.md +2 -2
  82. package/gsd-core/references/worktree-branch-check.md +4 -4
  83. package/gsd-core/templates/summary-minimal.md +4 -0
  84. package/gsd-core/templates/summary-standard.md +4 -0
  85. package/gsd-core/templates/summary.md +7 -0
  86. package/gsd-core/workflows/ai-integration-phase.md +4 -4
  87. package/gsd-core/workflows/audit-fix.md +4 -0
  88. package/gsd-core/workflows/audit-milestone.md +8 -0
  89. package/gsd-core/workflows/autonomous.md +19 -15
  90. package/gsd-core/workflows/check-todos.md +2 -2
  91. package/gsd-core/workflows/code-review-fix.md +14 -6
  92. package/gsd-core/workflows/code-review.md +76 -19
  93. package/gsd-core/workflows/debug.md +10 -2
  94. package/gsd-core/workflows/diagnose-issues.md +4 -0
  95. package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -4
  96. package/gsd-core/workflows/discuss-phase/modes/auto.md +0 -6
  97. package/gsd-core/workflows/discuss-phase-assumptions.md +15 -9
  98. package/gsd-core/workflows/discuss-phase.md +2 -2
  99. package/gsd-core/workflows/docs-update.md +8 -0
  100. package/gsd-core/workflows/eval-review.md +1 -1
  101. package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +4 -0
  102. package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +160 -0
  103. package/gsd-core/workflows/execute-phase.md +85 -115
  104. package/gsd-core/workflows/execute-plan.md +5 -4
  105. package/gsd-core/workflows/explore.md +4 -0
  106. package/gsd-core/workflows/extract-learnings.md +21 -0
  107. package/gsd-core/workflows/help/modes/full.md +3 -3
  108. package/gsd-core/workflows/import.md +4 -1
  109. package/gsd-core/workflows/ingest-docs.md +4 -0
  110. package/gsd-core/workflows/map-codebase.md +13 -6
  111. package/gsd-core/workflows/new-milestone.md +10 -2
  112. package/gsd-core/workflows/new-project.md +11 -4
  113. package/gsd-core/workflows/next.md +5 -2
  114. package/gsd-core/workflows/plan-phase.md +42 -46
  115. package/gsd-core/workflows/plan-review-convergence.md +18 -14
  116. package/gsd-core/workflows/progress.md +1 -1
  117. package/gsd-core/workflows/quick.md +14 -3
  118. package/gsd-core/workflows/review.md +146 -575
  119. package/gsd-core/workflows/scan.md +9 -1
  120. package/gsd-core/workflows/secure-phase.md +10 -2
  121. package/gsd-core/workflows/ship.md +41 -11
  122. package/gsd-core/workflows/smart-entry.md +1 -1
  123. package/gsd-core/workflows/ui-phase.md +8 -1
  124. package/gsd-core/workflows/ui-review.md +8 -1
  125. package/gsd-core/workflows/update.md +104 -5
  126. package/gsd-core/workflows/validate-phase.md +10 -2
  127. package/gsd-core/workflows/verify-work.md +8 -1
  128. package/hooks/dist/gsd-cursor-session-start.js +6 -2
  129. package/hooks/dist/gsd-cursor-stop.js +6 -2
  130. package/hooks/dist/gsd-cursor-subagent-start.js +6 -2
  131. package/hooks/dist/gsd-graphify-update.sh +9 -0
  132. package/hooks/dist/gsd-phase-boundary.sh +14 -2
  133. package/hooks/dist/gsd-prompt-guard.js +101 -2
  134. package/hooks/dist/gsd-read-guard.js +100 -2
  135. package/hooks/dist/gsd-read-injection-scanner.js +109 -2
  136. package/hooks/dist/gsd-statusline.js +9 -6
  137. package/hooks/dist/gsd-workflow-guard.js +110 -6
  138. package/hooks/dist/gsd-worktree-path-guard.js +132 -8
  139. package/hooks/dist/lib/cursor-workspace.js +74 -0
  140. package/hooks/gsd-cursor-session-start.js +6 -2
  141. package/hooks/gsd-cursor-stop.js +6 -2
  142. package/hooks/gsd-cursor-subagent-start.js +6 -2
  143. package/hooks/gsd-graphify-update.sh +9 -0
  144. package/hooks/gsd-phase-boundary.sh +14 -2
  145. package/hooks/gsd-prompt-guard.js +101 -2
  146. package/hooks/gsd-read-guard.js +100 -2
  147. package/hooks/gsd-read-injection-scanner.js +109 -2
  148. package/hooks/gsd-statusline.js +9 -6
  149. package/hooks/gsd-workflow-guard.js +110 -6
  150. package/hooks/gsd-worktree-path-guard.js +132 -8
  151. package/hooks/lib/cursor-workspace.js +74 -0
  152. package/package.json +7 -7
  153. package/pi/gsd.cjs +26 -1
  154. package/scripts/check-coverage-gate.cjs +51 -0
  155. package/scripts/check-glossary-refs.cjs +24 -0
  156. package/scripts/ci-test-scope.cjs +67 -17
  157. package/scripts/gen-adr-index.cjs +6 -4
  158. package/scripts/gen-capability-matrix.cjs +26 -2
  159. package/scripts/gen-capability-registry.cjs +132 -34
  160. package/scripts/gen-emitted-baseline.cjs +145 -0
  161. package/scripts/lint-compiled-artifact-sync.cjs +146 -0
  162. package/scripts/lint-emitted-drift-ack.cjs +149 -0
  163. package/scripts/lint-fix-has-regression-test.cjs +131 -0
  164. package/scripts/lint-resolution-provenance.cjs +9 -0
  165. package/scripts/mutation-matrix.cjs +4 -0
  166. package/scripts/prompt-injection-scan.sh +6 -0
  167. package/scripts/registry-schema.cjs +57 -8
  168. package/scripts/release-notes/conventional-title.cjs +19 -1
  169. package/scripts/release-notes/format-github-release-notes.cjs +7 -3
  170. package/scripts/workflow-size.cjs +16 -8
  171. package/skills/gsd-plan-review-convergence/SKILL.md +5 -1
  172. package/vscode/package.json +1 -1
  173. package/scripts/gen-golden-install-parity-zcode.cjs +0 -77
  174. 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
- function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
67
- if (!summaryPath) {
68
- error('summary-path required');
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
- const result = {
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 && !filePath.startsWith('http') && filePath.includes('/')) {
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.join(cwd, file))) {
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
- const result = { passed, checks, errors };
148
- output(result, raw, passed ? 'passed' : 'failed');
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
- const fm = extractFrontmatter(content);
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
- return [];
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 content = node_fs_1.default.readFileSync(node_path_1.default.join(phasePath, plan), 'utf-8');
1219
- const fmData = extractFrontmatter(content);
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 (!/^worktree-agent-[A-Za-z0-9._/-]+$/.test(branch))
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
- // Fail-closed on timeout/fatal git errors: if we cannot determine whether
453
- // the file is committed, do NOT rescue it (rescuing an actually-committed
454
- // file would re-create the untracked collision; the merge will surface the
455
- // issue). The cleanup will be blocked by merge_failed in the worst case,
456
- // which is the observable behaviour before this fix and is recoverable.
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 !== 1) {
459
- // Rescue only when cat-file definitively reports the object is absent (exit 1).
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 ^worktree-agent-[A-Za-z0-9._/-]+$ (got branch="${branch}"). Fix the field and re-run.`,
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,