mandrel 1.62.0 → 1.63.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 (27) hide show
  1. package/.agents/scripts/check-action-pinning.js +260 -0
  2. package/.agents/scripts/check-arch-cycles.js +38 -14
  3. package/.agents/scripts/epic-deliver-prepare.js +149 -104
  4. package/.agents/scripts/lib/baseline-snapshot.js +245 -141
  5. package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
  6. package/.agents/scripts/lib/orchestration/code-review.js +206 -168
  7. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
  8. package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
  9. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
  10. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
  11. package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
  12. package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
  13. package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
  14. package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
  15. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
  16. package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
  17. package/.agents/scripts/lib/signals/detectors/common.js +107 -0
  18. package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
  19. package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
  20. package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
  21. package/.agents/scripts/lib/story-body/story-body.js +102 -76
  22. package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
  23. package/.agents/scripts/single-story-init.js +16 -3
  24. package/.agents/workflows/audit-architecture.md +9 -0
  25. package/README.md +1 -1
  26. package/docs/CHANGELOG.md +28 -0
  27. package/package.json +1 -1
@@ -0,0 +1,260 @@
1
+ /**
2
+ * CLI: third-party GitHub Action pinning gate.
3
+ *
4
+ * Story #4079 (audit::devops). Closes a supply-chain regression window the
5
+ * `ci.yml` / `release-please.yml` comments *claimed* was guarded by a
6
+ * nonexistent `npm run audit-security` gate. There is no such npm script;
7
+ * `audit-security` is only a manual `/audit-security` slash-command lens.
8
+ * Nothing actually enforced that third-party `uses:` refs stay SHA-pinned,
9
+ * so a future edit reverting `trufflehog@<sha>` to `@main` would pass CI
10
+ * silently.
11
+ *
12
+ * This script scans `.github/workflows/*.yml` (and `*.yaml`), extracts every
13
+ * `uses:` ref, and fails the build when a **third-party** action (anything
14
+ * not under the first-party `actions/*` org) is pinned to a floating ref
15
+ * instead of a full 40-char commit SHA. First-party `actions/*` refs are
16
+ * allowed on major-version tags (`@v4`) — Dependabot's `github-actions`
17
+ * ecosystem bumps those in-place, matching the rationale in the workflow
18
+ * file headers.
19
+ *
20
+ * A "floating ref" is any of:
21
+ * - a branch head: `@main`, `@master`
22
+ * - a tag / partial-SHA that is not a full 40-hex-char commit SHA
23
+ * (`@v5`, `@v3.95.3`, `@release`, a 7-char short SHA, …)
24
+ *
25
+ * Contract:
26
+ * - Scans the workflows directory (default `.github/workflows`, override
27
+ * with `--dir <path>`).
28
+ * - Prints `<file>:<lineNo> <ref> — <reason>` for each violation, then a
29
+ * one-line summary even on a clean scan so operators see the "ok" signal.
30
+ * - With `--json`: writes a structured envelope to stdout and skips the
31
+ * human summary.
32
+ * - Exit codes: 0 = no violations; 1 = at least one floating third-party
33
+ * ref. A missing / empty workflows directory exits 0 (nothing to gate).
34
+ */
35
+
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import process from 'node:process';
39
+ import { runAsCli } from './lib/cli-utils.js';
40
+
41
+ /**
42
+ * Parse argv for `--dir <path>` and `--json`. Exported so unit tests can pin
43
+ * the parser.
44
+ *
45
+ * @param {string[]} argv
46
+ * @returns {{ dir: string | null, json: boolean }}
47
+ */
48
+ export function parseArgv(argv = []) {
49
+ let dir = null;
50
+ let json = false;
51
+ for (let i = 0; i < argv.length; i += 1) {
52
+ const a = argv[i];
53
+ if (a === '--dir') {
54
+ const next = argv[i + 1];
55
+ if (next && !next.startsWith('--')) {
56
+ dir = next;
57
+ i += 1;
58
+ }
59
+ } else if (a === '--json') {
60
+ json = true;
61
+ }
62
+ }
63
+ return { dir, json };
64
+ }
65
+
66
+ /**
67
+ * Is the given ref suffix a full 40-char hex commit SHA?
68
+ *
69
+ * @param {string} ref The portion after the `@` in a `uses:` value.
70
+ * @returns {boolean}
71
+ */
72
+ export function isFullSha(ref) {
73
+ return /^[0-9a-f]{40}$/i.test(ref);
74
+ }
75
+
76
+ /**
77
+ * Is the action a first-party `actions/*` action (e.g. `actions/checkout`)?
78
+ * First-party refs are allowed to float on major-version tags because
79
+ * Dependabot's `github-actions` ecosystem bumps them in-place.
80
+ *
81
+ * Local (`./…`) and reusable-workflow (`owner/repo/.github/workflows/x.yml`)
82
+ * refs and Docker refs (`docker://…`) are out of scope for the SHA-pin gate;
83
+ * `isFirstParty` only matters for `owner/repo[@ref]` registry actions.
84
+ *
85
+ * @param {string} action The portion before the `@` in a `uses:` value.
86
+ * @returns {boolean}
87
+ */
88
+ export function isFirstParty(action) {
89
+ return /^actions\//.test(action);
90
+ }
91
+
92
+ /**
93
+ * Pure helper: scan a single workflow file's text for `uses:` refs and return
94
+ * the violations. A violation is a third-party `owner/repo@ref` where `ref`
95
+ * is not a full 40-char SHA.
96
+ *
97
+ * Skips:
98
+ * - local actions (`uses: ./path`)
99
+ * - Docker refs (`uses: docker://…`)
100
+ * - first-party `actions/*` refs (allowed on major-version tags)
101
+ * - refs with no `@` (pinned by default branch implicitly — flagged as a
102
+ * violation: an unpinned third-party ref floats on the default branch)
103
+ *
104
+ * @param {string} file Relative file label used in violation rows.
105
+ * @param {string} text The file contents.
106
+ * @returns {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>}
107
+ */
108
+ export function scanWorkflowText(file, text) {
109
+ const violations = [];
110
+ const lines = text.split(/\r?\n/);
111
+ // Match `uses:` values, optionally quoted. The value runs until whitespace
112
+ // or a `#` comment. Capture the raw value for downstream parsing.
113
+ const usesRe = /^\s*(?:-\s*)?uses:\s*['"]?([^'"#\s]+)['"]?/;
114
+ for (let i = 0; i < lines.length; i += 1) {
115
+ const m = usesRe.exec(lines[i]);
116
+ if (!m) continue;
117
+ const value = m[1];
118
+ const lineNo = i + 1;
119
+ // Local actions and Docker refs are out of scope for the SHA-pin gate.
120
+ if (value.startsWith('./') || value.startsWith('docker://')) continue;
121
+ const atIndex = value.indexOf('@');
122
+ const action = atIndex === -1 ? value : value.slice(0, atIndex);
123
+ const ref = atIndex === -1 ? null : value.slice(atIndex + 1);
124
+ // First-party actions/* may float on major-version tags.
125
+ if (isFirstParty(action)) continue;
126
+ if (ref === null) {
127
+ violations.push({
128
+ file,
129
+ line: lineNo,
130
+ action,
131
+ ref: null,
132
+ reason: 'third-party action with no ref floats on the default branch',
133
+ });
134
+ continue;
135
+ }
136
+ if (!isFullSha(ref)) {
137
+ const floating = ref === 'main' || ref === 'master';
138
+ violations.push({
139
+ file,
140
+ line: lineNo,
141
+ action,
142
+ ref,
143
+ reason: floating
144
+ ? `third-party action pinned to branch head @${ref} (CWE-1357)`
145
+ : `third-party action @${ref} is not a full 40-char commit SHA`,
146
+ });
147
+ }
148
+ }
149
+ return violations;
150
+ }
151
+
152
+ /**
153
+ * Enumerate workflow files (`*.yml` / `*.yaml`) directly under `dir`.
154
+ * Returns absolute paths sorted for deterministic output. A missing directory
155
+ * yields an empty list.
156
+ *
157
+ * @param {string} dir Absolute workflows directory.
158
+ * @returns {string[]}
159
+ */
160
+ export function listWorkflowFiles(dir) {
161
+ let entries;
162
+ try {
163
+ entries = fs.readdirSync(dir, { withFileTypes: true });
164
+ } catch {
165
+ return [];
166
+ }
167
+ return entries
168
+ .filter((e) => e.isFile() && /\.ya?ml$/i.test(e.name))
169
+ .map((e) => path.join(dir, e.name))
170
+ .sort();
171
+ }
172
+
173
+ /**
174
+ * Pure helper: render the human-readable report. One line per violation
175
+ * followed by a one-line summary. The summary carries a `(gate fail)` /
176
+ * `(ok)` marker so the result is visible in CI output.
177
+ *
178
+ * @param {Array<{ file: string, line: number, action: string, ref: string | null, reason: string }>} violations
179
+ * @returns {string}
180
+ */
181
+ export function renderReport(violations) {
182
+ const lines = [];
183
+ for (const v of violations) {
184
+ const refLabel = v.ref === null ? '(no ref)' : `@${v.ref}`;
185
+ lines.push(`${v.file}:${v.line} ${v.action}${refLabel} — ${v.reason}`);
186
+ }
187
+ const tag = violations.length > 0 ? '(gate fail)' : '(ok)';
188
+ lines.push(`[action-pinning] violations=${violations.length} ${tag}`);
189
+ return lines.join('\n');
190
+ }
191
+
192
+ /**
193
+ * Top-level CLI entry. Exported so tests can drive the full pipeline against a
194
+ * fixture workflows directory without touching the repo's real workflows.
195
+ *
196
+ * @param {{
197
+ * argv?: string[],
198
+ * cwd?: string,
199
+ * stdout?: { write: (s: string) => void },
200
+ * stderr?: { write: (s: string) => void },
201
+ * }} [opts]
202
+ * @returns {Promise<number>} exit code: 0 = clean; 1 = floating third-party ref
203
+ */
204
+ export async function runCli({
205
+ argv = process.argv.slice(2),
206
+ cwd = process.cwd(),
207
+ stdout = process.stdout,
208
+ stderr = process.stderr,
209
+ } = {}) {
210
+ const { dir, json } = parseArgv(argv);
211
+ const resolvedDir = path.resolve(
212
+ cwd,
213
+ dir ?? path.join('.github', 'workflows'),
214
+ );
215
+
216
+ const files = listWorkflowFiles(resolvedDir);
217
+ const violations = [];
218
+ for (const file of files) {
219
+ let text;
220
+ try {
221
+ text = fs.readFileSync(file, 'utf-8');
222
+ } catch {
223
+ continue;
224
+ }
225
+ violations.push(...scanWorkflowText(path.relative(cwd, file), text));
226
+ }
227
+
228
+ const exitCode = violations.length > 0 ? 1 : 0;
229
+
230
+ if (json) {
231
+ const envelope = {
232
+ kind: 'action-pinning-report',
233
+ dir: resolvedDir,
234
+ filesScanned: files.length,
235
+ violations,
236
+ exitCode,
237
+ };
238
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
239
+ } else {
240
+ if (files.length === 0) {
241
+ stderr.write(
242
+ `[action-pinning] ⚠ no workflow files found under ${resolvedDir}\n`,
243
+ );
244
+ }
245
+ stdout.write(`\n--- action-pinning scan ---\n`);
246
+ stdout.write(`${renderReport(violations)}\n`);
247
+ }
248
+
249
+ return exitCode;
250
+ }
251
+
252
+ async function main() {
253
+ return runCli();
254
+ }
255
+
256
+ runAsCli(import.meta.url, main, {
257
+ source: 'action-pinning',
258
+ propagateExitCode: true,
259
+ errorPrefix: '[action-pinning] ❌ Fatal error',
260
+ });
@@ -1,10 +1,19 @@
1
1
  /**
2
2
  * CLI: ratchet-down architecture gate for import cycles (Story #3991).
3
3
  *
4
- * Walks every `.js` file under `.agents/scripts/` (excluding
5
- * `node_modules`), parses relative static-import edges
6
- * (`from './…/x.js'`), detects directed cycles via DFS, and compares
7
- * them against the committed allowlist at `baselines/arch-cycles.json`.
4
+ * Walks every `.js` file across the project's **distributed surface**
5
+ * (the `files[]` set published to npm — `.agents/scripts/`, `bin/`, and
6
+ * the root `lib/`, excluding `node_modules`), parses relative
7
+ * static-import edges (`from './…/x.js'`), detects directed cycles via
8
+ * DFS, and compares them against the committed allowlist at
9
+ * `baselines/arch-cycles.json`.
10
+ *
11
+ * The multi-root scan resolves every root into a **single** import graph
12
+ * keyed by repository-relative module ids (Story #4071). This lets
13
+ * `findCycles` catch cycles that cross the documented lifecycle↔runtime
14
+ * partition — e.g. a `bin/` lifecycle script and an `.agents/scripts/lib`
15
+ * runtime module importing each other — which a single-root scan cannot
16
+ * see because it only walks one side of the partition.
8
17
  *
9
18
  * Ratchet semantics mirror `check-dead-exports.js`:
10
19
  * - Any detected cycle NOT in the allowlist → exit 1, cycle path printed.
@@ -19,8 +28,8 @@
19
28
  * Flags:
20
29
  * --baseline <path> override the allowlist path (default
21
30
  * `baselines/arch-cycles.json`, resolved from cwd)
22
- * --root <path> override the scanned root (default
23
- * `.agents/scripts`, resolved from cwd)
31
+ * --root <path> scan a single explicit root instead of the default
32
+ * distributed surface, relativized against that root
24
33
  * --json write the structured envelope to stdout
25
34
  */
