mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -18,15 +18,33 @@
18
18
  * Detection: when a worktree is the active work tree (it exists on disk and is
19
19
  * distinct from `cwd`), inspect `git -C <mainCheckout> status --porcelain`. Any
20
20
  * **tracked-path** change (modified, staged, deleted, renamed) in the main
21
- * checkout is the wrong-tree signal. Untracked files (`??`) are ignored — they
22
- * are scratch artifacts, not relocated Story work, and flagging them would
23
- * produce false positives on every run.
24
- *
25
- * The guard runs before the gate chain. On detection it posts a `friction`
26
- * structured comment naming the stray files and throws, so the operator/agent
27
- * relocates the edits into the worktree before close proceeds. This is the
28
- * load-bearing, detection-based fix: it fires regardless of whether the agent
29
- * ever realized it edited the wrong tree.
21
+ * checkout is the candidate wrong-tree signal. Untracked files (`??`) are
22
+ * ignored — they are scratch artifacts, not relocated Story work, and flagging
23
+ * them would produce false positives on every run.
24
+ *
25
+ * Story #4424 the raw "main checkout is dirty" signal is too coarse for
26
+ * multi-session operation: uncommitted tracked-path changes in the main
27
+ * checkout can belong to a **different concurrent session** and have nothing to
28
+ * do with the Story being closed (framework-gap #4420). The guard therefore
29
+ * intersects the main-checkout stray tracked paths with the **Story's own
30
+ * diff-path set** (paths changed on the worktree branch vs the base branch,
31
+ * plus the worktree's uncommitted tracked changes):
32
+ * - **Overlap non-empty** → genuine wrong-tree signal: post a `friction`
33
+ * comment and throw (abort close), exactly as before.
34
+ * - **Disjoint** (Story diff-path set non-empty, no shared path) → another
35
+ * session's business: post a `friction` comment whose wording states close
36
+ * PROCEEDED and names the disjoint stray files (telemetry for
37
+ * concurrent-session hygiene), then return without throwing.
38
+ * - **Empty Story diff-path set** with stray paths → keep the abort (the
39
+ * #3364 silent empty-diff backstop; an empty set makes every stray path
40
+ * disjoint-by-definition, which must not downgrade the guard).
41
+ * - **Story-diff probe failure** with stray paths present → fall back to the
42
+ * coarse abort rather than fail-open, so a probe hiccup never converts a
43
+ * would-be abort into a silent pass.
44
+ *
45
+ * The main-checkout status probe keeps its fail-open semantics: a probe failure
46
+ * downgrades to a warning and skips the guard — it never blocks an otherwise
47
+ * valid close.
30
48
  */
31
49
 
32
50
  import path from 'node:path';
@@ -44,24 +62,31 @@ import { postStructuredComment } from '../../ticketing/state.js';
44
62
  */
45
63
  export function parsePorcelainStatus(raw) {
46
64
  if (typeof raw !== 'string' || raw.trim() === '') return [];
65
+ const unquote = (p) =>
66
+ p.startsWith('"') && p.endsWith('"') ? p.slice(1, -1) : p;
47
67
  return raw
48
68
  .split('\n')
49
69
  .map((line) => line.replace(/\r$/, ''))
50
70
  .filter((line) => line.length > 0)
51
- .map((line) => {
71
+ .flatMap((line) => {
52
72
  // First two chars are the status code; path begins at column 3.
53
73
  const status = line.slice(0, 2);
54
- let filePath = line.slice(3).trim();
55
- // Renames/copies render as "orig -> dest"; keep the destination.
56
- const arrowIdx = filePath.indexOf(' -> ');
74
+ const rawPath = line.slice(3).trim();
75
+ // Renames/copies render as "orig -> dest". Keep BOTH sides: the
76
+ // Story-diff intersection downstream must match a rename whose
77
+ // ORIGIN path is in the Story's footprint — collapsing to the
78
+ // destination only made such a stray downgrade to proceed.
79
+ const arrowIdx = rawPath.indexOf(' -> ');
57
80
  if (arrowIdx !== -1) {
58
- filePath = filePath.slice(arrowIdx + 4).trim();
81
+ const origin = unquote(rawPath.slice(0, arrowIdx).trim());
82
+ const dest = unquote(rawPath.slice(arrowIdx + 4).trim());
83
+ return [
84
+ { status, path: origin, untracked: status === '??' },
85
+ { status, path: dest, untracked: status === '??' },
86
+ ];
59
87
  }
60
88
  // Porcelain may quote paths containing special chars; strip the quotes.
61
- if (filePath.startsWith('"') && filePath.endsWith('"')) {
62
- filePath = filePath.slice(1, -1);
63
- }
64
- return { status, path: filePath, untracked: status === '??' };
89
+ return [{ status, path: unquote(rawPath), untracked: status === '??' }];
65
90
  });
66
91
  }
67
92
 
@@ -84,6 +109,20 @@ export function collectStrayTrackedPaths(entries) {
84
109
  .sort();
85
110
  }
86
111
 
112
+ /**
113
+ * Parse `git diff --name-only` output into a list of repo-relative paths.
114
+ *
115
+ * @param {string} raw - Raw `git diff --name-only` stdout (may be empty).
116
+ * @returns {string[]}
117
+ */
118
+ export function parseDiffNameOnly(raw) {
119
+ if (typeof raw !== 'string' || raw.trim() === '') return [];
120
+ return raw
121
+ .split('\n')
122
+ .map((line) => line.replace(/\r$/, '').trim())
123
+ .filter(Boolean);
124
+ }
125
+
87
126
  /**
88
127
  * Decide whether the wrong-tree guard applies for this close.
89
128
  *
@@ -101,7 +140,77 @@ export function guardApplies({ cwd, worktreePath }) {
101
140
  }
102
141
 
103
142
  /**
104
- * Format the `friction` finding body naming the stray files.
143
+ * Compute the Story's own diff-path set from the worktree: the union of paths
144
+ * changed on the worktree branch vs the base branch (committed diff) and the
145
+ * worktree's uncommitted tracked changes.
146
+ *
147
+ * Both probes are keyed off the worktree, so the returned paths are
148
+ * repo-relative and directly comparable to the main-checkout porcelain paths.
149
+ * A probe failure (thrown error or non-zero git exit) returns `{ ok: false }`
150
+ * so the caller can fall back to the coarse abort rather than fail-open.
151
+ *
152
+ * @param {{ worktreePath: string, baseBranch: string, gitSpawnFn: Function }} opts
153
+ * @returns {{ ok: boolean, paths: string[], error?: string }}
154
+ */
155
+ export function collectStoryDiffPaths({
156
+ worktreePath,
157
+ baseBranch,
158
+ gitSpawnFn,
159
+ }) {
160
+ let diffResult;
161
+ try {
162
+ diffResult = gitSpawnFn(
163
+ worktreePath,
164
+ 'diff',
165
+ '--name-only',
166
+ `${baseBranch}...HEAD`,
167
+ );
168
+ } catch (err) {
169
+ return { ok: false, paths: [], error: err?.message ?? String(err) };
170
+ }
171
+ if (!diffResult || diffResult.status !== 0) {
172
+ return { ok: false, paths: [], error: diffResult?.stderr || '(no stderr)' };
173
+ }
174
+
175
+ let statusResult;
176
+ try {
177
+ statusResult = gitSpawnFn(worktreePath, 'status', '--porcelain');
178
+ } catch (err) {
179
+ return { ok: false, paths: [], error: err?.message ?? String(err) };
180
+ }
181
+ if (!statusResult || statusResult.status !== 0) {
182
+ return {
183
+ ok: false,
184
+ paths: [],
185
+ error: statusResult?.stderr || '(no stderr)',
186
+ };
187
+ }
188
+
189
+ const committed = parseDiffNameOnly(diffResult.stdout ?? '');
190
+ const uncommitted = collectStrayTrackedPaths(
191
+ parsePorcelainStatus(statusResult.stdout ?? ''),
192
+ );
193
+ const union = Array.from(new Set([...committed, ...uncommitted])).sort();
194
+ return { ok: true, paths: union };
195
+ }
196
+
197
+ /**
198
+ * Repo-relative path intersection. Both sides are git-emitted repo-relative
199
+ * paths (forward-slash separated on every platform), so plain string equality
200
+ * is correct regardless of which tree the probe ran in.
201
+ *
202
+ * @param {string[]} mainStray
203
+ * @param {string[]} storyPaths
204
+ * @returns {string[]} sorted intersection.
205
+ */
206
+ export function intersectPaths(mainStray, storyPaths) {
207
+ const set = new Set(storyPaths);
208
+ return mainStray.filter((p) => set.has(p)).sort();
209
+ }
210
+
211
+ /**
212
+ * Format the `friction` finding body for an ABORT (overlap, empty-diff
213
+ * backstop, or diff-probe-failure fallback) naming the stray files.
105
214
  *
106
215
  * @param {{ storyId: number, strayFiles: string[], worktreePath: string }} opts
107
216
  * @returns {string}
@@ -111,7 +220,8 @@ export function formatWrongTreeFinding({ storyId, strayFiles, worktreePath }) {
111
220
  return (
112
221
  `### wrong-tree edit detected (close aborted)\n\n` +
113
222
  `Story #${storyId}: the main checkout has uncommitted changes under ` +
114
- `tracked paths while the active work tree is the per-Story worktree:\n\n` +
223
+ `tracked paths that intersect the Story's own diff-path set while the ` +
224
+ `active work tree is the per-Story worktree:\n\n` +
115
225
  `\`${worktreePath}\`\n\n` +
116
226
  `This is the wrong-tree failure mode: edits intended for the worktree ` +
117
227
  `landed in the main checkout instead (on Windows, \`cd\` steers the Bash ` +
@@ -127,31 +237,182 @@ export function formatWrongTreeFinding({ storyId, strayFiles, worktreePath }) {
127
237
  }
128
238
 
129
239
  /**
130
- * Run the wrong-tree detection guard for `single-story-close`.
240
+ * Format the `friction` finding body for the DOWNGRADE outcome — the main
241
+ * checkout has stray tracked paths, but they are fully disjoint from the
242
+ * Story's non-empty diff-path set (another concurrent session's work). Close
243
+ * proceeds; this comment is telemetry for concurrent-session hygiene.
131
244
  *
132
- * When a worktree is the active work tree and the main checkout has uncommitted
133
- * tracked-path changes, posts a `friction` comment naming the stray files and
134
- * throws to abort close. Otherwise returns a clean result.
245
+ * @param {{ storyId: number, strayFiles: string[], worktreePath: string }} opts
246
+ * @returns {string}
247
+ */
248
+ export function formatWrongTreeDowngradeFinding({
249
+ storyId,
250
+ strayFiles,
251
+ worktreePath,
252
+ }) {
253
+ const list = strayFiles.map((f) => `- \`${f}\``).join('\n');
254
+ return (
255
+ `### wrong-tree probe: disjoint main-checkout dirt (close proceeded)\n\n` +
256
+ `Story #${storyId}: the main checkout has uncommitted changes under ` +
257
+ `tracked paths while the active work tree is the per-Story worktree:\n\n` +
258
+ `\`${worktreePath}\`\n\n` +
259
+ `These stray paths are **fully disjoint** from the Story's own diff-path ` +
260
+ `set (committed diff vs base + uncommitted worktree changes), so they ` +
261
+ `belong to another concurrent session rather than this Story's work. ` +
262
+ `Close **proceeded** — this comment is telemetry for concurrent-session ` +
263
+ `hygiene, not an abort.\n\n` +
264
+ `**Disjoint stray files in the main checkout:**\n\n${list}\n`
265
+ );
266
+ }
267
+
268
+ /**
269
+ * Post the ABORT friction comment and throw to abort close. Shared by the
270
+ * overlap, empty-diff-backstop, and diff-probe-failure-fallback paths.
135
271
  *
136
- * Unlike the soft drift-detection phase, this guard is **load-bearing**: a
137
- * positive detection aborts close (throws). Failures to run git, however, are
138
- * swallowed as warnings — a probe failure must not block an otherwise valid
139
- * close (fail-open on the probe, fail-closed on a confirmed positive).
272
+ * @param {{
273
+ * storyId: number,
274
+ * strayFiles: string[],
275
+ * worktreePath: string,
276
+ * provider: object,
277
+ * progress: (tag: string, msg: string) => void,
278
+ * reasonTag: string,
279
+ * }} opts
280
+ * @throws {Error} always.
281
+ */
282
+ async function abortWrongTree({
283
+ storyId,
284
+ strayFiles,
285
+ worktreePath,
286
+ provider,
287
+ progress,
288
+ reasonTag,
289
+ }) {
290
+ const body = formatWrongTreeFinding({ storyId, strayFiles, worktreePath });
291
+ try {
292
+ await postStructuredComment(provider, storyId, 'friction', body);
293
+ progress(
294
+ 'WRONG-TREE',
295
+ `🛑 Wrong-tree edits detected (${reasonTag}): ${strayFiles.length} stray file(s). Posted friction comment to Story #${storyId}.`,
296
+ );
297
+ } catch (err) {
298
+ progress(
299
+ 'WRONG-TREE',
300
+ `⚠️ Failed to post wrong-tree friction comment: ${err?.message ?? err}`,
301
+ );
302
+ }
303
+
304
+ throw new Error(
305
+ `[single-story-close] Wrong-tree edits detected (${reasonTag}): the main ` +
306
+ `checkout has uncommitted tracked-path changes intersecting the Story's ` +
307
+ `diff-path set while the worktree (${worktreePath}) is the active work ` +
308
+ `tree. Close aborted to avoid an empty-diff PR. Stray files: ` +
309
+ `${strayFiles.join(', ')}. Relocate the edits into the worktree, ` +
310
+ `restore the main checkout, then re-run /single-story-deliver.`,
311
+ );
312
+ }
313
+
314
+ /**
315
+ * Post the DOWNGRADE friction comment (telemetry) without throwing. Best-effort:
316
+ * a post failure is logged but never converts the proceed into an abort.
317
+ *
318
+ * @param {{
319
+ * storyId: number,
320
+ * strayFiles: string[],
321
+ * worktreePath: string,
322
+ * provider: object,
323
+ * progress: (tag: string, msg: string) => void,
324
+ * }} opts
325
+ * @returns {Promise<void>}
326
+ */
327
+ async function reportDisjointDirt({
328
+ storyId,
329
+ strayFiles,
330
+ worktreePath,
331
+ provider,
332
+ progress,
333
+ }) {
334
+ const body = formatWrongTreeDowngradeFinding({
335
+ storyId,
336
+ strayFiles,
337
+ worktreePath,
338
+ });
339
+ try {
340
+ await postStructuredComment(provider, storyId, 'friction', body);
341
+ progress(
342
+ 'WRONG-TREE',
343
+ `⚠️ Main-checkout dirt disjoint from Story diff (${strayFiles.length} stray file(s) belong to another session). Close proceeds; posted telemetry friction comment to Story #${storyId}.`,
344
+ );
345
+ } catch (err) {
346
+ progress(
347
+ 'WRONG-TREE',
348
+ `⚠️ Failed to post disjoint-dirt friction comment: ${err?.message ?? err}`,
349
+ );
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Probe the main checkout for stray tracked-path changes.
355
+ *
356
+ * Fail-open on the probe: a thrown error or non-zero git exit returns
357
+ * `{ ok: false }` so the caller skips the guard rather than blocking a valid
358
+ * close on a git hiccup.
359
+ *
360
+ * @param {{ cwd: string, gitSpawnFn: Function, progress: Function }} opts
361
+ * @returns {{ ok: boolean, strayFiles: string[] }}
362
+ */
363
+ function probeMainCheckoutStray({ cwd, gitSpawnFn, progress }) {
364
+ let result;
365
+ try {
366
+ result = gitSpawnFn(cwd, 'status', '--porcelain');
367
+ } catch (err) {
368
+ progress(
369
+ 'WRONG-TREE',
370
+ `⚠️ Could not probe main checkout status: ${err?.message ?? err}. Skipping guard.`,
371
+ );
372
+ return { ok: false, strayFiles: [] };
373
+ }
374
+
375
+ if (!result || result.status !== 0) {
376
+ progress(
377
+ 'WRONG-TREE',
378
+ `⚠️ git status probe exited non-zero: ${result?.stderr || '(no stderr)'}. Skipping guard.`,
379
+ );
380
+ return { ok: false, strayFiles: [] };
381
+ }
382
+
383
+ const strayFiles = collectStrayTrackedPaths(
384
+ parsePorcelainStatus(result.stdout ?? ''),
385
+ );
386
+ return { ok: true, strayFiles };
387
+ }
388
+
389
+ /**
390
+ * Run the wrong-tree detection guard for `single-story-close`.
391
+ *
392
+ * When a worktree is the active work tree and the main checkout has stray
393
+ * tracked-path changes, the guard intersects those paths with the Story's own
394
+ * diff-path set (Story #4424): overlap aborts close (throws), a disjoint set
395
+ * downgrades to a proceed-with-telemetry `friction` comment, an empty Story
396
+ * diff-path set keeps the abort (empty-diff backstop), and a Story-diff probe
397
+ * failure falls back to the coarse abort. A main-checkout status probe failure
398
+ * skips the guard (fail-open on the probe, fail-closed on a confirmed positive).
140
399
  *
141
400
  * @param {{
142
401
  * cwd: string,
143
402
  * worktreePath: string|null,
403
+ * baseBranch: string,
144
404
  * storyId: number,
145
405
  * provider: object,
146
406
  * progress: (tag: string, msg: string) => void,
147
407
  * gitSpawn?: Function,
148
408
  * }} args
149
- * @returns {Promise<{ applied: boolean, strayFiles: string[] }>}
150
- * @throws {Error} when stray tracked-path edits are detected in the main checkout.
409
+ * @returns {Promise<{ applied: boolean, strayFiles: string[], overlap?: string[] }>}
410
+ * @throws {Error} when overlapping stray edits are detected in the main checkout.
151
411
  */
152
412
  export async function runWrongTreeGuardPhase({
153
413
  cwd,
154
414
  worktreePath,
415
+ baseBranch = 'main',
155
416
  storyId,
156
417
  provider,
157
418
  progress,
@@ -171,55 +432,78 @@ export async function runWrongTreeGuardPhase({
171
432
  `Checking main checkout for stray edits (worktree-isolated Story #${storyId})...`,
172
433
  );
173
434
 
174
- let result;
175
- try {
176
- result = gitSpawnFn(cwd, 'status', '--porcelain');
177
- } catch (err) {
178
- // Probe failure is non-fatal — never block a valid close on a git hiccup.
179
- progress(
180
- 'WRONG-TREE',
181
- `⚠️ Could not probe main checkout status: ${err?.message ?? err}. Skipping guard.`,
182
- );
183
- return { applied: false, strayFiles: [] };
184
- }
185
-
186
- if (!result || result.status !== 0) {
187
- progress(
188
- 'WRONG-TREE',
189
- `⚠️ git status probe exited non-zero: ${result?.stderr || '(no stderr)'}. Skipping guard.`,
190
- );
435
+ const mainProbe = probeMainCheckoutStray({ cwd, gitSpawnFn, progress });
436
+ if (!mainProbe.ok) {
191
437
  return { applied: false, strayFiles: [] };
192
438
  }
193
439
 
194
- const strayFiles = collectStrayTrackedPaths(
195
- parsePorcelainStatus(result.stdout ?? ''),
196
- );
197
-
440
+ const strayFiles = mainProbe.strayFiles;
198
441
  if (strayFiles.length === 0) {
199
442
  progress('WRONG-TREE', '✅ Main checkout clean — no wrong-tree edits.');
200
- return { applied: true, strayFiles: [] };
443
+ return { applied: true, strayFiles: [], overlap: [] };
201
444
  }
202
445
 
203
- // Confirmed positive: post a friction comment and abort close.
204
- const body = formatWrongTreeFinding({ storyId, strayFiles, worktreePath });
205
- try {
206
- await postStructuredComment(provider, storyId, 'friction', body);
207
- progress(
208
- 'WRONG-TREE',
209
- `🛑 Wrong-tree edits detected: ${strayFiles.length} stray file(s). Posted friction comment to Story #${storyId}.`,
210
- );
211
- } catch (err) {
446
+ // Stray paths present intersect with the Story's own diff-path set to tell
447
+ // this Story's misplaced work apart from a concurrent session's dirt.
448
+ const storyDiff = collectStoryDiffPaths({
449
+ worktreePath,
450
+ baseBranch,
451
+ gitSpawnFn,
452
+ });
453
+
454
+ if (!storyDiff.ok) {
455
+ // A failed Story-diff probe must NOT silently convert a would-be abort into
456
+ // a pass — fall back to the coarse (#3364) abort behavior.
212
457
  progress(
213
458
  'WRONG-TREE',
214
- `⚠️ Failed to post wrong-tree friction comment: ${err?.message ?? err}`,
459
+ `⚠️ Could not probe Story diff paths (${storyDiff.error}); falling back to coarse abort.`,
215
460
  );
461
+ await abortWrongTree({
462
+ storyId,
463
+ strayFiles,
464
+ worktreePath,
465
+ provider,
466
+ progress,
467
+ reasonTag: 'diff-probe-failed',
468
+ });
216
469
  }
217
470
 
218
- throw new Error(
219
- `[single-story-close] Wrong-tree edits detected: the main checkout has ` +
220
- `uncommitted tracked-path changes while the worktree (${worktreePath}) is ` +
221
- `the active work tree. Close aborted to avoid an empty-diff PR. Stray ` +
222
- `files: ${strayFiles.join(', ')}. Relocate the edits into the worktree, ` +
223
- `restore the main checkout, then re-run /single-story-deliver.`,
224
- );
471
+ if (storyDiff.paths.length === 0) {
472
+ // Empty-diff backstop: an empty Story diff-path set makes every stray path
473
+ // disjoint-by-definition; that is exactly the #3364 silent empty-diff
474
+ // failure mode, so keep the abort.
475
+ await abortWrongTree({
476
+ storyId,
477
+ strayFiles,
478
+ worktreePath,
479
+ provider,
480
+ progress,
481
+ reasonTag: 'empty-diff-backstop',
482
+ });
483
+ }
484
+
485
+ const overlap = intersectPaths(strayFiles, storyDiff.paths);
486
+
487
+ if (overlap.length > 0) {
488
+ await abortWrongTree({
489
+ storyId,
490
+ strayFiles,
491
+ worktreePath,
492
+ provider,
493
+ progress,
494
+ reasonTag: 'overlap',
495
+ });
496
+ }
497
+
498
+ // Disjoint: the stray paths belong to another session. Post a proceed-wording
499
+ // friction comment (telemetry) and return without throwing.
500
+ await reportDisjointDirt({
501
+ storyId,
502
+ strayFiles,
503
+ worktreePath,
504
+ provider,
505
+ progress,
506
+ });
507
+
508
+ return { applied: true, strayFiles, overlap: [] };
225
509
  }
@@ -15,6 +15,7 @@ import { runAutoMergePhase } from './phases/auto-merge.js';
15
15
  import { runBaseSyncPhase } from './phases/base-sync.js';
16
16
  import { runCloseValidationPhase } from './phases/close-validation.js';
17
17
  import { parsePrNumber, runStoryScopeReview } from './phases/code-review.js';
18
+ import { runConfirmMergePhase } from './phases/confirm-merge.js';
18
19
  import { parseCloseOptions } from './phases/options.js';
19
20
  import { ensurePullRequestWith } from './phases/pull-request.js';
20
21
  import { pushStoryBranch } from './phases/push.js';
@@ -57,6 +58,7 @@ async function runPrePushPhases({
57
58
  await runWrongTreeGuardPhase({
58
59
  cwd,
59
60
  worktreePath,
61
+ baseBranch,
60
62
  storyId,
61
63
  provider,
62
64
  progress,
@@ -204,6 +206,8 @@ function closeResult({
204
206
  autoMergeReason,
205
207
  worktreeReaped,
206
208
  leaseReleased,
209
+ waitedForMerge = false,
210
+ merged = false,
207
211
  }) {
208
212
  return {
209
213
  storyId,
@@ -217,9 +221,13 @@ function closeResult({
217
221
  autoMergeReason,
218
222
  worktreeReaped,
219
223
  leaseReleased,
220
- note: autoMergeEnabled
221
- ? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
222
- : 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
224
+ waitedForMerge,
225
+ merged,
226
+ note: waitedForMerge
227
+ ? 'Headless must-land: PR merge confirmed. Story flipped agent::closing → agent::done and the issue closed (confirmStoryMerged).'
228
+ : autoMergeEnabled
229
+ ? 'PR open against baseBranch with auto-merge enabled. Story rests at agent::closing (issue stays OPEN). GitHub will squash-merge when required checks pass; run single-story-confirm-merge.js after the merge confirms to flip agent::done and close the issue (the Closes #<id> footer also auto-closes it).'
230
+ : 'PR open against baseBranch. Story rests at agent::closing (issue stays OPEN). Operator merges via GitHub UI; run single-story-confirm-merge.js after the merge confirms to flip agent::done (the Closes #<id> footer also auto-closes the issue).',
223
231
  };
224
232
  }
225
233
 
@@ -230,6 +238,8 @@ export async function runSingleStoryClose({
230
238
  skipSync: skipSyncParam,
231
239
  noAutoMerge: noAutoMergeParam,
232
240
  noFullScopeCrap: noFullScopeCrapParam,
241
+ waitForMerge: waitForMergeParam,
242
+ noWaitForMerge: noWaitForMergeParam,
233
243
  injectedProvider,
234
244
  injectedConfig,
235
245
  injectedNotify,
@@ -246,10 +256,12 @@ export async function runSingleStoryClose({
246
256
  skipSyncParam,
247
257
  noAutoMergeParam,
248
258
  noFullScopeCrapParam,
259
+ waitForMergeParam,
260
+ noWaitForMergeParam,
249
261
  });
250
262
  if (!options.storyId) {
251
263
  throw new Error(
252
- 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap]',
264
+ 'Usage: node single-story-close.js --story <STORY_ID> [--cwd <main-repo>] [--skip-validation] [--skip-sync] [--no-auto-merge] [--no-full-scope-crap] [--wait-merge|--no-wait-merge]',
253
265
  );
254
266
  }
255
267
 
@@ -341,6 +353,56 @@ export async function runSingleStoryClose({
341
353
  WorktreeManager,
342
354
  });
343
355
  const leaseReleased = await releaseLease(leaseArgs);
356
+
357
+ // Story #4428 — headless must-land: `--wait-merge` polls the just-armed
358
+ // PR to merge confirmation (or an explicit `agent::blocked` +
359
+ // `merge.unlanded`) instead of resting at `agent::closing`. Attended
360
+ // (non-headless) runs never set this flag, so the exit shape below is
361
+ // unreachable and the pre-existing early-return is byte-identical.
362
+ if (options.waitForMerge) {
363
+ const waitOutcome = await runConfirmMergePhase({
364
+ cwd: options.cwd,
365
+ storyId: options.storyId,
366
+ prNumber,
367
+ prUrl,
368
+ autoMergeEnabled,
369
+ autoMergeReason,
370
+ provider,
371
+ config,
372
+ progress,
373
+ injectedGh,
374
+ injectedNotify,
375
+ });
376
+ if (!waitOutcome.confirmed) {
377
+ throw new Error(
378
+ `[single-story-close] Headless must-land: PR ${prUrl} did not reach a confirmed merge ` +
379
+ `(blockClass=${waitOutcome.blockClass}). Story #${options.storyId} was transitioned to ` +
380
+ `agent::blocked with a merge.unlanded lifecycle event. Reason: ${waitOutcome.reason}`,
381
+ );
382
+ }
383
+ const result = closeResult({
384
+ storyId: options.storyId,
385
+ storyBranch,
386
+ baseBranch,
387
+ prUrl,
388
+ prNumber,
389
+ autoMergeEnabled,
390
+ autoMergeReason,
391
+ worktreeReaped,
392
+ leaseReleased,
393
+ waitedForMerge: true,
394
+ merged: true,
395
+ });
396
+ Logger.info(
397
+ `\n--- STORY CLOSE RESULT ---\n${JSON.stringify(result, null, 2)}\n--- END RESULT ---\n`,
398
+ );
399
+ progress(
400
+ 'DONE',
401
+ `✅ Standalone Story #${options.storyId}: PR merged → ${prUrl}`,
402
+ );
403
+ return { success: true, result };
404
+ }
405
+
344
406
  const result = closeResult({
345
407
  storyId: options.storyId,
346
408
  storyBranch,