mandrel 1.84.0 → 1.85.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 (33) hide show
  1. package/.agents/docs/agentrc-reference.json +8 -2
  2. package/.agents/docs/configuration.md +7 -2
  3. package/.agents/instructions.md +4 -0
  4. package/.agents/rules/ci-remediation.md +131 -0
  5. package/.agents/schemas/agentrc.schema.json +29 -6
  6. package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
  7. package/.agents/scripts/git-pr-quality-gate.js +7 -5
  8. package/.agents/scripts/lib/config/ci.js +24 -3
  9. package/.agents/scripts/lib/config/explain.js +11 -3
  10. package/.agents/scripts/lib/config/github.js +11 -7
  11. package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
  12. package/.agents/scripts/lib/config-settings-schema.js +6 -6
  13. package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
  14. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
  15. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
  16. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
  17. package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
  18. package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
  19. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
  20. package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
  21. package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
  22. package/.agents/scripts/pr-watch-with-update.js +324 -37
  23. package/.agents/scripts/run-verify.js +18 -3
  24. package/.agents/scripts/single-story-confirm-merge.js +1 -1
  25. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
  26. package/.agents/skills/core/scope-triage/SKILL.md +5 -4
  27. package/.agents/workflows/helpers/deliver-epic-reference.md +22 -8
  28. package/.agents/workflows/helpers/deliver-epic.md +123 -28
  29. package/.agents/workflows/helpers/deliver-stories.md +2 -2
  30. package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
  31. package/.agents/workflows/helpers/single-story-deliver.md +56 -19
  32. package/docs/CHANGELOG.md +9 -0
  33. package/package.json +1 -1
@@ -1,6 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * pr-watch-with-update.js — Phase 8 "watch until green" CLI.
3
+ * pr-watch-with-update.js — the single CI-watch mechanism for BOTH the
4
+ * Epic Phase 8 path (`deliver-epic.md`) and the standalone single-Story
5
+ * Step 4 path (`single-story-deliver.md`). Story #4358 retired the bare
6
+ * `gh pr checks --watch` from the single-Story path so both drive this
7
+ * one CLI.
4
8
  *
5
9
  * Polls the PR's required checks to a terminal state and auto-recovers