26
35
 
@@ -29,6 +38,16 @@ import path from 'node:path';
29
38
  import process from 'node:process';
30
39
  import { runAsCli } from './lib/cli-utils.js';
31
40
 
41
+ /**
42
+ * Default scan roots making up the project's distributed surface — the
43
+ * directories published to npm via `package.json` `files[]`. Resolving
44
+ * them into one graph (relativized against the repo root) means a cycle
45
+ * crossing two roots is visible to `findCycles`.
46
+ *
47
+ * @type {string[]}
48
+ */
49
+ export const DEFAULT_ROOTS = [path.join('.agents', 'scripts'), 'bin', 'lib'];
50
+
32
51
  /**
33
52
  * Parse argv for `--baseline <path>`, `--root <path>`, and `--json`.
34
53
  * Exported so unit tests can pin the parser.
@@ -304,22 +323,27 @@ export async function runCli({
304
323
  stderr = process.stderr,
305
324
  } = {}) {
306
325
  const { baselinePath, rootPath, json } = parseArgv(argv);
307
- const resolvedRoot = path.resolve(
308
- cwd,
309
- rootPath ?? path.join('.agents', 'scripts'),
326
+ // With an explicit `--root`, scan that single root and relativize ids
327
+ // against it (unchanged contract). Without it, scan the full distributed
328
+ // surface and relativize every id against the repo root (`cwd`) so edges
329
+ // that cross two roots resolve into a single graph.
330
+ const graphRoot = rootPath ? path.resolve(cwd, rootPath) : path.resolve(cwd);
331
+ const scanDirs = (rootPath ? [rootPath] : DEFAULT_ROOTS).map((dir) =>
332
+ path.resolve(cwd, dir),
310
333
  );
311
334
  const resolvedBaselinePath = path.resolve(
312
335
  cwd,
313
336
  baselinePath ?? path.join('baselines', 'arch-cycles.json'),
314
337
  );
315
- if (!fs.existsSync(resolvedRoot)) {
316
- throw new Error(`[arch-cycles] scan root not found: ${resolvedRoot}`);
338
+ const presentScanDirs = scanDirs.filter((dir) => fs.existsSync(dir));
339
+ if (presentScanDirs.length === 0) {
340
+ throw new Error(`[arch-cycles] no scan root found: ${scanDirs.join(', ')}`);
317
341
  }
318
342
  const baseline = loadBaseline(resolvedBaselinePath);
319
343
  const allowlisted = Array.isArray(baseline?.cycles) ? baseline.cycles : [];
320
344
 
321
- const files = collectJsFiles(resolvedRoot);
322
- const graph = buildGraph(files, resolvedRoot);
345
+ const files = presentScanDirs.flatMap((dir) => collectJsFiles(dir));
346
+ const graph = buildGraph(files, graphRoot);
323
347
  const detected = findCycles(graph);
324
348
  const diff = diffCycles(allowlisted, detected);
325
349
  const exitCode = diff.added.length > 0 ? 1 : 0;
@@ -327,7 +351,7 @@ export async function runCli({
327
351
  if (json) {
328
352
  const envelope = {
329
353
  kind: 'arch-cycles-report',
330
- root: resolvedRoot,
354
+ root: graphRoot,
331
355
  baselinePath: resolvedBaselinePath,
332
356
  allowlisted: allowlisted.map(normalizeCycle),
333
357
  detected,
@@ -139,101 +139,82 @@ function resolveGitUserEmail(cwd) {
139
139
  * checkpointInitializedAt: string,
140
140
  * }>}
141
141
  */
