mandrel 2.66.0 → 2.68.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 (85) hide show
  1. package/.agents/agents/acceptance-critic.md +2 -2
  2. package/.agents/agents/story-worker.md +15 -11
  3. package/.agents/docs/agentrc-reference.json +5 -2
  4. package/.agents/docs/configuration.md +38 -2
  5. package/.agents/docs/workflows.md +4 -2
  6. package/.agents/instructions.md +2 -1
  7. package/.agents/rules/git-conventions-reference.md +5 -5
  8. package/.agents/rules/git-conventions.md +1 -1
  9. package/.agents/schemas/agentrc.schema.json +20 -2
  10. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  11. package/.agents/schemas/validation-evidence.schema.json +3 -1
  12. package/.agents/scripts/boot-sweep.js +97 -9
  13. package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
  14. package/.agents/scripts/clean-temp.js +54 -0
  15. package/.agents/scripts/clean-worktrees.js +593 -0
  16. package/.agents/scripts/coverage-capture.js +65 -9
  17. package/.agents/scripts/drain-pending-cleanup.js +5 -4
  18. package/.agents/scripts/evidence-gate.js +106 -8
  19. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  20. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  21. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  22. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  23. package/.agents/scripts/lib/child-exec.js +39 -1
  24. package/.agents/scripts/lib/clean-temp.js +440 -0
  25. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  26. package/.agents/scripts/lib/close-validation/process.js +23 -24
  27. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  28. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  29. package/.agents/scripts/lib/config/quality.js +7 -1
  30. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  31. package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
  32. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  33. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  34. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  35. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  36. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  37. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  38. package/.agents/scripts/lib/coverage-capture.js +121 -81
  39. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  40. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  41. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  42. package/.agents/scripts/lib/observability/source-classifier.js +4 -1
  43. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  44. package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
  45. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  46. package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
  47. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  48. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  49. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  50. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  51. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  52. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  53. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  54. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  55. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  56. package/.agents/scripts/lib/process-group.js +1 -1
  57. package/.agents/scripts/lib/single-story-sweep.js +2 -2
  58. package/.agents/scripts/lib/supervised-suite.js +247 -0
  59. package/.agents/scripts/lib/temp-removal.js +110 -0
  60. package/.agents/scripts/lib/temp-retention.js +122 -73
  61. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  62. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  63. package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
  64. package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
  65. package/.agents/scripts/quality-preview.js +112 -14
  66. package/.agents/scripts/single-story-init.js +120 -17
  67. package/.agents/scripts/stories-wave-tick.js +47 -0
  68. package/.agents/scripts/story-review-compute.js +207 -0
  69. package/.agents/scripts/update-coverage-baseline.js +15 -10
  70. package/.agents/scripts/update-crap-baseline.js +12 -2
  71. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  72. package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
  73. package/.agents/workflows/clean-temp.md +67 -0
  74. package/.agents/workflows/clean-worktrees.md +63 -0
  75. package/.agents/workflows/git-deliver.md +1 -1
  76. package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
  77. package/.agents/workflows/helpers/code-review.md +7 -5
  78. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  79. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  80. package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
  81. package/.agents/workflows/helpers/deliver-story.md +2 -1
  82. package/docs/CHANGELOG.md +39 -0
  83. package/lib/cli/registry.js +125 -18
  84. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  85. package/package.json +1 -1
@@ -37,6 +37,8 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
37
37
  'check-test-temp-hygiene.js',
38
38
  'check-windows-git-perf.js',
39
39
  'ceremony-derive.js',
40
+ 'clean-temp.js',
41
+ 'clean-worktrees.js',
40
42
  'cleanup-repo-test-temp.js',
41
43
  'coverage-capture.js',
42
44
  'deliver-light.js',
@@ -51,7 +53,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
51
53
  'generate-lens-checklists.js',
52
54
  'generate-skills-index.js',
53
55
  'generate-workflows-doc.js',
54
- 'git-cleanup.js',
56
+ 'clean-git.js',
55
57
  'lint-issue-body.js',
56
58
  'mandrel-update-preflight.js',
57
59
  'merge-baseline.js',
