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.
- package/.agents/agents/acceptance-critic.md +2 -2
- package/.agents/agents/story-worker.md +15 -11
- package/.agents/docs/agentrc-reference.json +5 -2
- package/.agents/docs/configuration.md +38 -2
- package/.agents/docs/workflows.md +4 -2
- package/.agents/instructions.md +2 -1
- package/.agents/rules/git-conventions-reference.md +5 -5
- package/.agents/rules/git-conventions.md +1 -1
- package/.agents/schemas/agentrc.schema.json +20 -2
- package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
- package/.agents/schemas/validation-evidence.schema.json +3 -1
- package/.agents/scripts/boot-sweep.js +97 -9
- package/.agents/scripts/{git-cleanup.js → clean-git.js} +2 -2
- package/.agents/scripts/clean-temp.js +54 -0
- package/.agents/scripts/clean-worktrees.js +593 -0
- package/.agents/scripts/coverage-capture.js +65 -9
- package/.agents/scripts/drain-pending-cleanup.js +5 -4
- package/.agents/scripts/evidence-gate.js +106 -8
- package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
- package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
- package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
- package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
- package/.agents/scripts/lib/child-exec.js +39 -1
- package/.agents/scripts/lib/clean-temp.js +440 -0
- package/.agents/scripts/lib/close-validation/gates.js +59 -19
- package/.agents/scripts/lib/close-validation/process.js +23 -24
- package/.agents/scripts/lib/close-validation/runner.js +71 -40
- package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
- package/.agents/scripts/lib/config/quality.js +7 -1
- package/.agents/scripts/lib/config/temp-paths.js +15 -0
- package/.agents/scripts/lib/config-settings-schema-delivery.js +12 -3
- package/.agents/scripts/lib/coverage-baseline.js +78 -5
- package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
- package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
- package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
- package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
- package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
- package/.agents/scripts/lib/coverage-capture.js +121 -81
- package/.agents/scripts/lib/full-suite-lock.js +49 -46
- package/.agents/scripts/lib/full-suite-queue.js +83 -8
- package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
- package/.agents/scripts/lib/observability/source-classifier.js +4 -1
- package/.agents/scripts/lib/orchestration/code-review.js +15 -3
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/cli.js +1 -1
- package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
- package/.agents/scripts/lib/orchestration/plan-runner/worktree-sweep.js +149 -97
- package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
- package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
- package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
- package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
- package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
- package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
- package/.agents/scripts/lib/process-group.js +1 -1
- package/.agents/scripts/lib/single-story-sweep.js +2 -2
- package/.agents/scripts/lib/supervised-suite.js +247 -0
- package/.agents/scripts/lib/temp-removal.js +110 -0
- package/.agents/scripts/lib/temp-retention.js +122 -73
- package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
- package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
- package/.agents/scripts/lib/worktree/canonical-path.js +34 -0
- package/.agents/scripts/lib/worktree/lifecycle/reap.js +15 -4
- package/.agents/scripts/quality-preview.js +112 -14
- package/.agents/scripts/single-story-init.js +120 -17
- package/.agents/scripts/stories-wave-tick.js +47 -0
- package/.agents/scripts/story-review-compute.js +207 -0
- package/.agents/scripts/update-coverage-baseline.js +15 -10
- package/.agents/scripts/update-crap-baseline.js +12 -2
- package/.agents/scripts/update-maintainability-baseline.js +12 -2
- package/.agents/workflows/{git-cleanup.md → clean-git.md} +10 -10
- package/.agents/workflows/clean-temp.md +67 -0
- package/.agents/workflows/clean-worktrees.md +63 -0
- package/.agents/workflows/git-deliver.md +1 -1
- package/.agents/workflows/helpers/acceptance-self-eval.md +3 -2
- package/.agents/workflows/helpers/code-review.md +7 -5
- package/.agents/workflows/helpers/deliver-digest.md +39 -36
- package/.agents/workflows/helpers/deliver-reference.md +115 -5
- package/.agents/workflows/helpers/deliver-story-reference.md +2 -2
- package/.agents/workflows/helpers/deliver-story.md +2 -1
- package/docs/CHANGELOG.md +39 -0
- package/lib/cli/registry.js +125 -18
- package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
- 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 =
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
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
|
+
});
|