142
- export async function runEpicDeliverPrepare({
142
+ /**
143
+ * Run the fail-closed preflight guards (Story #3482): refuse on a
144
+ * dirty/foreign-branch checkout and on a live foreign Epic lease, BEFORE any
145
+ * snapshot or git mutation. No-op when guards are suppressed. The guards are
146
+ * skipped when `skipPreflightGuards` is set, OR — implicitly — when a caller
147
+ * injects a provider but no git seam (the signature of the prepare-runner
148
+ * unit tests that drive an in-memory provider and never stand up a tree). The
149
+ * real CLI path injects neither, so the guards always run for an
150
+ * operator-driven invocation. Story #4075 — extracted from
151
+ * `runEpicDeliverPrepare`.
152
+ */
153
+ async function runPreflightGuardsForPrepare({
143
154
  epicId,
144
155
  cwd,
156
+ config,
157
+ provider,
145
158
  injectedProvider,
146
- injectedConfig,
147
- injectedFindings,
148
- ignoreConcurrencyHazards = false,
149
- steal = false,
150
- asOperator,
151
159
  injectedGit,
160
+ asOperator,
161
+ steal,
152
162
  leaseHeartbeatAt,
153
163
  leaseNow,
154
- skipPreflightGuards = false,
155
- } = {}) {
156
- if (!Number.isInteger(epicId) || epicId <= 0) {
157
- throw new TypeError(
158
- 'runEpicDeliverPrepare: --epic must be a positive integer',
159
- );
160
- }
161
-
162
- const config = injectedConfig ?? resolveConfig({ cwd });
163
- if (!config.github) {
164
- throw new Error('runEpicDeliverPrepare: no github block in .agentrc.json');
165
- }
166
- const provider = injectedProvider ?? createProvider(config);
167
- const { deliverRunner } = getRunners(config);
168
- const concurrencyCap = deliverRunner.concurrencyCap;
169
-
170
- // Preflight guards (Story #3482): fail closed on a dirty/foreign-branch
171
- // checkout and on a live foreign Epic lease, BEFORE any snapshot or git
172
- // mutation runs. The guards are injectable so the unit suite exercises them
173
- // without a real repo. They are skipped when an explicit `skipPreflightGuards`
174
- // is set, OR — implicitly — when a caller injects a provider but no git seam:
175
- // that combination is the signature of the pre-existing prepare-runner unit
176
- // tests that assert the DAG/checkpoint behaviour against an in-memory
177
- // provider and never stand up a working tree. The real CLI path passes
178
- // neither `injectedProvider` nor `injectedGit`, so the guards always run for
179
- // an operator-driven invocation.
164
+ skipPreflightGuards,
165
+ }) {
180
166
  const guardsSuppressed =
181
167
  skipPreflightGuards || (Boolean(injectedProvider) && !injectedGit);
182
- if (!guardsSuppressed) {
183
- const guardCwd = cwd ?? process.cwd();
184
- const git = injectedGit ?? createGitShim(guardCwd);
185
- const baseBranch = config.project?.baseBranch ?? 'main';
186
- const expectedBranch = [getEpicBranch(epicId), baseBranch];
187
- const operator =
188
- resolveOperator({
189
- asFlag: asOperator,
190
- config,
191
- gitUserEmail: injectedGit ? undefined : resolveGitUserEmail(guardCwd),
192
- }) ?? null;
193
-
194
- // Liveness seam: a foreign claim is only "live" (and so refuses) when the
195
- // claim *owner* has a recent `story.heartbeat`. Without this the lease
196
- // guard is inert — `heartbeatAt` defaults to null, `isClaimLive(null)` is
197
- // false, and every foreign claim looks stale and gets silently reclaimed
198
- // (audit #3513). Read the Epic's current assignee (the claim owner) and
199
- // resolve that owner's latest heartbeat from the Epic lifecycle ledger
200
- // (`temp/epic-<id>/lifecycle.ndjson`) via the shared resolver, so a LIVE
201
- // foreign claim actually refuses and only a genuinely stale/absent one is
202
- // reclaimed. Tests may inject `leaseHeartbeatAt` directly (any value,
203
- // including null) to bypass the ledger read; the CLI passes nothing.
204
- let heartbeatAt = leaseHeartbeatAt;
205
- if (heartbeatAt === undefined) {
206
- const epicTicket = await provider.getTicket(epicId);
207
- const claimOwner = leaseCurrentOwner(epicTicket?.assignees);
208
- heartbeatAt = claimOwner
209
- ? latestHeartbeatForOwner({ epicId, owner: claimOwner, config })
210
- : null;
211
- }
168
+ if (guardsSuppressed) return;
212
169
 
213
- await runPrepareGuards({
214
- epicId,
215
- expectedBranch,
216
- git,
217
- provider,
218
- operator,
219
- heartbeatAt,
220
- steal,
170
+ const guardCwd = cwd ?? process.cwd();
171
+ const git = injectedGit ?? createGitShim(guardCwd);
172
+ const baseBranch = config.project?.baseBranch ?? 'main';
173
+ const expectedBranch = [getEpicBranch(epicId), baseBranch];
174
+ const operator =
175
+ resolveOperator({
176
+ asFlag: asOperator,
221
177
  config,
222
- now: leaseNow,
223
- logger: Logger,
224
- });
178
+ gitUserEmail: injectedGit ? undefined : resolveGitUserEmail(guardCwd),
179
+ }) ?? null;
180
+
181
+ // Liveness seam: a foreign claim is only "live" (and so refuses) when the
182
+ // claim *owner* has a recent `story.heartbeat`. Without this the lease
183
+ // guard is inert — every foreign claim looks stale and gets silently
184
+ // reclaimed (audit #3513). Read the Epic's current assignee (the claim
185
+ // owner) and resolve that owner's latest heartbeat from the Epic lifecycle
186
+ // ledger via the shared resolver. Tests may inject `leaseHeartbeatAt`
187
+ // directly (any value, including null) to bypass the ledger read.
188
+ let heartbeatAt = leaseHeartbeatAt;
189
+ if (heartbeatAt === undefined) {
190
+ const epicTicket = await provider.getTicket(epicId);
191
+ const claimOwner = leaseCurrentOwner(epicTicket?.assignees);
192
+ heartbeatAt = claimOwner
193
+ ? latestHeartbeatForOwner({ epicId, owner: claimOwner, config })
194
+ : null;
225
195
  }
226
196
 
227
- // Story #3027: try the preflight cache first so we don't re-walk Epic
228
- // → Feature → Story when `epic-deliver-preflight.js` already did. The
229
- // cache key is a deterministic fingerprint of the Epic ticket plus the
230
- // cached Story snapshots (Story #4019): the Epic re-fetch plus one
231
- // getTicket per cached Story is still far cheaper than the full
232
- // hierarchy BFS, and a Story-dependency edit now invalidates the cache.
233
- // Cache miss or baseSha mismatch → fall back to a fresh pass.
234
- const ctx = { epicId, provider };
235
- let state = {};
236
- let cacheStatus = 'miss';
197
+ await runPrepareGuards({
198
+ epicId,
199
+ expectedBranch,
200
+ git,
201
+ provider,
202
+ operator,
203
+ heartbeatAt,
204
+ steal,
205
+ config,
206
+ now: leaseNow,
207
+ logger: Logger,
208
+ });
209
+ }
210
+
211
+ /**
212
+ * Resolve the Epic state, preferring the preflight cache (Story #3027) and
213
+ * falling back to a fresh snapshot + wave-DAG pass on miss or baseSha
214
+ * mismatch. Returns `{ state, cacheStatus }`. Story #4075 — extracted from
215
+ * `runEpicDeliverPrepare`.
216
+ */
217
+ async function resolvePrepareState({ epicId, cwd, provider }) {
237
218
  const cached = await readPreflightCache({ epicId, cwd });
238
219
  if (cached) {
239
220
  const freshEpic = await provider.getTicket(epicId);
@@ -245,35 +226,42 @@ export async function runEpicDeliverPrepare({
245
226
  );
246
227
  const freshBaseSha = computeBaseSha(freshEpic, freshStories);
247
228
  if (freshBaseSha === cached.baseSha) {
248
- state = {
249
- epic: cached.epic,
250
- stories: cached.stories,
251
- waves: cached.waves,
229
+ return {
230
+ state: {
231
+ epic: cached.epic,
232
+ stories: cached.stories,
233
+ waves: cached.waves,
234
+ },
235
+ cacheStatus: 'hit',
252
236
  };
253
- cacheStatus = 'hit';
254
- } else {
255
- cacheStatus = 'stale';
256
237
  }
257
238
  }
258
- if (cacheStatus !== 'hit') {
259
- state = await runSnapshotPhase(ctx, {}, state);
260
- state = await runBuildWaveDagPhase(ctx, {}, state);
261
- }
239
+ const ctx = { epicId, provider };
240
+ let state = await runSnapshotPhase(ctx, {}, {});
241
+ state = await runBuildWaveDagPhase(ctx, {}, state);
242
+ return { state, cacheStatus: cached ? 'stale' : 'miss' };
243
+ }
262
244
 
263
- // Cross-Story concurrency-hazard gate (Story #2297). Findings come in
264
- // via DI; no default loader is wired yet production callers will
265
- // either pass findings derived from the persisted manifest or rely on
266
- // the empty default (gate trivially passes).
245
+ /**
246
+ * Evaluate the cross-Story concurrency-hazard gate (Story #2297). Throws on a
247
+ * tripped, non-bypassed gate; warns (and returns `gate`) on a bypassed trip.
248
+ * Story #4075 extracted from `runEpicDeliverPrepare`.
249
+ */
250
+ function evaluatePrepareConcurrencyGate({
251
+ config,
252
+ waves,
253
+ injectedFindings,
254
+ ignoreConcurrencyHazards,
255
+ }) {
267
256
  const findings = Array.isArray(injectedFindings) ? injectedFindings : [];
268
- const pendingKeys = collectPendingStoryKeys(state.waves);
257
+ const pendingKeys = collectPendingStoryKeys(waves);
269
258
  const pendingFindings = filterFindingsToPending(findings, pendingKeys);
270
- const concurrencyPolicy = {
271
- failOnConcurrencyHazards:
272
- config?.delivery?.failOnConcurrencyHazards === true,
273
- };
274
259
  const gate = evaluateConcurrencyGate({
275
260
  findings: pendingFindings,
276
- policy: concurrencyPolicy,
261
+ policy: {
262
+ failOnConcurrencyHazards:
263
+ config?.delivery?.failOnConcurrencyHazards === true,
264
+ },
277
265
  ignore: ignoreConcurrencyHazards === true,
278
266
  });
279
267
  if (gate.tripped && !gate.bypassed) {
@@ -288,6 +276,63 @@ export async function runEpicDeliverPrepare({
288
276
  `[epic-deliver-prepare] ⚠️ Concurrency-hazard gate bypassed via --ignore-concurrency-hazards (reason=${gate.reason}, count=${gate.findings.length}).`,
289
277
  );
290
278
  }
279
+ return gate;
280
+ }
281
+
282
+ export async function runEpicDeliverPrepare({
283
+ epicId,
284
+ cwd,
285
+ injectedProvider,
286
+ injectedConfig,
287
+ injectedFindings,
288
+ ignoreConcurrencyHazards = false,
289
+ steal = false,
290
+ asOperator,
291
+ injectedGit,
292
+ leaseHeartbeatAt,
293
+ leaseNow,
294
+ skipPreflightGuards = false,
295
+ } = {}) {
296
+ if (!Number.isInteger(epicId) || epicId <= 0) {
297
+ throw new TypeError(
298
+ 'runEpicDeliverPrepare: --epic must be a positive integer',
299
+ );
300
+ }
301
+
302
+ const config = injectedConfig ?? resolveConfig({ cwd });
303
+ if (!config.github) {
304
+ throw new Error('runEpicDeliverPrepare: no github block in .agentrc.json');
305
+ }
306
+ const provider = injectedProvider ?? createProvider(config);
307
+ const { deliverRunner } = getRunners(config);
308
+ const concurrencyCap = deliverRunner.concurrencyCap;
309
+
310
+ await runPreflightGuardsForPrepare({
311
+ epicId,
312
+ cwd,
313
+ config,
314
+ provider,
315
+ injectedProvider,
316
+ injectedGit,
317
+ asOperator,
318
+ steal,
319
+ leaseHeartbeatAt,
320
+ leaseNow,
321
+ skipPreflightGuards,
322
+ });
323
+
324
+ const { state, cacheStatus } = await resolvePrepareState({
325
+ epicId,
326
+ cwd,
327
+ provider,
328
+ });
329
+
330
+ const gate = evaluatePrepareConcurrencyGate({
331
+ config,
332
+ waves: state.waves,
333
+ injectedFindings,
334
+ ignoreConcurrencyHazards,
335
+ });
291
336
 
292
337
  const totalWaves = state.waves.length;
293
338
  const checkpointState = await initializeEpicRunState({