@@ -73,6 +75,7 @@ const FRAMEWORK_SCRIPT_BASENAMES = Object.freeze([
73
75
  'single-story-confirm-merge.js',
74
76
  'single-story-init.js',
75
77
  'stories-wave-tick.js',
78
+ 'story-review-compute.js',
76
79
  'sync-agentrc.js',
77
80
  'sync-claude-agents.js',
78
81
  'sync-claude-commands.js',
@@ -103,10 +103,13 @@ function resolveScopeEnvelope(opts, config) {
103
103
  * createReviewProviderFn?: typeof createReviewProvider,
104
104
  * upsertCommentFn?: typeof upsertStructuredComment,
105
105
  * renderFindingsFn?: typeof renderFindings,
106
+ * deferPost?: boolean,
106
107
  * }} opts
107
108
  * @returns {Promise<{
108
109
  * status: 'ok'|'no-changes'|'invalid',
109
110
  * severity: { critical: number, high: number, medium: number, suggestion: number },
111
+ * findings?: Array<object>,
112
+ * providerName?: string,
110
113
  * report?: string,
111
114
  * posted: boolean,
112
115
  * postedCommentId: number|null,
@@ -196,9 +199,10 @@ async function resolvePromptMessages(reviewProvider, reviewInput, logger) {
196
199
  }
197
200
 
198
201
  /**
199
- * Posting failure is non-fatal and surfaces as `posted: false`.
202
+ * Posting failure is non-fatal and surfaces as `posted: false`. Also posts a
203
+ * `deferPost` review's held report.
200
204
  */
201
- async function postReviewComment({
205
+ export async function postReviewComment({
202
206
  upsertCommentFn,
203
207
  provider,
204
208
  commentTargetId,
@@ -230,6 +234,12 @@ async function postReviewComment({
230
234
  }
231
235
  }
232
236
 
237
+ /** A `deferPost` review holds its report for the caller to post. */
238
+ async function postUnlessDeferred(opts, post) {
239
+ if (opts.deferPost) return { posted: false, postedCommentId: null };
240
+ return postReviewComment(post);
241
+ }
242
+
233
243
  /**
234
244
  * Which review provider(s) raised the critical findings. A chain reports its
235
245
  * own per-entry attribution; any other provider owns every critical.
@@ -309,7 +319,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
309
319
  degradations,
310
320
  });
311
321
 
312
- const { posted, postedCommentId } = await postReviewComment({
322
+ const { posted, postedCommentId } = await postUnlessDeferred(opts, {
313
323
  upsertCommentFn,
314
324
  provider,
315
325
  commentTargetId,
@@ -320,6 +330,8 @@ async function executeReviewPipeline({ opts, config, envelope }) {
320
330
  return {
321
331
  status: 'ok',
322
332
  severity,
333
+ findings,
334
+ providerName,
323
335
  report,
324
336
  posted,
325
337
  postedCommentId,
@@ -1,5 +1,5 @@
1
1
  /**
2
- * git-cleanup `main()`: argv, base-branch resolution, the four phases, and
2
+ * `clean-git.js` `main()`: argv, base-branch resolution, the four phases, and
3
3
  * the optional JSON envelope.
4
4
  *
5
5
  * @module lib/orchestration/git-cleanup/phases/cli
@@ -26,6 +26,11 @@ export function pollIntervalMs(checksStatus, intervalSeconds) {
26
26
  }
27
27
  export const DEFAULT_MAX_BUDGET_SECONDS = 3600;
28
28
 
29
+ /** CI never reddens in the first minute, so only green or red polls on. */
30
+ export function probeSettlesAsyncWait(checksStatus) {
31
+ return checksStatus !== 'success' && checksStatus !== 'failure';
32
+ }
33
+
29
34
  /** Bounds every `gh` spawn so a hang degrades to the probe-error path. */
30
35
  export const MERGE_WAIT_GH_TIMEOUT_MS = 60_000;
31
36
 
@@ -1,11 +1,17 @@
1
1
  /**
2
- * plan-runner/worktree-sweep.js — plan-boot sweep that force-removes
2
+ * plan-runner/worktree-sweep.js — boot sweep that removes
3
3
  * `.worktrees/story-<id>/` entries whose Story is closed or `agent::done`,
4
- * after draining the pending-cleanup manifest.
4
+ * after draining the pending-cleanup manifest. Called by `runBootSweep`
5
+ * (`boot-sweep.js`) under the shared merged-branch sweep lock.
5
6
  *
6
- * `--force` is deliberate: a done Story's branch is already merged, so any
7
- * residue (dirty artifacts, an interrupted rebase, a Windows lock) is noise;
8
- * the `WorktreeManager.reap` safety rails are for the active close path.
7
+ * Removal goes through `removeWorktreeWithRecovery` — the same seam close
8
+ * uses: Windows lock retries, an `fs.rm` fallback that discards residue a
9
+ * done Story's merged branch no longer needs, and the pending-cleanup
10
+ * hand-off when that exhausts. Branches are never deleted here; the
11
+ * merged-branch sweep owns them.
12
+ *
13
+ * Two trees are never touched: an open Story's, and the one the running
14
+ * process was loaded from or is working in (`findRunningCodeInside`).
9
15
  */
10
16
 
11
17
  import path from 'node:path';
@@ -13,8 +19,13 @@ import * as defaultGit from '../../git-utils.js';
13
19
  import { NOOP_LOGGER } from '../../Logger.js';
14
20
  import { AGENT_LABELS } from '../../label-constants.js';
15
21
  import { concurrentMap } from '../../util/concurrent-map.js';
16
- import { parseWorktreePorcelain } from '../../worktree/inspector.js';
22
+ import { canonicalPath } from '../../worktree/canonical-path.js';
23
+ import { parseWorktreePorcelain, samePath } from '../../worktree/inspector.js';
17
24
  import { forceDrainPendingCleanup } from '../../worktree/lifecycle/force-drain.js';
25
+ import {
26
+ findRunningCodeInside,
27
+ removeWorktreeWithRecovery,
28
+ } from '../../worktree/lifecycle/reap.js';
18
29
 
19
30
  const TICKET_READ_CONCURRENCY = 8;
20
31
 
@@ -27,33 +38,32 @@ function isStoryDone(ticket) {
27
38
  return labels.includes(DONE_LABEL);
28
39
  }
29
40
 
30
- function storyIdFromPath(wtPath) {
31
- const parts = wtPath.replace(/\\/g, '/').split('/');
32
- const last = parts[parts.length - 1] ?? '';
33
- return defaultGit.parseStoryBranch(last);
41
+ /**
42
+ * The Story id of a `<worktreeRoot>/story-<id>` entry, else `null` — a
43
+ * `story-<id>` directory anywhere else (e.g. `.claude/worktrees/`) is not
44
+ * this sweep's to judge.
45
+ *
46
+ * @param {string} wtPath
47
+ * @param {string} worktreeRoot
48
+ * @param {string} platform
49
+ * @returns {number|null}
50
+ */
51
+ function storyIdFromPath(wtPath, worktreeRoot, platform) {
52
+ const parent = path.dirname(canonicalPath(wtPath));
53
+ if (!samePath(parent, canonicalPath(worktreeRoot), platform)) return null;
54
+ return defaultGit.parseStoryBranch(path.basename(path.resolve(wtPath)));
34
55
  }
35
56
 
36
57
  /**
37
- * Never touches a worktree whose Story is still open.
58
+ * Validate and normalise the options bag.
38
59
  *
39
60
  * @param {object} opts
40
- * @param {object} opts.provider Only `getTicket(id)` is required.
41
- * @param {string} opts.repoRoot Absolute path to the main checkout.
42
- * @param {object} [opts.git]
43
- * @param {object} [opts.logger]
44
- * @returns {Promise<{
45
- * reaped: Array<{ storyId: number, path: string }>,
46
- * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
47
- * }>}
61
+ * @returns {object}
48
62
  */
49
- export async function sweepStaleStoryWorktrees(opts = {}) {
63
+ function resolveSweepOptions(opts) {
50
64
  const ctx = opts.ctx;
51
65
  const provider = opts.provider ?? ctx?.provider;
52
66
  const repoRoot = opts.repoRoot ?? ctx?.cwd;
53
- const git = opts.git ?? defaultGit;
54
- const logger = opts.logger ?? ctx?.logger ?? NOOP_LOGGER;
55
- const fsRm = opts.fsRm;
56
- const worktreeRoot = opts.worktreeRoot;
57
67
  if (!provider || typeof provider.getTicket !== 'function') {
58
68
  throw new Error(
59
69
  'sweepStaleStoryWorktrees: provider with getTicket(id) is required',
@@ -62,107 +72,149 @@ export async function sweepStaleStoryWorktrees(opts = {}) {
62
72
  if (!repoRoot || typeof repoRoot !== 'string') {
63
73
  throw new Error('sweepStaleStoryWorktrees: repoRoot is required');
64
74
  }
75
+ const platform = opts.platform ?? process.platform;
76
+ return {
77
+ provider,
78
+ repoRoot,
79
+ platform,
80
+ git: opts.git ?? defaultGit,
81
+ logger: opts.logger ?? ctx?.logger ?? NOOP_LOGGER,
82
+ fsRm: opts.fsRm,
83
+ sleepFn: opts.sleepFn,
84
+ worktreeRoot: path.resolve(
85
+ opts.worktreeRoot ?? path.join(repoRoot, '.worktrees'),
86
+ ),
87
+ guardPaths: [process.cwd(), ...(opts.runningPaths ?? [])],
88
+ };
89
+ }
65
90
 
66
- const resolvedWorktreeRoot =
67
- worktreeRoot ?? path.join(repoRoot, '.worktrees');
91
+ /**
92
+ * Read every Story worktree's ticket; each mapper captures its own error so
93
+ * one provider hiccup cannot abort the sweep via concurrentMap's
94
+ * first-rejection-wins policy.
95
+ */
96
+ function readStoryTickets(entries, o) {
97
+ return concurrentMap(
98
+ entries,
99
+ async (entry) => {
100
+ const wtPath = entry.path;
101
+ const storyId = wtPath
102
+ ? storyIdFromPath(wtPath, o.worktreeRoot, o.platform)
103
+ : null;
104
+ if (storyId === null) return { kind: 'non-story' };
105
+ try {
106
+ const ticket = await o.provider.getTicket(storyId);
107
+ return { kind: 'ok', wtPath, storyId, ticket };
108
+ } catch (err) {
109
+ return { kind: 'provider-error', wtPath, storyId, error: err };
110
+ }
111
+ },
112
+ { concurrency: TICKET_READ_CONCURRENCY },
113
+ );
114
+ }
115
+
116
+ /**
117
+ * Why a read entry must be kept, or `null` when it may be removed.
118
+ *
119
+ * @returns {string|null}
120
+ */
121
+ function keepReason(r, o) {
122
+ if (r.kind === 'provider-error') {
123
+ o.logger.warn(
124
+ `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
125
+ );
126
+ return `provider-error: ${r.error.message}`;
127
+ }
128
+ if (!isStoryDone(r.ticket)) return 'story-open';
129
+ const selfPath = findRunningCodeInside(o, r.wtPath, o.guardPaths);
130
+ if (selfPath) {
131
+ o.logger.warn(
132
+ `worktree-sweep: skipped storyId=${r.storyId} path=${r.wtPath} — the running process uses it (${selfPath})`,
133
+ );
134
+ return 'running-from-target-tree';
135
+ }
136
+ return null;
137
+ }
138
+
139
+ async function removeOne(r, o) {
140
+ const ctx = {
141
+ repoRoot: o.repoRoot,
142
+ git: o.git,
143
+ logger: o.logger,
144
+ platform: o.platform,
145
+ worktreeRoot: o.worktreeRoot,
146
+ listCache: { list: null, ts: 0 },
147
+ fsRm: o.fsRm,
148
+ };
149
+ const res = await removeWorktreeWithRecovery(ctx, r.wtPath, {
150
+ storyId: r.storyId,
151
+ ...(o.sleepFn ? { sleepFn: o.sleepFn, retryDelay: 0 } : {}),
152
+ });
153
+ return res.removed
154
+ ? null
155
+ : `remove-failed: ${res.reason ?? 'worktree-remove-failed'}`;
156
+ }
157
+
158
+ /**
159
+ * Never touches a worktree whose Story is still open, nor the one the
160
+ * running process uses.
161
+ *
162
+ * @param {object} opts
163
+ * @param {object} opts.provider Only `getTicket(id)` is required.
164
+ * @param {string} opts.repoRoot Absolute path to the main checkout.
165
+ * @param {object} [opts.git]
166
+ * @param {object} [opts.logger]
167
+ * @param {string[]} [opts.runningPaths] Extra paths the running process owns.
168
+ * @returns {Promise<{
169
+ * reaped: Array<{ storyId: number, path: string }>,
170
+ * skipped: Array<{ storyId: number|null, path: string, reason: string }>,
171
+ * }>}
172
+ */
173
+ export async function sweepStaleStoryWorktrees(opts = {}) {
174
+ const o = resolveSweepOptions(opts);
68
175
 
69
176
  // Drain the pending-cleanup manifest first so entries whose Windows locks
70
177
  // have released self-heal instead of accumulating.
71
178
  const drainResult = await forceDrainPendingCleanup({
72
- repoRoot,
73
- worktreeRoot: resolvedWorktreeRoot,
74
- git,
75
- fsRm,
76
- logger,
179
+ repoRoot: o.repoRoot,
180
+ worktreeRoot: o.worktreeRoot,
181
+ git: o.git,
182
+ fsRm: o.fsRm,
183
+ logger: o.logger,
77
184
  });
78
185
 
79
186
  const reaped = [];
80
187
  const skipped = [];
81
188
 
82
- const listRes = git.gitSpawn(repoRoot, 'worktree', 'list', '--porcelain');
189
+ const listRes = o.git.gitSpawn(o.repoRoot, 'worktree', 'list', '--porcelain');
83
190
  if (listRes.status !== 0) {
84
- logger.warn(
191
+ o.logger.warn(
85
192
  `worktree-sweep: git worktree list failed: ${listRes.stderr || listRes.stdout || 'unknown'}`,
86
193
  );
87
194
  return { reaped, skipped };
88
195
  }
89
196
 
90
- const entries = parseWorktreePorcelain(listRes.stdout || '');
91
-
92
- // Each mapper captures its own error so one provider hiccup cannot abort
93
- // the sweep via concurrentMap's first-rejection-wins policy.
94
- const reads = await concurrentMap(
95
- entries,
96
- async (entry) => {
97
- const wtPath = entry.path;
98
- if (!wtPath) return { kind: 'no-path' };
99
- const storyId = storyIdFromPath(wtPath);
100
- if (storyId === null) return { kind: 'non-story' };
101
- try {
102
- const ticket = await provider.getTicket(storyId);
103
- return { kind: 'ok', wtPath, storyId, ticket };
104
- } catch (err) {
105
- return { kind: 'provider-error', wtPath, storyId, error: err };
106
- }
107
- },
108
- { concurrency: TICKET_READ_CONCURRENCY },
197
+ const reads = await readStoryTickets(
198
+ parseWorktreePorcelain(listRes.stdout || ''),
199
+ o,
109
200
  );
110
201
 
111
202
  // Removes stay sequential: they mutate .git/worktrees/, and racing git's
112
203
  // locking on Windows can leave a partial admin dir the next remove trips on.
113
204
  for (const r of reads) {
114
- if (r.kind === 'no-path' || r.kind === 'non-story') continue;
115
- if (r.kind === 'provider-error') {
116
- skipped.push({
117
- storyId: r.storyId,
118
- path: r.wtPath,
119
- reason: `provider-error: ${r.error.message}`,
120
- });
121
- logger.warn(
122
- `worktree-sweep: provider.getTicket(#${r.storyId}) failed: ${r.error.message}`,
123
- );
124
- continue;
125
- }
126
-
127
- if (!isStoryDone(r.ticket)) {
128
- skipped.push({
129
- storyId: r.storyId,
130
- path: r.wtPath,
131
- reason: 'story-open',
132
- });
133
- continue;
134
- }
135
-
136
- const res = git.gitSpawn(
137
- repoRoot,
138
- 'worktree',
139
- 'remove',
140
- '--force',
141
- r.wtPath,
142
- );
143
- if (res.status !== 0) {
144
- const reason = (
145
- res.stderr ||
146
- res.stdout ||
147
- 'worktree-remove-failed'
148
- ).trim();
149
- skipped.push({
150
- storyId: r.storyId,
151
- path: r.wtPath,
152
- reason: `remove-failed: ${reason}`,
153
- });
154
- logger.warn(
155
- `worktree-sweep: failed to reap storyId=${r.storyId} path=${r.wtPath}: ${reason}`,
156
- );
205
+ if (r.kind === 'non-story') continue;
206
+ const reason = keepReason(r, o) ?? (await removeOne(r, o));
207
+ if (reason) {
208
+ skipped.push({ storyId: r.storyId, path: r.wtPath, reason });
157
209
  continue;
158
210
  }
159
211
  reaped.push({ storyId: r.storyId, path: r.wtPath });
160
- logger.info(
212
+ o.logger.info(
161
213
  `worktree-sweep: reaped stale worktree storyId=${r.storyId} path=${r.wtPath}`,
162
214
  );
163
215
  }
164
216
 
165
- git.gitSpawn(repoRoot, 'worktree', 'prune');
217
+ o.git.gitSpawn(o.repoRoot, 'worktree', 'prune');
166
218
 
167
219
  return {
168
220
  reaped,
@@ -0,0 +1,219 @@
1
+ /**
2
+ * review-deposit.js — the held Story-scope review a worker computes at push
3
+ * time, and its identity.
4
+ *
5
+ * The identity is the **diff digest**: a sha256 of the exact
6
+ * `git diff --no-color <base>...<head>` text the review provider is handed.
7
+ * A clean base-sync merge moves HEAD without changing that text, while any
8
+ * commit that changes the diff changes it — so close adopts a deposit, and
9
+ * posts a held result, on digest equality, never on SHA equality.
10
+ *
11
+ * The file lives beside the terminal envelope; nothing deletes it but the
12
+ * Story temp purge. A mismatched deposit is simply not adopted.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import nodeFs from 'node:fs';
17
+ import path from 'node:path';
18
+
19
+ import { storyReviewDepositPath } from '../config/temp-paths.js';
20
+ import { resolveSharedBaseRef } from './review-base-ref.js';
21
+
22
+ const DEPOSIT_KIND = 'story-review-deposit';
23
+
24
+ /**
25
+ * @param {{ cwd: string, ref: string, gitSpawnFn: Function }} args
26
+ * @returns {string|null} the commit `ref` points at.
27
+ */
28
+ export function resolveRefSha({ cwd, ref, gitSpawnFn }) {
29
+ try {
30
+ const probe = gitSpawnFn(
31
+ cwd,
32
+ 'rev-parse',
33
+ '--verify',
34
+ '--quiet',
35
+ `${ref}^{commit}`,
36
+ );
37
+ const sha = probe?.status === 0 ? String(probe.stdout ?? '').trim() : '';
38
+ return sha.length > 0 ? sha : null;
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * sha256 of the three-dot diff text, spawned exactly as the review provider
46
+ * spawns it. `null` when the diff cannot be read — an unknown identity that
47
+ * never matches anything.
48
+ *
49
+ * @param {{ cwd: string, baseRef: string|null, headRef: string|null,
50
+ * gitSpawnFn: Function }} args
51
+ * @returns {string|null}
52
+ */
53
+ export function computeReviewDiffDigest({ cwd, baseRef, headRef, gitSpawnFn }) {
54
+ if (!baseRef || !headRef) return null;
55
+ try {
56
+ const diff = gitSpawnFn(
57
+ cwd,
58
+ 'diff',
59
+ '--no-color',
60
+ `${baseRef}...${headRef}`,
61
+ );
62
+ if (diff?.status !== 0) return null;
63
+ return createHash('sha256')
64
+ .update(String(diff.stdout ?? ''), 'utf8')
65
+ .digest('hex');
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Flatten a computed review `result` into the deposit record.
73
+ *
74
+ * @param {{ storyId: number, headSha: string, baseRef: string,
75
+ * diffDigest: string, result: object, createdAt?: string }} args
76
+ * @returns {object}
77
+ */
78
+ export function buildReviewDeposit({
79
+ storyId,
80
+ headSha,
81
+ baseRef,
82
+ diffDigest,
83
+ result,
84
+ createdAt = new Date().toISOString(),
85
+ }) {
86
+ return {
87
+ kind: DEPOSIT_KIND,
88
+ storyId,
89
+ headSha,
90
+ baseRef,
91
+ diffDigest,
92
+ createdAt,
93
+ provider: result.providerName ?? null,
94
+ severity: result.severity,
95
+ halted: !!result.halted,
96
+ criticalByProvider: result.criticalByProvider ?? {},
97
+ findings: Array.isArray(result.findings) ? result.findings : [],
98
+ report: result.report ?? '',
99
+ degraded: !!result.degraded,
100
+ degradations: Array.isArray(result.degradations) ? result.degradations : [],
101
+ blockerReason: result.blockerReason ?? null,
102
+ };
103
+ }
104
+
105
+ /**
106
+ * The deposit as `computeStoryScopeReview`'s `{ result }` shape, ready for
107
+ * `settleStoryScopeReview` to post.
108
+ *
109
+ * @param {object} deposit
110
+ * @returns {{ result: object }}
111
+ */
112
+ export function depositAsComputedReview(deposit) {
113
+ return {
114
+ result: {
115
+ status: 'ok',
116
+ severity: deposit.severity,
117
+ findings: deposit.findings,
118
+ providerName: deposit.provider,
119
+ report: deposit.report,
120
+ posted: false,
121
+ postedCommentId: null,
122
+ halted: deposit.halted,
123
+ criticalByProvider: deposit.criticalByProvider,
124
+ degraded: deposit.degraded,
125
+ degradations: deposit.degradations,
126
+ blockerReason: deposit.blockerReason,
127
+ },
128
+ };
129
+ }
130
+
131
+ /**
132
+ * Atomic write (pid-scoped tmp + rename): close may read it mid-write.
133
+ *
134
+ * @param {object} deposit
135
+ * @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
136
+ * @returns {string} the path written.
137
+ */
138
+ export function writeReviewDeposit(deposit, { config, fsImpl = nodeFs } = {}) {
139
+ const target = storyReviewDepositPath(deposit.storyId, config);
140
+ fsImpl.mkdirSync(path.dirname(target), { recursive: true });
141
+ const tmpPath = `${target}.${process.pid}.tmp`;
142
+ fsImpl.writeFileSync(
143
+ tmpPath,
144
+ `${JSON.stringify(deposit, null, 2)}\n`,
145
+ 'utf8',
146
+ );
147
+ fsImpl.renameSync(tmpPath, target);
148
+ return target;
149
+ }
150
+
151
+ /**
152
+ * @param {unknown} record
153
+ * @param {number} storyId
154
+ * @returns {boolean}
155
+ */
156
+ function isDeposit(record, storyId) {
157
+ return (
158
+ record?.kind === DEPOSIT_KIND &&
159
+ record.storyId === storyId &&
160
+ typeof record.diffDigest === 'string' &&
161
+ record.diffDigest.length > 0 &&
162
+ typeof record.report === 'string' &&
163
+ typeof record.severity === 'object' &&
164
+ record.severity !== null
165
+ );
166
+ }
167
+
168
+ /**
169
+ * @param {number} storyId
170
+ * @param {{ config?: object, fsImpl?: typeof nodeFs }} [deps]
171
+ * @returns {object|null} the Story's deposit, or `null` when absent or
172
+ * malformed.
173
+ */
174
+ function readReviewDeposit(storyId, { config, fsImpl = nodeFs } = {}) {
175
+ try {
176
+ const raw = fsImpl.readFileSync(
177
+ storyReviewDepositPath(storyId, config),
178
+ 'utf8',
179
+ );
180
+ const record = JSON.parse(raw);
181
+ return isDeposit(record, storyId) ? record : null;
182
+ } catch {
183
+ return null;
184
+ }
185
+ }
186
+
187
+ /**
188
+ * Close's held-review start point: the base ref, the diff digest of
189
+ * `origin/<base>...<sha>`, and the worker deposit when its digest matches.
190
+ *
191
+ * @param {{ cwd: string, storyId: number|string, sha: string,
192
+ * baseBranch: string, gitSpawnFn: Function, config?: object,
193
+ * readDepositFn?: typeof readReviewDeposit }} args
194
+ * @returns {{ baseRef: string|null, diffDigest: string|null,
195
+ * deposit: object|null }}
196
+ */
197
+ export function probeHeldReviewDiff({
198
+ cwd,
199
+ storyId,
200
+ sha,
201
+ baseBranch,
202
+ gitSpawnFn,
203
+ config,
204
+ readDepositFn = readReviewDeposit,
205
+ }) {
206
+ const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
207
+ const baseRef = base.resolved ? base.ref : null;
208
+ const diffDigest = computeReviewDiffDigest({
209
+ cwd,
210
+ baseRef,
211
+ headRef: sha,
212
+ gitSpawnFn,
213
+ });
214
+ const deposit = diffDigest
215
+ ? readDepositFn(Number(storyId), { config })
216
+ : null;
217
+ const matches = deposit?.diffDigest === diffDigest;
218
+ return { baseRef, diffDigest, deposit: matches ? deposit : null };
219
+ }
@@ -15,7 +15,7 @@
15
15
  * @typedef {import('./types.js').ReviewProvider} ReviewProvider
16
16
  */
17
17
 
18
- import { spawnCapture } from '../../child-exec.js';
18
+ import { spawnCaptureAsync } from '../../child-exec.js';
19
19
  import { gitSpawn } from '../../git-utils.js';
20
20
  import { PROJECT_ROOT } from '../../project-root.js';
21
21
  import { parseProviderFindings } from './parse-findings.js';
@@ -62,14 +62,15 @@ function buildPrompt(input, diff) {
62
62
  }
63
63
 
64
64
  /**
65
- * The prompt rides stdin so no shell ever quotes it.
65
+ * The prompt rides stdin so no shell ever quotes it. Asynchronous: the
66
+ * review overlaps close's gate children, whose output must keep draining.
66
67
  *
67
68
  * @param {string} prompt
68
- * @param {Function} [run] - `spawnSync`-shaped seam for tests.
69
- * @returns {{ status: number, stdout: string, stderr: string }}
69
+ * @param {Function} [run] - `(file, args, options)` seam for tests.
70
+ * @returns {Promise<{ status: number, stdout: string, stderr: string }>}
70
71
  */
71
72
  function invokeClaude(prompt, run) {
72
- return spawnCapture('claude', [...CLAUDE_ARGS], {
73
+ return spawnCaptureAsync('claude', [...CLAUDE_ARGS], {
73
74
  cwd: PROJECT_ROOT,
74
75
  input: prompt,
75
76
  shell: process.platform === 'win32',
@@ -144,7 +145,7 @@ function assertInput(input) {
144
145
  * gitSpawnFn?: typeof gitSpawn,
145
146
  * spawnFn?: Function,
146
147
  * logger?: { info?: Function, warn?: Function },
147
- * }} [deps] - `spawnFn` replaces `spawnSync` for the `claude` call.
148
+ * }} [deps] - `spawnFn` replaces the async runner for the `claude` call.
148
149
  * @returns {ReviewProvider}
149
150
  */
150
151
  export function createCodeReviewProviderForRegistry(deps = {}) {
@@ -183,7 +184,10 @@ export function createCodeReviewProviderForRegistry(deps = {}) {
183
184
  logger?.info?.(
184
185
  `[code-review] Invoking claude --print --effort low for Story #${ticketId} (${baseRef}...${headRef})...`,
185
186
  );
186
- const result = invokeClaude(buildPrompt(input, diff.stdout), spawnFn);
187
+ const result = await invokeClaude(
188
+ buildPrompt(input, diff.stdout),
189
+ spawnFn,
190
+ );
187
191
  if (result.status !== 0) {
188
192
  logger?.warn?.(
189
193
  `[code-review] claude exited ${result.status}; emitting advisory.`,