mandrel 2.67.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 (60) hide show
  1. package/.agents/agents/story-worker.md +15 -11
  2. package/.agents/docs/agentrc-reference.json +3 -1
  3. package/.agents/docs/configuration.md +36 -1
  4. package/.agents/schemas/agentrc.schema.json +14 -1
  5. package/.agents/schemas/story-deliver-terminal.schema.json +23 -1
  6. package/.agents/schemas/validation-evidence.schema.json +3 -1
  7. package/.agents/scripts/coverage-capture.js +65 -9
  8. package/.agents/scripts/evidence-gate.js +106 -8
  9. package/.agents/scripts/lib/baselines/coverage-refresh-scope.js +60 -0
  10. package/.agents/scripts/lib/baselines/crap-updater-cli.js +101 -4
  11. package/.agents/scripts/lib/baselines/refresh-service.js +1 -1
  12. package/.agents/scripts/lib/baselines/seat-missing.js +228 -0
  13. package/.agents/scripts/lib/child-exec.js +39 -1
  14. package/.agents/scripts/lib/close-validation/gates.js +59 -19
  15. package/.agents/scripts/lib/close-validation/process.js +23 -24
  16. package/.agents/scripts/lib/close-validation/runner.js +71 -40
  17. package/.agents/scripts/lib/config/gates/coverage.schema.js +21 -0
  18. package/.agents/scripts/lib/config/quality.js +7 -1
  19. package/.agents/scripts/lib/config/temp-paths.js +15 -0
  20. package/.agents/scripts/lib/config-settings-schema-delivery.js +1 -1
  21. package/.agents/scripts/lib/coverage-baseline.js +78 -5
  22. package/.agents/scripts/lib/coverage-capture-affected.js +345 -0
  23. package/.agents/scripts/lib/coverage-capture-delta.js +180 -0
  24. package/.agents/scripts/lib/coverage-capture-fullscope.js +53 -32
  25. package/.agents/scripts/lib/coverage-capture-incremental.js +49 -26
  26. package/.agents/scripts/lib/coverage-capture-usage.js +1 -1
  27. package/.agents/scripts/lib/coverage-capture.js +121 -81
  28. package/.agents/scripts/lib/full-suite-lock.js +49 -46
  29. package/.agents/scripts/lib/full-suite-queue.js +83 -8
  30. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  31. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  32. package/.agents/scripts/lib/orchestration/code-review.js +15 -3
  33. package/.agents/scripts/lib/orchestration/merge-poll.js +5 -0
  34. package/.agents/scripts/lib/orchestration/review-deposit.js +219 -0
  35. package/.agents/scripts/lib/orchestration/review-providers/code-review.js +11 -7
  36. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +29 -10
  37. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +124 -73
  38. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +38 -20
  39. package/.agents/scripts/lib/orchestration/single-story-close/phases/lock-wait-pending.js +8 -2
  40. package/.agents/scripts/lib/orchestration/single-story-close/review-overlap.js +161 -0
  41. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +47 -7
  42. package/.agents/scripts/lib/orchestration/story-deliver-terminal.js +8 -16
  43. package/.agents/scripts/lib/process-group.js +1 -1
  44. package/.agents/scripts/lib/supervised-suite.js +247 -0
  45. package/.agents/scripts/lib/wave-runner/cross-run-overlap.js +120 -0
  46. package/.agents/scripts/lib/wave-runner/live-probe.js +5 -1
  47. package/.agents/scripts/quality-preview.js +112 -14
  48. package/.agents/scripts/stories-wave-tick.js +47 -0
  49. package/.agents/scripts/story-review-compute.js +207 -0
  50. package/.agents/scripts/update-coverage-baseline.js +15 -10
  51. package/.agents/scripts/update-crap-baseline.js +12 -2
  52. package/.agents/scripts/update-maintainability-baseline.js +12 -2
  53. package/.agents/workflows/helpers/code-review.md +7 -5
  54. package/.agents/workflows/helpers/deliver-digest.md +39 -36
  55. package/.agents/workflows/helpers/deliver-reference.md +115 -5
  56. package/.agents/workflows/helpers/deliver-story.md +2 -1
  57. package/docs/CHANGELOG.md +26 -0
  58. package/lib/cli/registry.js +125 -18
  59. package/lib/migrations/steps/strip-removed-agentrc-keys.js +0 -5
  60. package/package.json +1 -1
