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
@@ -18,7 +18,7 @@ import { CODING_GUARDRAILS } from './lib/config/quality.js';
18
18
 
19
19
  const USAGE = {
20
20
  invocation:
21
- 'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--json]',
21
+ 'node .agents/scripts/quality-preview.js [--staged | --changed-since <ref>] [--only mi|crap] [--json]',
22
22
  summary:
23
23
  'Preview the per-file maintainability and CRAP deltas for the change set, and exit non-zero on any threshold violation.',
24
24
  flags: [
@@ -30,6 +30,10 @@ const USAGE = {
30
30
  '--changed-since <ref>',
31
31
  'Score the diff against <ref> (default: HEAD). Last occurrence wins.',
32
32
  ],
33
+ [
34
+ '--only mi|crap',
35
+ 'Run one half only — maintainability (`mi`) or CRAP (`crap`); the other half is reported as not run. Default: both, serially.',
36
+ ],
33
37
  ['--json', 'Emit both gate envelopes plus the merged table as JSON.'],
34
38
  ],
35
39
  };
@@ -73,6 +77,96 @@ export function parseStagedFlag(argv) {
73
77
  return argv.includes('--staged');
74
78
  }
75
79
 
80
+ /** The halves `--only` can select. */
81
+ const PREVIEW_HALVES = new Set(['mi', 'crap']);
82
+
83
+ /**
84
+ * `--only <half>` (last occurrence wins); `null` runs both halves. A value
85
+ * outside {@link PREVIEW_HALVES} is returned as-is for the caller to refuse.
86
+ *
87
+ * @param {string[]} argv
88
+ * @returns {string | null}
89
+ */
90
+ function parseOnlyArg(argv) {
91
+ const at = argv.lastIndexOf('--only');
92
+ return at === -1 ? null : (argv[at + 1] ?? '');
93
+ }
94
+
95
+ /**
96
+ * @param {string|null} only
97
+ * @returns {boolean}
98
+ */
99
+ function isUnknownHalf(only) {
100
+ return only !== null && !PREVIEW_HALVES.has(only);
101
+ }
102
+
103
+ /**
104
+ * Run `half` unless `--only` selected the other one.
105
+ *
106
+ * @param {{ only: string|null, half: 'mi'|'crap', run: () => Promise<{exitCode: number, envelope: object|null}> }} opts
107
+ * @returns {Promise<{exitCode: number, envelope: object|null}>}
108
+ */
109
+ function runHalf({ only, half, run }) {
110
+ return only === null || only === half ? run() : Promise.resolve(NOT_RUN);
111
+ }
112
+
113
+ /**
114
+ * The `--json` field naming the selected half; empty for the default run so
115
+ * its envelope stays byte-identical.
116
+ *
117
+ * @param {string|null} only
118
+ * @returns {{ only?: string }}
119
+ */
120
+ function onlyField(only) {
121
+ return only ? { only } : {};
122
+ }
123
+
124
+ /**
125
+ * The report line naming the selected half; empty for the default run.
126
+ *
127
+ * @param {string|null} only
128
+ * @returns {string}
129
+ */
130
+ function halfLine(only) {
131
+ return only ? `half=${only} only — the other half was not run\n` : '';
132
+ }
133
+
134
+ /**
135
+ * `--staged` wins; otherwise `--changed-since` (absent → `HEAD`).
136
+ *
137
+ * @param {string[]} argv
138
+ * @param {boolean} staged
139
+ * @returns {string|null}
140
+ */
141
+ function resolveRef(argv, staged) {
142
+ return staged ? null : (parseChangedSinceArg(argv) ?? 'HEAD');
143
+ }
144
+
145
+ /** A half `--only` left out: clean, with no envelope to merge. */
146
+ const NOT_RUN = Object.freeze({ exitCode: 0, envelope: null });
147
+
148
+ /**
149
+ * Run the selected halves serially, not via Promise.all: each runner sizes its
150
+ * own pool to availableParallelism, so overlapping them oversubscribes 2x and
151
+ * stacks two escomplex heaps (>1 GB RSS).
152
+ *
153
+ * @param {{ only: string|null, args: object, runMi: Function, runCrap: Function, stderr: { write: (s: string) => void } }} opts
154
+ * @returns {Promise<{ miResult: {exitCode: number, envelope: object|null}, crapResult: {exitCode: number, envelope: object|null} }>}
155
+ */
156
+ async function runHalves({ only, args, runMi, runCrap, stderr }) {
157
+ const miResult = await runHalf({
158
+ only,
159
+ half: 'mi',
160
+ run: () => runGateSafely(runMi, args, 'MI', stderr),
161
+ });
162
+ const crapResult = await runHalf({
163
+ only,
164
+ half: 'crap',
165
+ run: () => runGateSafely(runCrap, args, 'CRAP', stderr),
166
+ });
167
+ return { miResult, crapResult };
168
+ }
169
+
76
170
  /**
77
171
  * @param {unknown} value
78
172
  * @returns {number}
@@ -325,6 +419,7 @@ function stagedScopeLine({ staged, ref, cwd }) {
325
419
  *
326
420
  * @param {{
327
421
  * json: boolean,
422
+ * only: string|null,
328
423
  * staged: boolean,
329
424
  * ref: string|null,
330
425
  * cwd: string,
@@ -338,6 +433,7 @@ function stagedScopeLine({ staged, ref, cwd }) {
338
433
  */
