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.
- package/.agents/scripts/check-action-pinning.js +260 -0
- package/.agents/scripts/check-arch-cycles.js +38 -14
- package/.agents/scripts/epic-deliver-prepare.js +149 -104
- package/.agents/scripts/lib/baseline-snapshot.js +245 -141
- package/.agents/scripts/lib/feedback-loop/graduator-core.js +171 -137
- package/.agents/scripts/lib/orchestration/code-review.js +206 -168
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/creation.js +71 -5
- package/.agents/scripts/lib/orchestration/epic-plan-decompose/phases/persist.js +16 -2
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/component-drift.js +101 -1
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/crap-drift.js +20 -42
- package/.agents/scripts/lib/orchestration/epic-runner/progress-signals/maintainability-drift.js +12 -32
- package/.agents/scripts/lib/orchestration/lifecycle/trace-logger.js +97 -60
- package/.agents/scripts/lib/orchestration/model-attribution.js +73 -45
- package/.agents/scripts/lib/orchestration/review-providers/parse-findings.js +97 -49
- package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +73 -69
- package/.agents/scripts/lib/orchestration/story-close-recovery.js +109 -79
- package/.agents/scripts/lib/signals/detectors/common.js +107 -0
- package/.agents/scripts/lib/signals/detectors/hotspot.js +12 -18
- package/.agents/scripts/lib/signals/detectors/retry.js +3 -40
- package/.agents/scripts/lib/signals/detectors/rework.js +3 -40
- package/.agents/scripts/lib/story-body/story-body.js +102 -76
- package/.agents/scripts/providers/github/blocked-by-add.js +252 -0
- package/.agents/scripts/single-story-init.js +16 -3
- package/.agents/workflows/audit-architecture.md +9 -0
- package/README.md +1 -1
- package/docs/CHANGELOG.md +28 -0
- 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
|
|
5
|
-
* `
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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>
|
|
23
|
-
*
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
-
|
|
316
|
-
|
|
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(
|
|
322
|
-
const graph = buildGraph(files,
|
|
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:
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
223
|
-
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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(
|
|
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:
|
|
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({
|