@@ -0,0 +1,247 @@
1
+ /**
2
+ * A full suite supervised as a process group. The suite may write
3
+ * `$MANDREL_SUITE_READY_FILE` when its tests start; the kill timer then
4
+ * re-arms a fresh `timeoutMs`, so a pre-test wait (bounded by `timeoutMs`
5
+ * too) never spends the test budget. Close parses the timing line back.
6
+ */
7
+ import { spawn } from 'node:child_process';
8
+ import crypto from 'node:crypto';
9
+ import fs from 'node:fs';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ import {
14
+ groupSpawnOptions,
15
+ killProcessGroup,
16
+ superviseGroup,
17
+ TIMEOUT_EXIT_CODE,
18
+ } from './process-group.js';
19
+
20
+ /** @typedef {{ lockWaitMs: number, hostWaitMs: number|null, testRunMs: number }} SuiteTimings */
21
+
22
+ export const SUITE_READY_FILE_ENV = 'MANDREL_SUITE_READY_FILE';
23
+
24
+ const DEFAULT_READY_POLL_MS = 250;
25
+
26
+ const NO_HOST_WAIT = 'n/a';
27
+
28
+ /** @param {{ dir?: string }} [opts] */
29
+ export function suiteReadyHandshake({ dir = os.tmpdir() } = {}) {
30
+ const name = `mandrel-suite-ready-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
31
+ const file = path.join(dir, name);
32
+ return { file, env: { [SUITE_READY_FILE_ENV]: file } };
33
+ }
34
+
35
+ /** @param {SuiteTimings} timings */
36
+ export function formatSuiteTimings({ lockWaitMs, hostWaitMs, testRunMs }) {
37
+ const host = hostWaitMs === null ? NO_HOST_WAIT : Math.round(hostWaitMs);
38
+ return `⏲ suite timings: lockWaitMs=${Math.round(lockWaitMs)} hostWaitMs=${host} testRunMs=${Math.round(testRunMs)}`;
39
+ }
40
+
41
+ /**
42
+ * @param {string} line
43
+ * @returns {SuiteTimings|null}
44
+ */
45
+ export function parseSuiteTimings(line) {
46
+ const match =
47
+ /suite timings: lockWaitMs=(\d+) hostWaitMs=(\d+|n\/a) testRunMs=(\d+)/u.exec(
48
+ String(line ?? ''),
49
+ );
50
+ if (!match) return null;
51
+ return {
52
+ lockWaitMs: Number(match[1]),
53
+ hostWaitMs: match[2] === NO_HOST_WAIT ? null : Number(match[2]),
54
+ testRunMs: Number(match[3]),
55
+ };
56
+ }
57
+
58
+ function fileExists(fsImpl, file) {
59
+ try {
60
+ return fsImpl.existsSync(file);
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ function removeQuietly(fsImpl, file) {
67
+ try {
68
+ fsImpl.rmSync(file, { force: true });
69
+ } catch {
70
+ // A leftover marker is harmless.
71
+ }
72
+ }
73
+
74
+ class SuiteClock {
75
+ constructor({ kill, timeoutMs, readyTimeoutMs, readyFile, fsImpl, nowFn }) {
76
+ Object.assign(this, { kill, timeoutMs, readyFile, fsImpl, nowFn });
77
+ this.timedOut = false;
78
+ this.startedAt = nowFn();
79
+ this.readyAt = null;
80
+ this.endedAt = null;
81
+ this.timer = null;
82
+ this.poller = null;
83
+ this.arm(readyTimeoutMs ?? timeoutMs);
84
+ }
85
+
86
+ arm(boundMs) {
87
+ clearTimeout(this.timer);
88
+ if (!(Number.isFinite(boundMs) && boundMs > 0)) return;
89
+ this.timer = setTimeout(() => {
90
+ this.timedOut = true;
91
+ this.kill();
92
+ }, boundMs);
93
+ }
94
+
95
+ watch(pollMs) {
96
+ this.poller = setInterval(() => this.checkReady(), pollMs);
97
+ this.poller.unref?.();
98
+ }
99
+
100
+ checkReady() {
101
+ if (this.readyAt !== null || this.timedOut) return;
102
+ if (!fileExists(this.fsImpl, this.readyFile)) return;
103
+ this.readyAt = this.nowFn();
104
+ clearInterval(this.poller);
105
+ this.arm(this.timeoutMs);
106
+ }
107
+
108
+ stop() {
109
+ this.endedAt ??= this.nowFn();
110
+ this.checkReady();
111
+ clearInterval(this.poller);
112
+ clearTimeout(this.timer);
113
+ removeQuietly(this.fsImpl, this.readyFile);
114
+ }
115
+
116
+ timings() {
117
+ const end = this.endedAt ?? this.nowFn();
118
+ const testStart = this.readyAt ?? this.startedAt;
119
+ return {
120
+ hostWaitMs: this.readyAt === null ? null : this.readyAt - this.startedAt,
121
+ testRunMs: Math.max(0, end - testStart),
122
+ };
123
+ }
124
+ }
125
+
126
+ /**
127
+ * `hostWaitMs` is spawn → ready (null without a signal); `testRunMs` ends at
128
+ * exit. `readyTimeoutMs` (pre-ready bound) is a test seam over `timeoutMs`.
129
+ *
130
+ * @param {{ pid?: number, kill?: Function }} child
131
+ * @param {{ readyFile: string, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, abortSignal?: AbortSignal, signalOnParentSignal?: string, fsImpl?: object, nowFn?: () => number }} opts
132
+ */
133
+ export function superviseSuite(child, opts) {
134
+ const { readyPollMs = DEFAULT_READY_POLL_MS, fsImpl = fs } = opts;
135
+ const group = superviseGroup(child, {
136
+ abortSignal: opts.abortSignal,
137
+ signalOnParentSignal: opts.signalOnParentSignal,
138
+ });
139
+ const clock = new SuiteClock({
140
+ ...opts,
141
+ fsImpl,
142
+ nowFn: opts.nowFn ?? Date.now,
143
+ kill: () => killProcessGroup(child, 'SIGKILL'),
144
+ });
145
+ clock.watch(readyPollMs);
146
+ return {
147
+ get timedOut() {
148
+ return clock.timedOut;
149
+ },
150
+ get timings() {
151
+ return clock.timings();
152
+ },
153
+ release() {
154
+ clock.stop();
155
+ group.release();
156
+ },
157
+ };
158
+ }
159
+
160
+ /**
161
+ * A bare suite gets SIGKILL on a parent signal; a gate with its own cleanup
162
+ * (a capture holding the lock) SIGTERM. A full-suite gate also gets the
163
+ * handshake and emits its timing line on release.
164
+ *
165
+ * @param {{ fullSuiteLock?: boolean, timeoutMs?: number, signal?: AbortSignal, env?: Record<string, string> }} opts
166
+ * @param {{ lockWaitMs?: number }} [lock]
167
+ */
168
+ export function gateSupervision(opts, lock = {}) {
169
+ const base = {
170
+ timeoutMs: opts.timeoutMs,
171
+ abortSignal: opts.signal,
172
+ signalOnParentSignal: opts.fullSuiteLock ? 'SIGKILL' : 'SIGTERM',
173
+ };
174
+ if (!opts.fullSuiteLock) {
175
+ return { env: opts.env, supervise: (child) => superviseGroup(child, base) };
176
+ }
177
+ const handshake = suiteReadyHandshake();
178
+ return {
179
+ env: { ...opts.env, ...handshake.env },
180
+ supervise: (child, output) =>
181
+ reportOnRelease(
182
+ superviseSuite(child, { ...base, readyFile: handshake.file }),
183
+ (timings) =>
184
+ output.emit(
185
+ output.prefix +
186
+ formatSuiteTimings({
187
+ lockWaitMs: lock.lockWaitMs ?? 0,
188
+ ...timings,
189
+ }),
190
+ ),
191
+ ),
192
+ };
193
+ }
194
+
195
+ /**
196
+ * @param {ReturnType<typeof superviseSuite>} supervisor
197
+ * @param {(timings: object) => void} report
198
+ */
199
+ function reportOnRelease(supervisor, report) {
200
+ return {
201
+ get timedOut() {
202
+ return supervisor.timedOut;
203
+ },
204
+ release() {
205
+ supervisor.release();
206
+ report(supervisor.timings);
207
+ },
208
+ };
209
+ }
210
+
211
+ /**
212
+ * Resolves the exit code, or `124` when the supervisor killed the suite.
213
+ *
214
+ * @param {{ cmd: string, args: string[], cwd: string, env?: Record<string, string>, timeoutMs?: number, readyTimeoutMs?: number, readyPollMs?: number, lockWaitMs?: number, spawnImpl?: typeof spawn, onTimings?: (timings: SuiteTimings) => void, onTimeout?: () => void }} opts
215
+ * @returns {Promise<number>}
216
+ */
217
+ export function runSupervisedSuite(opts) {
218
+ const { cmd, args, cwd, env = {}, spawnImpl = spawn } = opts;
219
+ const handshake = suiteReadyHandshake();
220
+ return new Promise((resolve) => {
221
+ const child = spawnImpl(cmd, args, {
222
+ cwd,
223
+ env: { ...process.env, ...env, ...handshake.env },
224
+ stdio: 'inherit',
225
+ shell: process.platform === 'win32',
226
+ ...groupSpawnOptions(),
227
+ });
228
+ const supervisor = superviseSuite(child, {
229
+ ...opts,
230
+ readyFile: handshake.file,
231
+ });
232
+ let settled = false;
233
+ const settle = (code) => {
234
+ if (settled) return;
235
+ settled = true;
236
+ supervisor.release();
237
+ opts.onTimings?.({
238
+ lockWaitMs: opts.lockWaitMs ?? 0,
239
+ ...supervisor.timings,
240
+ });
241
+ if (supervisor.timedOut) opts.onTimeout?.();
242
+ resolve(supervisor.timedOut ? TIMEOUT_EXIT_CODE : code);
243
+ };
244
+ child.on('error', () => settle(1));
245
+ child.on('exit', (code) => settle(code ?? 1));
246
+ });
247
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Advisory report of probed Stories whose declared footprint shares a
3
+ * concrete path with a Story another session has in flight. Never feeds
4
+ * selection, ordering or the exit code.
5
+ *
6
+ * @module lib/wave-runner/cross-run-overlap
7
+ */
8
+
9
+ import { TYPE_LABELS } from '../label-constants.js';
10
+ import { storyFootprintPaths } from '../orchestration/resolve-stories.js';
11
+ import { currentOwner } from '../orchestration/ticket-lease.js';
12
+ import { detectCollision } from './footprint.js';
13
+ import { classifyStory, storyIdOf } from './ready-set.js';
14
+
15
+ /**
16
+ * One labelled list query. Never rejects, so a failed read reports
17
+ * "unavailable" instead of an empty list.
18
+ *
19
+ * @param {object} provider
20
+ * @returns {Promise<{ tickets?: object[], error?: string }>}
21
+ */
22
+ async function listOpenStories(provider) {
23
+ if (typeof provider?.listTicketsByLabel !== 'function') {
24
+ return { error: 'the provider cannot list issues by label' };
25
+ }
26
+ try {
27
+ const tickets = await provider.listTicketsByLabel({
28
+ state: 'open',
29
+ labels: TYPE_LABELS.STORY,
30
+ });
31
+ return { tickets: Array.isArray(tickets) ? tickets : [] };
32
+ } catch (err) {
33
+ return { error: String(err?.message ?? err) };
34
+ }
35
+ }
36
+
37
+ /**
38
+ * @param {object[]} tickets
39
+ * @param {Set<number>} inSetIds
40
+ * @returns {Array<{id: number, files: string[], holder: string|null}>}
41
+ */
42
+ function outsideInFlight(tickets, inSetIds) {
43
+ const outside = [];
44
+ for (const ticket of tickets) {
45
+ const id = storyIdOf(ticket);
46
+ if (id === null || inSetIds.has(id)) continue;
47
+ if (classifyStory(ticket) !== 'executing') continue;
48
+ outside.push({
49
+ id,
50
+ files: storyFootprintPaths(ticket.body, id),
51
+ holder: currentOwner(ticket.assignees),
52
+ });
53
+ }
54
+ return outside;
55
+ }
56
+
57
+ /**
58
+ * The reservation guard's `concreteOnly` rule, so advisory and guard never
59
+ * disagree: a glob or the UNKNOWN sentinel reports nothing.
60
+ *
61
+ * @param {Array<{id: number}>} probed
62
+ * @param {Array<{id: number, holder: string|null}>} outside
63
+ * @returns {object[]}
64
+ */
65
+ function findCrossRunOverlaps(probed, outside) {
66
+ const overlaps = [];
67
+ for (const rec of probed) {
68
+ for (const other of outside) {
69
+ const hit = detectCollision(rec, other, { concreteOnly: true });
70
+ if (!hit) continue;
71
+ overlaps.push({
72
+ id: rec.id,
73
+ otherId: other.id,
74
+ holder: other.holder,
75
+ paths: hit.paths,
76
+ });
77
+ }
78
+ }
79
+ return overlaps;
80
+ }
81
+
82
+ /**
83
+ * @param {{ tickets?: object[], error?: string }} listing
84
+ * @param {object[]} nodes
85
+ * @param {Set<number>} inSetIds
86
+ * @returns {object}
87
+ */
88
+ function buildReport(listing, nodes, inSetIds) {
89
+ if (listing.error) {
90
+ return {
91
+ crossRunOverlapProbe: 'unavailable',
92
+ crossRunOverlapProbeReason: `Could not list in-flight Stories outside this run: ${listing.error}`,
93
+ };
94
+ }
95
+ const candidates = nodes.filter((node) => {
96
+ const cls = classifyStory(node);
97
+ return cls === 'ready' || cls === 'executing';
98
+ });
99
+ return {
100
+ crossRunOverlaps: findCrossRunOverlaps(
101
+ candidates,
102
+ outsideInFlight(listing.tickets, inSetIds),
103
+ ),
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Starts the read now so it overlaps the per-Story reads; probed-set Stories
109
+ * are excluded (their overlap is the ready-set guard's job).
110
+ *
111
+ * @param {object} provider
112
+ * @returns {{ report: (nodes: object[]) => Promise<object> }}
113
+ */
114
+ export function startCrossRunProbe(provider) {
115
+ const listing = listOpenStories(provider);
116
+ return {
117
+ report: async (nodes) =>
118
+ buildReport(await listing, nodes, new Set(nodes.map((n) => n.id))),
119
+ };
120
+ }
@@ -19,6 +19,7 @@ import {
19
19
  currentOwner,
20
20
  normalizeOperatorHandle,
21
21
  } from '../orchestration/ticket-lease.js';
22
+ import { startCrossRunProbe } from './cross-run-overlap.js';
22
23
  import { classifyStory, storyIdOf } from './ready-set.js';
23
24
 
24
25
  /**
@@ -143,7 +144,8 @@ export function createProbeContext({
143
144
  * inFlight: number,
144
145
  * blockedIds: number[],
145
146
  * stalledDispatch: number[],
146
- * foreignHeld: Array<{id: number, holder: string}>
147
+ * foreignHeld: Array<{id: number, holder: string}>,
148
+ * crossRunOverlaps?: object[]
147
149
  * }>}
148
150
  */
149
151
  export async function probeLiveState({
@@ -156,6 +158,7 @@ export async function probeLiveState({
156
158
  self,
157
159
  warn,
158
160
  }) {
161
+ const crossRun = startCrossRunProbe(provider);
159
162
  // The `agent::*` guard is an admission check; a just-dispatched Story is
160
163
  // legitimately unlabelled until init flips it, so a per-beat probe allows it.
161
164
  const stories = await fetchStories(provider, ids, { allowUnlabelled: true });
@@ -207,6 +210,7 @@ export async function probeLiveState({
207
210
  .filter((id) => !foreignHeld.has(id))
208
211
  .sort((a, b) => a - b),
209
212
  foreignHeld: [...foreignHeld].map(([id, holder]) => ({ id, holder })),
213
+ ...(await crossRun.report(nodes)),
210
214
  };
211
215
  }
212
216
 
@@ -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,
@@ -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}