339
434
  function emitReport({
340
435
  json,
436
+ only,
341
437
  staged,
342
438
  ref,
343
439
  cwd,
@@ -355,6 +451,7 @@ function emitReport({
355
451
  {
356
452
  ref: staged ? null : ref,
357
453
  staged,
454
+ ...onlyField(only),
358
455
  mi: { exit: miExit, envelope: miResult.envelope },
359
456
  crap: { exit: crapExit, envelope: crapResult.envelope },
360
457
  merged,
@@ -366,6 +463,7 @@ function emitReport({
366
463
  return;
367
464
  }
368
465
  stdout.write('\n--- quality:preview ---\n');
466
+ stdout.write(halfLine(only));
369
467
  stdout.write(stagedScopeLine({ staged, ref, cwd }));
370
468
  stdout.write(`${renderTable(merged)}\n`);
371
469
  writeAdvisories(merged.advisories, stdout);
@@ -399,23 +497,22 @@ export async function runCli({
399
497
  } = {}) {
400
498
  const json = parseJsonFlag(argv);
401
499
  const staged = parseStagedFlag(argv);
402
- const ref = staged ? null : (parseChangedSinceArg(argv) ?? 'HEAD');
500
+ const ref = resolveRef(argv, staged);
501
+ const only = parseOnlyArg(argv);
502
+ if (isUnknownHalf(only)) {
503
+ stderr.write(
504
+ `[quality:preview] --only takes mi or crap (got "${only}").\n`,
505
+ );
506
+ return { exitCode: 2, merged: mergeEnvelopes(null, null) };
507
+ }
403
508
 
404
- // Serial, not Promise.all: each runner sizes its own pool to
405
- // availableParallelism, so overlapping them oversubscribes 2x and stacks
406
- // two escomplex heaps (>1 GB RSS).
407
- const miResult = await runGateSafely(
509
+ const { miResult, crapResult } = await runHalves({
510
+ only,
511
+ args: { cwd, staged, changedSinceRef: ref },
408
512
  runMi,
409
- { cwd, staged, changedSinceRef: ref },
410
- 'MI',
411
- stderr,
412
- );
413
- const crapResult = await runGateSafely(
414
513
  runCrap,
415
- { cwd, staged, changedSinceRef: ref },
416
- 'CRAP',
417
514
  stderr,
418
- );
515
+ });
419
516
 
420
517
  const merged = mergeEnvelopes(miResult.envelope, crapResult.envelope, {
421
518
  cyclomaticFlag: DEFAULT_CYCLOMATIC_FLAG,
@@ -423,6 +520,7 @@ export async function runCli({
423
520
 
424
521
  emitReport({
425
522
  json,
523
+ only,
426
524
  staged,
427
525
  ref,
428
526
  cwd,
@@ -229,10 +229,60 @@ export function decideStoryBranchSeed({ localHas, remoteHas }) {
229
229
  }
230
230
 
231
231
  /**
232
- * Reap merged `story-*` branches (excluding the current one). Never blocks
233
- * init. Protected candidates (unpushed work, dirty worktree, open Story) are
234
- * skipped; the lockfile is shared with `boot-sweep.js` via
235
- * `resolveSweepLockPath` so concurrent reaps cannot race.
232
+ * Remove closed-Story `.worktrees/story-<id>` trees through the boot sweep's
233
+ * own seam (`runWorktreeSweep`: same lock, same invariants). The Story being
234
+ * initialized is always kept, whatever its ticket state. Never throws: a
235
+ * failure lands in the returned outcome, which rides the init envelope.
236
+ *
237
+ * @returns {Promise<object>} `{ ok, reaped, skipped, reason?, error? }`.
238
+ */
239
+ export async function reapClosedStoryWorktrees({
240
+ cwd,
241
+ storyBranch,
242
+ provider,
243
+ lockPath,
244
+ lockTimeoutMs,
245
+ worktreeSweepFn,
246
+ acquireLockFn,
247
+ }) {
248
+ const logger = {
249
+ info: (m) => progress('CLEANUP', m),
250
+ warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
251
+ };
252
+ try {
253
+ const { runWorktreeSweep } = await import('./boot-sweep.js');
254
+ const outcome = await runWorktreeSweep({
255
+ root: cwd,
256
+ provider,
257
+ lockPath,
258
+ lockTimeoutMs,
259
+ ...(worktreeSweepFn ? { sweepFn: worktreeSweepFn } : {}),
260
+ ...(acquireLockFn ? { acquireLockFn } : {}),
261
+ logger,
262
+ logTag: '[worktree-sweep]',
263
+ keepPaths: [path.join(cwd, '.worktrees', storyBranch)],
264
+ });
265
+ if (outcome.reaped?.length > 0) {
266
+ progress(
267
+ 'CLEANUP',
268
+ `🧹 removed ${outcome.reaped.length} closed-Story worktree(s).`,
269
+ );
270
+ }
271
+ return outcome;
272
+ } catch (err) {
273
+ const msg = err?.message ?? String(err);
274
+ logger.warn(`worktree sweep threw (init continues): ${msg}`);
275
+ return { ok: false, error: msg, reaped: [], skipped: [] };
276
+ }
277
+ }
278
+
279
+ /**
280
+ * Reap merged `story-*` branches (excluding the current one), then the
281
+ * closed-Story worktrees. Never blocks init. Protected candidates (unpushed
282
+ * work, dirty worktree, open Story) are skipped; the lockfile is shared with
283
+ * `boot-sweep.js` via `resolveSweepLockPath` so concurrent reaps cannot race.
284
+ *
285
+ * @returns {Promise<{ worktreeSweep: object }>}
236
286
  */
237
287
  export async function reapMergedStoryBranches({
238
288
  cwd,
@@ -241,6 +291,8 @@ export async function reapMergedStoryBranches({
241
291
  config,
242
292
  provider,
243
293
  injectedSweep,
294
+ worktreeSweepFn,
295
+ acquireLockFn,
244
296
  }) {
245
297
  const sweepFn =
246
298
  injectedSweep ??
@@ -248,7 +300,37 @@ export async function reapMergedStoryBranches({
248
300
  const tempRoot = config?.project?.paths?.tempRoot ?? 'temp';
249
301
  const lockPath = resolveSweepLockPath({ cwd, tempRoot });
250
302
  const lockTimeoutMs =
251
- config.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
303
+ config?.delivery?.worktreeIsolation?.sweepLockMs ?? 60_000;
304
+ await reapMergedBranches({
305
+ cwd,
306
+ baseBranch,
307
+ storyBranch,
308
+ provider,
309
+ sweepFn,
310
+ lockPath,
311
+ lockTimeoutMs,
312
+ });
313
+ const worktreeSweep = await reapClosedStoryWorktrees({
314
+ cwd,
315
+ storyBranch,
316
+ provider,
317
+ lockPath,
318
+ lockTimeoutMs,
319
+ worktreeSweepFn,
320
+ acquireLockFn,
321
+ });
322
+ return { worktreeSweep };
323
+ }
324
+
325
+ async function reapMergedBranches({
326
+ cwd,
327
+ baseBranch,
328
+ storyBranch,
329
+ provider,
330
+ sweepFn,
331
+ lockPath,
332
+ lockTimeoutMs,
333
+ }) {
252
334
  try {
253
335
  const sweep = await sweepFn({
254
336
  cwd,
@@ -300,9 +382,12 @@ export async function reapMergedStoryBranches({
300
382
  * @param {object} opts.config
301
383
  * @param {object} opts.provider
302
384
  * @param {Function|undefined} opts.injectedSweep
385
+ * @param {Function} [opts.worktreeSweepFn] Test override for the
386
+ * closed-Story worktree sweep.
303
387
  * @param {Function} opts.progress
304
388
  * @param {import('./lib/git/cached-fetch.js').FetchCache} [opts.fetchCache]
305
389
  * Test override; production shares the module singleton.
390
+ * @returns {Promise<{ worktreeSweep: object }>}
306
391
  */
307
392
  export async function materializeBaseBranch({
308
393
  cwd,
@@ -311,6 +396,7 @@ export async function materializeBaseBranch({
311
396
  config,
312
397
  provider,
313
398
  injectedSweep,
399
+ worktreeSweepFn,
314
400
  progress,
315
401
  fetchCache,
316
402
  }) {
@@ -326,13 +412,14 @@ export async function materializeBaseBranch({
326
412
  );
327
413
  }
328
414
 
329
- await reapMergedStoryBranches({
415
+ const { worktreeSweep } = await reapMergedStoryBranches({
330
416
  cwd,
331
417
  baseBranch,
332
418
  storyBranch,
333
419
  config,
334
420
  provider,
335
421
  injectedSweep,
422
+ worktreeSweepFn,
336
423
  });
337
424
 
338
425
  if (!branchExistsLocally(baseBranch, cwd)) {
@@ -342,10 +429,19 @@ export async function materializeBaseBranch({
342
429
  `Failed to fetch base branch ${baseBranch}: ${r.stderr || '(no stderr)'}`,
343
430
  );
344
431
  }
345
- return;
432
+ return { worktreeSweep };
346
433
  }
347
434
 
348
- // `git fetch` leaves local base at the old tip until fast-forwarded.
435
+ fastForwardBase({ cwd, baseBranch, progress });
436
+ return { worktreeSweep };
437
+ }
438
+
439
+ /**
440
+ * `git fetch` leaves local base at the old tip until fast-forwarded.
441
+ *
442
+ * @param {{ cwd: string, baseBranch: string, progress: Function }} opts
443
+ */
444
+ function fastForwardBase({ cwd, baseBranch, progress }) {
349
445
  const ffPlan = planFastForward({ cwd, baseBranch });
350
446
  const ff = executeFastForward({
351
447
  cwd,
@@ -461,6 +557,7 @@ export async function runSingleStoryInit({
461
557
  injectedProvider,
462
558
  injectedConfig,
463
559
  injectedSweep,
560
+ injectedWorktreeSweep,
464
561
  injectedAcquireLease,
465
562
  steal = false,
466
563
  injectedVerifyRemote,
@@ -533,6 +630,7 @@ export async function runSingleStoryInit({
533
630
  let workCwd = cwd;
534
631
  let worktreeCreated = false;
535
632
  let installStatus = { status: 'skipped', reason: 'dry-run' };
633
+ let worktreeSweep = null;
536
634
 
537
635
  if (!dryRun) {
538
636
  const acquire = injectedAcquireLease ?? acquireStoryLease;
@@ -563,15 +661,17 @@ export async function runSingleStoryInit({
563
661
  await rollUpContainerEpic(provider, storyId, config);
564
662
 
565
663
  try {
566
- await injectedMaterialize({
567
- cwd,
568
- baseBranch,
569
- storyBranch,
570
- config,
571
- provider,
572
- injectedSweep,
573
- progress,
574
- });
664
+ ({ worktreeSweep } =
665
+ (await injectedMaterialize({
666
+ cwd,
667
+ baseBranch,
668
+ storyBranch,
669
+ config,
670
+ provider,
671
+ injectedSweep,
672
+ worktreeSweepFn: injectedWorktreeSweep,
673
+ progress,
674
+ })) ?? {});
575
675
  injectedSeedBranch({ cwd, storyBranch, baseBranch, progress });
576
676
  ({ workCwd, worktreeCreated, installStatus } =
577
677
  await injectedProvisionWorktree({
@@ -610,6 +710,9 @@ export async function runSingleStoryInit({
610
710
  installStatus,
611
711
  dependenciesInstalled,
612
712
  installFailed: installStatus.status === 'failed',
713
+ // Closed-Story worktree sweep outcome; a failure degrades here, never
714
+ // into an init failure. `null` under --dry-run.
715
+ worktreeSweep: worktreeSweep ?? null,
613
716
  dryRun,
614
717
  remoteVerified: remote.remoteVerified,
615
718
  remoteProbe: { remoteUrl: remote.remoteUrl, detail: remote.detail },
@@ -183,6 +183,15 @@ delivery.deliverRunner.footprintGuard: under "advisory" the collisions are
183
183
  detected and listed in "advisory" but never withhold, and dispatch follows the
184
184
  declared depends_on edges alone.
185
185
 
186
+ crossRunOverlaps (probe mode only) is ADVISORY: each probed Story that is
187
+ ready or in flight and shares a concrete path with an open Story in flight in
188
+ ANOTHER session (outside --stories) — { id, otherId, holder, paths } — plus one
189
+ stderr warning line per pair. It never withholds, reorders or delays dispatch
190
+ and never changes the exit code; it uses the same concrete-path rule as
191
+ inFlightReservation. When the outside query fails the envelope carries
192
+ crossRunOverlapProbe: "unavailable" and crossRunOverlapProbeReason instead of
193
+ the list, so "no overlap" is never inferred from a failed read.
194
+
186
195
  Exit codes:
187
196
  0 - Success, ready set emitted
188
197
  1 - Invalid input (missing/malformed DAG, invalid --concurrency/--in-flight/--done)
@@ -890,6 +899,7 @@ export function runStoriesWaveTick({
890
899
  * @param {NodeJS.ProcessEnv} [args.env]
891
900
  * @param {Function} [args.probe] Test seam.
892
901
  * @param {Function} [args.context] Test seam.
902
+ * @param {Function} [args.warn] Test seam.
893
903
  * @returns {Promise<{ envelope: object, exitCode: number, records: object[] }>}
894
904
  * `records` are the probed nodes, kept off stdout.
895
905
  */
@@ -902,6 +912,7 @@ export async function runProbedStoriesWaveTick({
902
912
  env,
903
913
  probe = probeLiveState,
904
914
  context = createProbeContext,
915
+ warn = Logger.warn,
905
916
  } = {}) {
906
917
  const { value: override, error: concurrencyError } =
907
918
  parseConcurrencyOverride(concurrency);
@@ -956,6 +967,8 @@ export async function runProbedStoriesWaveTick({
956
967
  foreignHeld = [],
957
968
  inFlightRecords = [],
958
969
  } = probed;
970
+ const crossRun = crossRunFields(probed);
971
+ for (const line of crossRunWarnings(crossRun.crossRunOverlaps)) warn(line);
959
972
  const { envelope, exitCode } = buildReadySetEnvelope(nodes, {
960
973
  concurrencyCap,
961
974
  capPrecedence,
@@ -979,6 +992,7 @@ export async function runProbedStoriesWaveTick({
979
992
  stalledDispatch,
980
993
  foreignHeld,
981
994
  foreignHeldReason: foreignHeldReasonFor(foreignHeld),
995
+ ...crossRun,
982
996
  },
983
997
  records: nodes,
984
998
  // Blocked outranks a wedge (its blockers are moot while a human owes a
@@ -990,6 +1004,39 @@ export async function runProbedStoriesWaveTick({
990
1004
  };
991
1005
  }
992
1006
 
1007
+ /**
1008
+ * Exactly one advisory shape passes through; a failed read never becomes [].
1009
+ *
1010
+ * @param {object} probed
1011
+ * @returns {object}
1012
+ */
1013
+ function crossRunFields(probed) {
1014
+ if (Array.isArray(probed.crossRunOverlaps)) {
1015
+ return { crossRunOverlaps: probed.crossRunOverlaps };
1016
+ }
1017
+ if (probed.crossRunOverlapProbe === 'unavailable') {
1018
+ return {
1019
+ crossRunOverlapProbe: 'unavailable',
1020
+ crossRunOverlapProbeReason: probed.crossRunOverlapProbeReason ?? null,
1021
+ };
1022
+ }
1023
+ return {};
1024
+ }
1025
+
1026
+ /**
1027
+ * @param {object[]} [overlaps]
1028
+ * @returns {string[]}
1029
+ */
1030
+ function crossRunWarnings(overlaps = []) {
1031
+ return overlaps.map(({ id, otherId, holder, paths }) => {
1032
+ const who = holder ? `held by @${holder}` : 'holder unknown';
1033
+ return (
1034
+ `stories-wave-tick: #${id} overlaps #${otherId} (in flight in another session, ${who}) ` +
1035
+ `on ${paths.join(', ')} — expect a rebase conflict at close. Advisory only; dispatch is unchanged.`
1036
+ );
1037
+ });
1038
+ }
1039
+
993
1040
  /**
994
1041
  * @param {number[]} blockedIds
995
1042
  * @returns {string|null}
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * story-review-compute.js — compute the Story-scope review on the worker, at
5
+ * push time, and deposit it for close to adopt.
6
+ *
7
+ * Runs close's compute step (`computeStoryScopeReview` over the configured
8
+ * provider chain, posting nothing) against `origin/<base>...story-<id>` and
9
+ * writes one held-review JSON beside the terminal envelope, keyed on the diff
10
+ * digest. Close adopts it when the digest still matches, so the serialized
11
+ * close tail only posts; a CRITICAL surfaces here, where the worker can fix
12
+ * and re-push inside its own parallel loop.
13
+ *
14
+ * Posts nothing to GitHub and takes no full-suite lock. Exit 0 whatever the
15
+ * findings; 1 only on a usage error or a provider throw.
16
+ */
17
+
18
+ import { parseArgs } from 'node:util';
19
+
20
+ import { runAsCli } from './lib/cli-utils.js';
21
+ import { resolveConfig } from './lib/config-resolver.js';
22
+ import { getStoryBranch, gitSpawn } from './lib/git-utils.js';
23
+ import { runCodeReview } from './lib/orchestration/code-review.js';
24
+ import { resolveSharedBaseRef } from './lib/orchestration/review-base-ref.js';
25
+ import {
26
+ buildReviewDeposit,
27
+ computeReviewDiffDigest,
28
+ resolveRefSha,
29
+ writeReviewDeposit,
30
+ } from './lib/orchestration/review-deposit.js';
31
+ import { computeStoryScopeReview } from './lib/orchestration/single-story-close/phases/code-review.js';
32
+
33
+ const USAGE = {
34
+ invocation:
35
+ 'node .agents/scripts/story-review-compute.js --story <id> [--cwd <workCwd>]',
36
+ summary:
37
+ 'Compute the Story-scope code review of origin/<base>...story-<id> without posting it, and write the held result (keyed on the diff digest) for close to adopt. Exits 0 whatever the findings.',
38
+ flags: [
39
+ ['--story <id>', 'Story issue number; the head ref is story-<id>.'],
40
+ [
41
+ '--cwd <path>',
42
+ 'Checkout to resolve the branch and diff in (default: the current directory).',
43
+ ],
44
+ ],
45
+ };
46
+
47
+ /**
48
+ * @param {string[]} argv
49
+ * @returns {{ storyId: number|null, cwd: string|null }}
50
+ */
51
+ export function parseArgv(argv) {
52
+ const { values } = parseArgs({
53
+ args: argv,
54
+ options: {
55
+ story: { type: 'string' },
56
+ cwd: { type: 'string' },
57
+ },
58
+ strict: false,
59
+ });
60
+ const storyId = Number.parseInt(values.story ?? '', 10);
61
+ return {
62
+ storyId: Number.isInteger(storyId) && storyId > 0 ? storyId : null,
63
+ cwd: values.cwd ?? null,
64
+ };
65
+ }
66
+
67
+ /**
68
+ * @param {object} severity
69
+ * @returns {string}
70
+ */
71
+ function formatTally(severity) {
72
+ const s = severity ?? {};
73
+ return `critical=${s.critical ?? 0} high=${s.high ?? 0} medium=${s.medium ?? 0} suggestion=${s.suggestion ?? 0}`;
74
+ }
75
+
76
+ /**
77
+ * @param {{ storyId: number, cwd: string, config: object }} input
78
+ * @param {{
79
+ * gitSpawnFn?: typeof gitSpawn,
80
+ * runCodeReviewFn?: typeof runCodeReview,
81
+ * writeDepositFn?: typeof writeReviewDeposit,
82
+ * progress?: (tag: string, msg: string) => void,
83
+ * nowIso?: () => string,
84
+ * }} [deps]
85
+ * @returns {Promise<{ written: boolean, path?: string, reason?: string,
86
+ * deposit?: object }>}
87
+ */
88
+ export async function computeStoryReviewDeposit(
89
+ { storyId, cwd, config },
90
+ deps = {},
91
+ ) {
92
+ const {
93
+ gitSpawnFn = gitSpawn,
94
+ runCodeReviewFn = runCodeReview,
95
+ writeDepositFn = writeReviewDeposit,
96
+ progress = () => {},
97
+ nowIso = () => new Date().toISOString(),
98
+ } = deps;
99
+ const storyBranch = getStoryBranch(storyId);
100
+ const baseBranch = config?.project?.baseBranch ?? 'main';
101
+ const headSha = resolveRefSha({ cwd, ref: storyBranch, gitSpawnFn });
102
+ if (!headSha) {
103
+ return { written: false, reason: `could not resolve ${storyBranch}` };
104
+ }
105
+ const base = resolveSharedBaseRef({ baseBranch, cwd, gitSpawnFn });
106
+ const diffDigest = computeReviewDiffDigest({
107
+ cwd,
108
+ baseRef: base.resolved ? base.ref : null,
109
+ headRef: headSha,
110
+ gitSpawnFn,
111
+ });
112
+ if (!diffDigest) {
113
+ return {
114
+ written: false,
115
+ reason: `could not read the ${base.remoteRef ?? baseBranch}...${storyBranch} diff`,
116
+ };
117
+ }
118
+ const computed = await computeStoryScopeReview({
119
+ cwd,
120
+ storyId,
121
+ headRef: headSha,
122
+ baseBranch,
123
+ deferPost: true,
124
+ provider: null,
125
+ runCodeReviewFn,
126
+ gitSpawnFn,
127
+ progress,
128
+ });
129
+ if (!computed.result) {
130
+ return { written: false, reason: 'the review base is unresolvable' };
131
+ }
132
+ const deposit = buildReviewDeposit({
133
+ storyId,
134
+ headSha,
135
+ baseRef: base.ref,
136
+ diffDigest,
137
+ result: computed.result,
138
+ createdAt: nowIso(),
139
+ });
140
+ const path = writeDepositFn(deposit, { config });
141
+ return { written: true, path, deposit };
142
+ }
143
+
144
+ /**
145
+ * @param {string[]} [argv]
146
+ * @param {{
147
+ * resolveConfigImpl?: typeof resolveConfig,
148
+ * stdout?: { write: (s: string) => void },
149
+ * cwd?: string,
150
+ * } & Parameters<typeof computeStoryReviewDeposit>[1]} [deps]
151
+ * @returns {Promise<object>} the outcome
152
+ */
153
+ export async function runStoryReviewComputeCli(
154
+ argv = process.argv.slice(2),
155
+ deps = {},
156
+ ) {
157
+ const {
158
+ resolveConfigImpl = resolveConfig,
159
+ stdout = process.stdout,
160
+ cwd: defaultCwd = process.cwd(),
161
+ ...computeDeps
162
+ } = deps;
163
+ const { storyId, cwd } = parseArgv(argv);
164
+ if (!storyId) {
165
+ throw new Error(
166
+ 'story-review-compute: --story <id> is required (a positive integer).',
167
+ );
168
+ }
169
+ const workCwd = cwd ?? defaultCwd;
170
+ const config = resolveConfigImpl({ cwd: workCwd });
171
+ const progress =
172
+ computeDeps.progress ??
173
+ ((tag, msg) => stdout.write(`[story-review-compute] [${tag}] ${msg}\n`));
174
+ const outcome = await computeStoryReviewDeposit(
175
+ { storyId, cwd: workCwd, config },
176
+ { ...computeDeps, progress },
177
+ );
178
+ if (!outcome.written) {
179
+ stdout.write(
180
+ `[story-review-compute] ⏭ No held review written: ${outcome.reason}. Close computes the review itself.\n`,
181
+ );
182
+ return outcome;
183
+ }
184
+ const { deposit } = outcome;
185
+ stdout.write(
186
+ `[story-review-compute] ✅ Held review for Story #${storyId} written → ${outcome.path}\n` +
187
+ `[story-review-compute] diff ${deposit.diffDigest.slice(0, 12)} @ ${deposit.headSha.slice(0, 12)} · ${formatTally(deposit.severity)}\n`,
188
+ );
189
+ if (deposit.halted) {
190
+ stdout.write(
191
+ `[story-review-compute] ❌ CRITICAL: fix, commit and re-push before hand-off. Report:\n${deposit.report}\n`,
192
+ );
193
+ }
194
+ return outcome;
195
+ }
196
+
197
+ async function main() {
198
+ await runStoryReviewComputeCli();
199
+ return 0;
200
+ }
201
+
202
+ runAsCli(import.meta.url, main, {
203
+ source: 'story-review-compute',
204
+ propagateExitCode: true,
205
+ errorPrefix: '[story-review-compute] ❌ Fatal error',
206
+ usage: USAGE,
207
+ });