6
10
  * from `mergeStateStatus: BEHIND` (via bounded `gh pr update-branch`
@@ -9,24 +13,53 @@
9
13
  * and the bus path are byte-for-byte equivalent. No lifecycle bus is
10
14
  * created; this is a direct, synchronous watch with a real exit code.
11
15
  *
12
- * Contract (Story #3902):
13
- * - Blocks until every required check is terminal (or the poll cap
14
- * fires).
15
- * - Prints the final `{ checkName: outcome }` map to stdout as JSON.
16
- * - Exits 0 only when every required check is green
17
- * (success / neutral / skipped); exits non-zero otherwise (red
18
- * check, timed-out poll cap, or an unresolvable `gh pr checks`
19
- * failure) so the calling workflow can gate on the exit code.
16
+ * Slow-vs-failed semantics (Story #4358):
17
+ * - GREEN every required check terminal + green exit 0.
18
+ * - RED — one or more required checks genuinely failed → exit 1
19
+ * IMMEDIATELY, consuming no resume budget. On red the CLI
20
+ * writes `temp/epic-<id>-ci-digest.{json,md}` (failing check
21
+ * name, run id, a `gh run view --log-failed` tail, and a
22
+ * coarse classification) and prints the `/loop
23
+ * /loops:fix-failing-tests` handoff.
24
+ * - STILL-RUNNING — the poll cap fired with checks still pending and
25
+ * none failed; the watcher re-armed up to
26
+ * `delivery.ci.watch.maxResumes` times, then returned a
27
+ * `still-running` verdict → exit 2 (NEVER 1, NEVER
28
+ * `timed_out`). The CLI prints the `/loop 5m /loops:watch-ci`
29
+ * handoff so the host can keep polling on its own cadence.
30
+ *
31
+ * Config (Story #4356 namespace, read via `getCiDelivery`):
32
+ * - `delivery.ci.watch.pollIntervalMs`
33
+ * - `delivery.ci.watch.maxPolls`
34
+ * - `delivery.ci.watch.maxResumes`
35
+ * CLI flags override config; config overrides the framework fallback.
20
36
  *
21
37
  * Usage:
22
38
  * node .agents/scripts/pr-watch-with-update.js --pr <n> [--repo owner/repo]
23
- * [--max-updates N] [--poll-interval-ms MS] [--max-polls N]
39
+ * [--epic <id>] [--max-updates N] [--poll-interval-ms MS]
40
+ * [--max-polls N] [--max-resumes N]
24
41
  */
42
+ import { spawnSync } from 'node:child_process';
43
+ import { mkdirSync, writeFileSync } from 'node:fs';
44
+ import path from 'node:path';
25
45
  import { parseArgs } from 'node:util';
26
46
  import { runAsCli } from './lib/cli-utils.js';
47
+ import { getCiDelivery } from './lib/config/ci.js';
48
+ import { resolveConfig } from './lib/config-resolver.js';
27
49
  import { Logger } from './lib/Logger.js';
28
50
  import { watchPrToTerminal } from './lib/orchestration/lifecycle/listeners/watcher.js';
29
51
 
52
+ /** Framework fallbacks when neither a CLI flag nor config supplies a value. */
53
+ export const WATCH_DEFAULTS = Object.freeze({
54
+ pollIntervalMs: 10_000,
55
+ maxPolls: 180,
56
+ maxUpdates: 3,
57
+ maxResumes: 3,
58
+ });
59
+
60
+ /** Exit code reserved for the slow-but-not-red `still-running` verdict. */
61
+ export const STILL_RUNNING_EXIT_CODE = 2;
62
+
30
63
  function parsePositiveInt(raw, fallback) {
31
64
  if (raw == null) return fallback;
32
65
  const n = Number.parseInt(raw, 10);
@@ -34,41 +67,232 @@ function parsePositiveInt(raw, fallback) {
34
67
  }
35
68
 
36
69
  /**
37
- * Run the watch loop and resolve to the exit code (0 = all green,
38
- * 1 = not green / failed). Exported for tests so the green / red /
39
- * BEHIND paths can be exercised with injected `gh` spawns and no
40
- * `process.exit`.
70
+ * Resolve the effective poll knobs: CLI flag `delivery.ci.watch.*`
71
+ * framework fallback. Pure (given a config bag) exported for tests so
72
+ * the precedence ladder is reviewable. `flags` are the raw string values
73
+ * from `parseArgs` (or numbers, in tests); a nullish flag falls through
74
+ * to config, and a nullish config field falls through to the default.
75
+ *
76
+ * @param {object} opts
77
+ * @param {object|null} [opts.config] resolved config (or a bare bag).
78
+ * @param {object} [opts.flags] `{ pollIntervalMs, maxPolls, maxResumes, maxUpdates }`.
79
+ * @returns {{ pollIntervalMs: number, maxPolls: number, maxResumes: number, maxUpdates: number }}
80
+ */
81
+ export function resolveWatchKnobs({ config, flags = {} } = {}) {
82
+ const watch = getCiDelivery(config).watch ?? {};
83
+ const pick = (flag, cfg, dflt) =>
84
+ parsePositiveInt(flag, Number.isInteger(cfg) && cfg >= 0 ? cfg : dflt);
85
+ return {
86
+ pollIntervalMs: pick(
87
+ flags.pollIntervalMs,
88
+ watch.pollIntervalMs,
89
+ WATCH_DEFAULTS.pollIntervalMs,
90
+ ),
91
+ maxPolls: pick(flags.maxPolls, watch.maxPolls, WATCH_DEFAULTS.maxPolls),
92
+ maxResumes: pick(
93
+ flags.maxResumes,
94
+ watch.maxResumes,
95
+ WATCH_DEFAULTS.maxResumes,
96
+ ),
97
+ maxUpdates: pick(flags.maxUpdates, undefined, WATCH_DEFAULTS.maxUpdates),
98
+ };
99
+ }
100
+
101
+ /**
102
+ * Coarse failure classification from a failing-check name. Pure —
103
+ * exported for tests. Deliberately shallow: it steers the operator's
104
+ * next move (which `/loop` unit to reach for), not a root-cause verdict.
105
+ *
106
+ * @param {string} name failing required-check name.
107
+ * @returns {'test'|'lint'|'baseline'|'build'|'unknown'}
108
+ */
109
+ export function classifyFailure(name) {
110
+ const n = String(name ?? '').toLowerCase();
111
+ if (/lint|format|biome|markdownlint/.test(n)) return 'lint';
112
+ if (/baseline|coverage|crap|maintainab|duplicat/.test(n)) return 'baseline';
113
+ if (/build|compile|typecheck|bundle/.test(n)) return 'build';
114
+ if (/test|spec|validate|ci|check/.test(n)) return 'test';
115
+ return 'unknown';
116
+ }
117
+
118
+ /**
119
+ * Default `gh run view --log-failed` spawn — pulls the tail of the failed
120
+ * job log so the digest carries an actionable excerpt. Best-effort:
121
+ * returns an empty tail when the run id is unknown or `gh` errors.
122
+ * Exported indirectly via `writeCiDigest` injection so tests can stub
123
+ * without shelling out.
124
+ */
125
+ function ghRunLogTail({ runId, cwd, spawnFn = spawnSync, maxLines = 40 }) {
126
+ if (!runId) return '';
127
+ const result = spawnFn('gh', ['run', 'view', String(runId), '--log-failed'], {
128
+ cwd,
129
+ encoding: 'utf-8',
130
+ shell: false,
131
+ maxBuffer: 10 * 1024 * 1024,
132
+ });
133
+ const out = (result.stdout ?? '').trim();
134
+ if (out.length === 0) return '';
135
+ const lines = out.split('\n');
136
+ return lines.slice(-maxLines).join('\n');
137
+ }
138
+
139
+ /**
140
+ * Resolve the GitHub Actions run id for a failing check. Best-effort via
141
+ * `gh pr checks --json name,link` — the `link` field carries the run URL
142
+ * whose trailing path segment is the run id. Returns `null` when
143
+ * unresolvable.
144
+ */
145
+ function resolveRunId({ prRef, checkName, cwd, spawnFn = spawnSync }) {
146
+ const result = spawnFn('gh', ['pr', 'checks', prRef, '--json', 'name,link'], {
147
+ cwd,
148
+ encoding: 'utf-8',
149
+ shell: false,
150
+ });
151
+ try {
152
+ const parsed = JSON.parse((result.stdout ?? '').trim() || '[]');
153
+ const entry = Array.isArray(parsed)
154
+ ? parsed.find((e) => e?.name === checkName)
155
+ : null;
156
+ const link = entry?.link ?? '';
157
+ const m = /\/runs\/(\d+)/.exec(String(link));
158
+ return m ? m[1] : null;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Write the CI failure digest (`.json` + `.md`) for a red watch. Returns
166
+ * the two paths written (or `null` when no epic id was supplied — the
167
+ * digest is Epic-scoped by filename). Exported for tests.
168
+ *
169
+ * @param {object} opts
170
+ * @param {number|string|null} opts.epicId
171
+ * @param {number} opts.prNumber
172
+ * @param {Array<{name:string, outcome:string}>} opts.failures
173
+ * @param {string} opts.tempRoot
174
+ * @param {string} opts.cwd
175
+ * @param {string} opts.prRef
176
+ * @param {Function} [opts.runIdFn]
177
+ * @param {Function} [opts.logTailFn]
178
+ * @returns {{ jsonPath: string, mdPath: string } | null}
179
+ */
180
+ export function writeCiDigest({
181
+ epicId,
182
+ prNumber,
183
+ failures,
184
+ tempRoot,
185
+ cwd,
186
+ prRef,
187
+ runIdFn = resolveRunId,
188
+ logTailFn = ghRunLogTail,
189
+ }) {
190
+ if (epicId == null || String(epicId).length === 0) return null;
191
+ const primary = failures[0] ?? { name: 'unknown', outcome: 'failure' };
192
+ const runId = runIdFn({ prRef, checkName: primary.name, cwd });
193
+ const logTail = logTailFn({ runId, cwd });
194
+ const classification = classifyFailure(primary.name);
195
+ const digest = {
196
+ epicId: Number.parseInt(String(epicId), 10),
197
+ prNumber,
198
+ failingCheck: primary.name,
199
+ failingOutcome: primary.outcome,
200
+ runId,
201
+ classification,
202
+ allFailures: failures,
203
+ logTail,
204
+ generatedAt: new Date().toISOString(),
205
+ };
206
+ const dir = path.isAbsolute(tempRoot) ? tempRoot : path.join(cwd, tempRoot);
207
+ mkdirSync(dir, { recursive: true });
208
+ const base = `epic-${digest.epicId}-ci-digest`;
209
+ const jsonPath = path.join(dir, `${base}.json`);
210
+ const mdPath = path.join(dir, `${base}.md`);
211
+ writeFileSync(jsonPath, `${JSON.stringify(digest, null, 2)}\n`);
212
+ const md = [
213
+ `# CI failure digest — Epic #${digest.epicId} (PR #${prNumber})`,
214
+ '',
215
+ `- **Failing check:** \`${digest.failingCheck}\` (${digest.failingOutcome})`,
216
+ `- **Run id:** ${runId ?? 'unresolved'}`,
217
+ `- **Classification:** ${classification}`,
218
+ `- **Generated:** ${digest.generatedAt}`,
219
+ '',
220
+ failures.length > 1
221
+ ? `Other non-green checks: ${failures
222
+ .slice(1)
223
+ .map((f) => `\`${f.name}\`=${f.outcome}`)
224
+ .join(', ')}`
225
+ : '',
226
+ '',
227
+ '## `gh run view --log-failed` tail',
228
+ '',
229
+ '```text',
230
+ logTail || '(no failed-log output available)',
231
+ '```',
232
+ '',
233
+ ].join('\n');
234
+ writeFileSync(mdPath, md);
235
+ return { jsonPath, mdPath };
236
+ }
237
+
238
+ /**
239
+ * Run the watch loop and resolve to the exit code. Exported for tests so
240
+ * the green / red / still-running / BEHIND paths can be exercised with
241
+ * injected `gh` spawns and no `process.exit`.
242
+ *
243
+ * 0 → all required checks green.
244
+ * 1 → a required check genuinely failed (red).
245
+ * 2 → still-running (slow CI): cap + resume budget exhausted, none red.
41
246
  *
42
247
  * @param {object} opts
43
248
  * @param {number} opts.prNumber
44
249
  * @param {string|null} [opts.repo]
45
- * @param {number} [opts.maxUpdates]
46
- * @param {number} [opts.pollIntervalMs]
47
- * @param {number} [opts.maxPolls]
48
- * @param {Function} [opts.ghPrChecksFn] inject for tests
49
- * @param {Function} [opts.ghPrViewFn] inject for tests
250
+ * @param {number|string|null} [opts.epicId] Epic id for the red-path digest.
251
+ * @param {number|string} [opts.maxUpdates]
252
+ * @param {number|string} [opts.pollIntervalMs]
253
+ * @param {number|string} [opts.maxPolls]
254
+ * @param {number|string} [opts.maxResumes]
255
+ * @param {object|null} [opts.config] resolved config (defaults to resolveConfig()).
256
+ * @param {string} [opts.tempRoot] digest output dir (default `temp`).
257
+ * @param {Function} [opts.ghPrChecksFn] inject for tests
258
+ * @param {Function} [opts.ghPrViewFn] inject for tests
50
259
  * @param {Function} [opts.ghPrUpdateBranchFn] inject for tests
51
- * @param {Function} [opts.sleepFn] inject for tests
260
+ * @param {Function} [opts.sleepFn] inject for tests
261
+ * @param {Function} [opts.writeDigestFn] inject for tests (default writeCiDigest)
52
262
  * @param {object} [opts.logger]
53
- * @param {(line: string) => void} [opts.print] stdout sink (default console.log)
263
+ * @param {(line: string) => void} [opts.print] stdout sink (default process.stdout)
54
264
  * @returns {Promise<number>} process exit code.
55
265
  */
56
266
  export async function runPrWatch({
57
267
  prNumber,
58
268
  repo = null,
269
+ epicId = null,
59
270
  maxUpdates,
60
271
  pollIntervalMs,
61
272
  maxPolls,
273
+ maxResumes,
274
+ config,
275
+ tempRoot,
62
276
  ghPrChecksFn,
63
277
  ghPrViewFn,
64
278
  ghPrUpdateBranchFn,
65
279
  sleepFn,
280
+ writeDigestFn = writeCiDigest,
66
281
  logger = Logger,
67
282
  print = (line) => process.stdout.write(`${line}\n`),
68
283
  } = {}) {
69
284
  if (!Number.isInteger(prNumber) || prNumber < 1)
70
285
  throw new TypeError('runPrWatch: --pr requires a positive integer');
71
286
 
287
+ const resolvedConfig =
288
+ config !== undefined ? config : safeResolveConfig(logger);
289
+ const knobs = resolveWatchKnobs({
290
+ config: resolvedConfig,
291
+ flags: { pollIntervalMs, maxPolls, maxResumes, maxUpdates },
292
+ });
293
+ const effectiveTempRoot =
294
+ tempRoot ?? resolvedConfig?.project?.paths?.tempRoot ?? 'temp';
295
+
72
296
  // `gh` accepts a bare PR number or a URL; passing `<repo>#<n>` lets
73
297
  // `gh` resolve the right repository without a URL. When `--repo` is
74
298
  // omitted, `gh` infers the repo from the cwd's remote.
@@ -77,9 +301,10 @@ export async function runPrWatch({
77
301
  const result = await watchPrToTerminal({
78
302
  prUrl: prRef,
79
303
  cwd: process.cwd(),
80
- maxPolls: parsePositiveInt(maxPolls, 180),
81
- maxUpdates: parsePositiveInt(maxUpdates, 3),
82
- pollIntervalMs: parsePositiveInt(pollIntervalMs, 10_000),
304
+ maxPolls: knobs.maxPolls,
305
+ maxUpdates: knobs.maxUpdates,
306
+ maxResumes: knobs.maxResumes,
307
+ pollIntervalMs: knobs.pollIntervalMs,
83
308
  ...(ghPrChecksFn ? { ghPrChecksFn } : {}),
84
309
  ...(ghPrViewFn ? { ghPrViewFn } : {}),
85
310
  ...(ghPrUpdateBranchFn ? { ghPrUpdateBranchFn } : {}),
@@ -96,8 +321,10 @@ export async function runPrWatch({
96
321
  requiredChecks: result.requiredChecks,
97
322
  polls: result.polls,
98
323
  updatesApplied: result.updatesApplied,
324
+ resumesApplied: result.resumesApplied,
99
325
  terminal: result.terminal,
100
326
  green: result.green,
327
+ stillRunning: result.stillRunning,
101
328
  ...(result.error ? { error: result.error } : {}),
102
329
  }),
103
330
  );
@@ -108,22 +335,78 @@ export async function runPrWatch({
108
335
  );
109
336
  return 1;
110
337
  }
111
- if (!result.terminal) {
112
- logger.error?.(
113
- `[pr-watch] poll cap reached before every required check went terminal (polls=${result.polls}).`,
114
- );
115
- return 1;
338
+
339
+ if (result.green) {
340
+ logger.info?.('[pr-watch] all required checks green.');
341
+ return 0;
116
342
  }
117
- if (!result.green) {
118
- const red = Object.entries(result.outcomes)
119
- .filter(([, v]) => v !== 'success' && v !== 'neutral' && v !== 'skipped')
120
- .map(([k, v]) => `${k}=${v}`)
343
+
344
+ // Slow-but-not-red: the cap AND resume budget are exhausted with checks
345
+ // still pending and none failed. Never exit 1, never `timed_out` hand
346
+ // off to the host's interval loop and exit 2.
347
+ if (result.stillRunning) {
348
+ const stillPending = Object.entries(result.outcomes)
349
+ .filter(([, v]) => v === 'still-running')
350
+ .map(([k]) => k)
121
351
  .join(', ');
122
- logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
123
- return 1;
352
+ logger.warn?.(
353
+ `[pr-watch] required check(s) still running after ${result.polls} polls + ${result.resumesApplied} resumes: ${stillPending}. Hand off to the interval watch loop:`,
354
+ );
355
+ logger.warn?.('[pr-watch] /loop 5m /loops:watch-ci');
356
+ return STILL_RUNNING_EXIT_CODE;
357
+ }
358
+
359
+ // Genuine red check — exit 1 immediately, write the digest, and surface
360
+ // the fix-loop handoff.
361
+ // Exclude 'still-running' as well as the non-failing states: when the cap
362
+ // fires with a mixed failed+pending map, promotePendingToStillRunning has
363
+ // rewritten the pending entries, and a still-running check is slow, not
364
+ // red — including it here would let it become the digest's "primary"
365
+ // failing check and mispoint the diagnosis at a slow check.
366
+ const failures = Object.entries(result.outcomes)
367
+ .filter(
368
+ ([, v]) =>
369
+ v !== 'success' &&
370
+ v !== 'neutral' &&
371
+ v !== 'skipped' &&
372
+ v !== 'still-running',
373
+ )
374
+ .map(([name, outcome]) => ({ name, outcome }));
375
+ const red = failures.map((f) => `${f.name}=${f.outcome}`).join(', ');
376
+ logger.error?.(`[pr-watch] required check(s) not green: ${red}`);
377
+ let digestPaths = null;
378
+ try {
379
+ digestPaths = writeDigestFn({
380
+ epicId,
381
+ prNumber,
382
+ failures,
383
+ tempRoot: effectiveTempRoot,
384
+ cwd: process.cwd(),
385
+ prRef,
386
+ });
387
+ } catch (err) {
388
+ logger.warn?.(
389
+ `[pr-watch] failed to write CI digest (non-fatal): ${err?.message ?? err}`,
390
+ );
391
+ }
392
+ if (digestPaths) {
393
+ logger.error?.(`[pr-watch] CI failure digest → ${digestPaths.jsonPath}`);
394
+ }
395
+ logger.error?.('[pr-watch] a required check failed. Drive it to green with:');
396
+ logger.error?.('[pr-watch] /loop /loops:fix-failing-tests');
397
+ return 1;
398
+ }
399
+
400
+ /** Resolve config without letting a config error abort the watch. */
401
+ function safeResolveConfig(logger) {
402
+ try {
403
+ return resolveConfig();
404
+ } catch (err) {
405
+ logger?.warn?.(
406
+ `[pr-watch] config resolve failed; using framework watch defaults: ${err?.message ?? err}`,
407
+ );
408
+ return null;
124
409
  }
125
- logger.info?.('[pr-watch] all required checks green.');
126
- return 0;
127
410
  }
128
411
 
129
412
  async function main() {
@@ -131,18 +414,22 @@ async function main() {
131
414
  options: {
132
415
  pr: { type: 'string' },
133
416
  repo: { type: 'string' },
417
+ epic: { type: 'string' },
134
418
  'max-updates': { type: 'string' },
135
419
  'poll-interval-ms': { type: 'string' },
136
420
  'max-polls': { type: 'string' },
421
+ 'max-resumes': { type: 'string' },
137
422
  },
138
423
  strict: false,
139
424
  });
140
425
  return runPrWatch({
141
426
  prNumber: Number.parseInt(values.pr ?? '', 10),
142
427
  repo: values.repo ?? null,
428
+ epicId: values.epic ?? null,
143
429
  maxUpdates: values['max-updates'],
144
430
  pollIntervalMs: values['poll-interval-ms'],
145
431
  maxPolls: values['max-polls'],
432
+ maxResumes: values['max-resumes'],
146
433
  });
147
434
  }
148
435
 
@@ -2,16 +2,31 @@
2
2
  /* node:coverage ignore file */
3
3
 
4
4
  /**
5
- * Local full verification — mirrors the intent of close-validation without
6
- * epic-scoped MI projection or push semantics.
5
+ * Local full verification — a true CI mirror for the gates that CAN be proven
6
+ * locally, without epic-scoped MI projection or push semantics.
7
7
  *
8
- * Order: lint (includes docs:check) → full test suite → unified baselines.
8
+ * Order: audit (SCA) → lint (includes docs:check) → full test suite →
9
+ * unified baselines.
10
+ *
11
+ * The `audit` step runs `npm audit --audit-level=high`, matching CI's
12
+ * "Dependency Vulnerability Audit (SCA)" gate so a local green no longer hides
13
+ * a high-severity advisory that CI would fail on. It is independent of the
14
+ * pre-push `PREPUSH_AUDIT` opt-in, which stays unchanged.
15
+ *
16
+ * A handful of CI gates cannot be reproduced by this command (action pinning,
17
+ * TruffleHog secret scan, the BASELINE_SCOPE=full push-scoped maintainability
18
+ * run) — those are catalogued in docs/ci-contract.md.
9
19
  */
10
20
 
11
21
  import { spawnSync } from 'node:child_process';
12
22
  import { runAsCli } from './lib/cli-utils.js';
13
23
 
14
24
  const STEPS = [
25
+ {
26
+ label: 'audit',
27
+ cmd: 'npm',
28
+ args: ['audit', '--audit-level=high'],
29
+ },
15
30
  { label: 'lint', cmd: 'npm', args: ['run', 'lint'] },
16
31
  { label: 'test', cmd: 'npm', args: ['test'] },
17
32
  {
@@ -10,7 +10,7 @@
10
10
  * auto-merge completes *asynchronously* after the close script exits, so
11
11
  * the `agent::done` flip (which closes the issue) is deferred to this
12
12
  * confirmation step, invoked by the CI-watch loop in
13
- * `single-story-deliver.md` Step 5 once `gh pr checks --watch` exits.
13
+ * `single-story-deliver.md` Step 5 once `pr-watch-with-update.js` exits.
14
14
  *
15
15
  * The script:
16
16
  * 1. Resolves the PR number (`--pr <n>`, or probes
@@ -238,7 +238,38 @@ The envelope also has a **floor**, not just a ceiling: a Story that would plausi
238
238
 
239
239
  - A Story touching more than **`softFiles` (15)** files emits an advisory width finding — a nudge to check cohesion or declare `wide`.
240
240
  - A Story touching more than **`hardFiles` (30)** files is **rejected** unless it declares `wide` with a reason.
241
- - A Story with more than **`maxAcceptance` (14)** acceptance items is **rejected**; more than **`softAcceptanceCount` (10)** emits an advisory warning.
241
+ - Acceptance mass is **advisory only**: more than **`softAcceptanceCount` (10)** acceptance items emits an advisory warning. There is NO hard acceptance ceiling a long binding contract is a signal to re-check cohesion, never a reason to fragment one coherent capability into dependent slices.
242
+
243
+ #### DELIVERY-SCHEDULE SIMULATION (the story count must earn itself)
244
+
245
+ Before emitting, simulate the delivery schedule the plan implies and judge the
246
+ plan by its schedule, not by how tidy the taxonomy looks. The canonical rules
247
+ live in the rendered decomposer prompt (`decomposer-prompts.js`) — in brief:
248
+
249
+ 1. **Build the wave schedule.** A Story runs only after every `depends_on`
250
+ completes, and two Stories that name the same file in `changes[]` cannot
251
+ run in the same wave (the scheduler serializes file-overlapping Stories
252
+ even when no `depends_on` edge links them).
253
+ 2. **Compute the parallelism yield** — story count ÷ critical-path length in
254
+ waves. A yield near 1.0 means the plan is a serial chain: N Stories that
255
+ deliver no faster than one Story while paying N delivery sessions.
256
+ 3. **Every Story must earn its slot** by at least one of **(a) parallelism**
257
+ (it runs concurrently with a sibling in the schedule just built — not
258
+ merely "logically independent"), **(b) risk isolation** (it isolates a
259
+ consumer-facing behavior change or high-risk cutover into its own
260
+ reviewable, revertable unit), or **(c) envelope pressure** (merged into its
261
+ neighbor it would exceed the one-pass delivery envelope).
262
+ 4. **A dependent link with none of those justifications merges into its
263
+ consumer** — the single-consumer merge rule generalized from pairs to
264
+ chains.
265
+ 5. **Hot-file rule.** When one file appears in the `changes[]` of more than a
266
+ third of the Stories, the slicing axis cuts across a shared seam — merge
267
+ the Stories that co-edit it, or re-slice along the seam.
268
+
269
+ End each Story's `reason_to_exist` with its justification letter and one
270
+ clause, e.g. "… (a: runs in wave 1 alongside <slug>)" or "(b: isolates the
271
+ auto-merge default change)". A reason that names only a topic ("config work",
272
+ "docs") with no justification is a merge signal.
242
273
 
243
274
  #### DELIVERY SLICING (consume the Tech Spec target grouping when present)
244
275
 
@@ -50,10 +50,11 @@ The work is a single shippable capability. Signals:
50
50
  - **One capability, one reason to exist.** The artifact describes one coherent
51
51
  change a reviewer would accept as a single PR — the
52
52
  `DELIVERABLE_GRANULARITY_GUIDANCE.definition` notion of a Story.
53
- - **Acceptance fits one Story.** The acceptance-criteria list plausibly fits a
54
- single Story's inline `acceptance[]` i.e. it sits under the
55
- `maxAcceptance` ceiling in `DEFAULT_TASK_SIZING` rather than spanning many
56
- independent outcomes.
53
+ - **Acceptance fits one Story.** The acceptance-criteria list reads as the
54
+ binding contract of one coherent capability rather than spanning many
55
+ independent outcomes. Acceptance mass is advisory-only (the
56
+ `softAcceptanceCount` nudge in `DEFAULT_TASK_SIZING` — there is no hard
57
+ ceiling), so the question is cohesion, not count.
57
58
  - **Footprint fits Story sizing.** The plausible file footprint fits the Story
58
59
  width described by `DEFAULT_TASK_SIZING` (the `softFiles` / `hardFiles`
59
60
  knobs); a legitimately broad-but-cohesive change would declare `wide` rather
@@ -342,14 +342,28 @@ just fires the emit and reads the resulting ledger.
342
342
  refusing to run when the body has no `## Acceptance Table` section
343
343
  and no waiver is set (the
344
344
  start gate in Phase 1 would normally catch that first).
345
- 2. **PR open — bus-driven (Story #2894).** On
346
- `acceptance.reconcile.ok` the `Finalizer` listener invokes
347
- [`openOrLocatePr`](../../scripts/lib/orchestration/finalize/open-or-locate-pr.js)
348
- with `{ epicId, headBranch: 'epic/<id>', baseBranch: 'main' }`.
349
- The helper probes for an existing open PR on the head branch
350
- first (idempotent locate patha re-run of `/deliver`
351
- on the same branch short-circuits without opening a duplicate)
352
- and only opens a new PR when none exists. The listener then
345
+ 2. **PR open / ready — bus-driven (Story #2894; earlyPr split Story
346
+ #4359).** On `acceptance.reconcile.ok` the `Finalizer` listener
347
+ resolves `delivery.ci.earlyPr` via
348
+ [`getCiDelivery`](../../scripts/lib/config/ci.js) (default `true`) and
349
+ branches:
350
+ - **`earlyPr` on (default)**the Epic PR already exists as a draft
351
+ (Phase 2 opened it at wave 1 with
352
+ [`openOrLocatePr`](../../scripts/lib/orchestration/finalize/open-or-locate-pr.js)
353
+ `{ draft: true }`). Finalize locates it and flips it ready-for-review
354
+ via
355
+ [`markPrReady`](../../scripts/lib/orchestration/finalize/open-or-locate-pr.js)
356
+ rather than creating a PR. `gh pr ready` on an already-ready PR is a
357
+ no-op, so replay is idempotent.
358
+ - **`earlyPr` off** — finalize invokes `openOrLocatePr` with
359
+ `{ epicId, headBranch: 'epic/<id>', baseBranch: 'main' }` (no
360
+ `draft`). The helper probes for an existing open PR on the head branch
361
+ first (idempotent locate path — a re-run of `/deliver` on the same
362
+ branch short-circuits without opening a duplicate) and only opens a
363
+ new PR when none exists.
364
+
365
+ In both modes the PR title/body contract (`feat: Epic #<id>` /
366
+ `Closes #<id>`) is identical. The listener then
353
367
  emits `pr.created` → `epic.finalize.end` and **stops** (Story
354
368
  #3367). It does **not** emit `epic.merge.ready`: that event is
355
369
  the sole `AutomergeArmer` trigger, and emitting it from finalize