mandrel 1.84.0 → 1.86.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/docs/agentrc-reference.json +8 -2
- package/.agents/docs/configuration.md +7 -2
- package/.agents/instructions.md +4 -0
- package/.agents/rules/ci-remediation.md +131 -0
- package/.agents/rules/git-conventions.md +33 -0
- package/.agents/schemas/agentrc.schema.json +29 -6
- package/.agents/schemas/lifecycle/epic.watch.end.schema.json +2 -1
- package/.agents/scripts/boot-sweep.js +183 -0
- package/.agents/scripts/epic-deliver-prepare.js +55 -0
- package/.agents/scripts/git-pr-quality-gate.js +7 -5
- package/.agents/scripts/lib/config/ci.js +24 -3
- package/.agents/scripts/lib/config/explain.js +11 -3
- package/.agents/scripts/lib/config/github.js +11 -7
- package/.agents/scripts/lib/config-settings-schema-delivery.js +21 -0
- package/.agents/scripts/lib/config-settings-schema.js +6 -6
- package/.agents/scripts/lib/orchestration/epic-cleanup.js +289 -1
- package/.agents/scripts/lib/orchestration/finalize/open-or-locate-pr.js +65 -0
- package/.agents/scripts/lib/orchestration/git-cleanup/phases/git-probes-ff.js +83 -30
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +401 -84
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/branch-cleaner.js +8 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +48 -3
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +6 -1
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +172 -58
- package/.agents/scripts/lib/orchestration/single-story-close/phases/auto-merge.js +19 -0
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +2 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/gate-failure.js +54 -6
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/regression-projection.js +35 -4
- package/.agents/scripts/lib/orchestration/ticket-validator-sizing.js +17 -16
- package/.agents/scripts/lib/single-story-sweep/protection-ctx.js +75 -0
- package/.agents/scripts/lib/single-story-sweep.js +181 -54
- package/.agents/scripts/lib/templates/decomposer-prompts.js +17 -3
- package/.agents/scripts/pr-watch-with-update.js +324 -37
- package/.agents/scripts/run-verify.js +18 -3
- package/.agents/scripts/single-story-confirm-merge.js +1 -1
- package/.agents/scripts/single-story-init.js +7 -51
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +32 -1
- package/.agents/skills/core/scope-triage/SKILL.md +5 -4
- package/.agents/workflows/git-deliver.md +36 -0
- package/.agents/workflows/helpers/deliver-epic-reference.md +41 -21
- package/.agents/workflows/helpers/deliver-epic.md +148 -28
- package/.agents/workflows/helpers/deliver-stories.md +18 -5
- package/.agents/workflows/helpers/single-story-deliver-reference.md +3 -3
- package/.agents/workflows/helpers/single-story-deliver.md +56 -19
- package/.agents/workflows/plan.md +32 -4
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* pr-watch-with-update.js —
|
|
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
|
-
*
|
|
13
|
-
* -
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
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]
|
|
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
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
* `
|
|
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.
|
|
46
|
-
* @param {number} [opts.
|
|
47
|
-
* @param {number} [opts.
|
|
48
|
-
* @param {
|
|
49
|
-
* @param {
|
|
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]
|
|
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
|
|
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:
|
|
81
|
-
maxUpdates:
|
|
82
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
return 1;
|
|
338
|
+
|
|
339
|
+
if (result.green) {
|
|
340
|
+
logger.info?.('[pr-watch] all required checks green.');
|
|
341
|
+
return 0;
|
|
116
342
|
}
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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.
|
|
123
|
-
|
|
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 —
|
|
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 →
|
|
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 `
|
|
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
|
|
@@ -35,7 +35,6 @@
|
|
|
35
35
|
* @see .agents/workflows/helpers/single-story-deliver.md
|
|
36
36
|
*/
|
|
37
37
|
|
|
38
|
-
import { spawnSync as defaultSpawnSync } from 'node:child_process';
|
|
39
38
|
import { existsSync } from 'node:fs';
|
|
40
39
|
import path from 'node:path';
|
|
41
40
|
import { parseSprintArgs } from './lib/cli-args.js';
|
|
@@ -67,12 +66,18 @@ import {
|
|
|
67
66
|
upsertStructuredComment,
|
|
68
67
|
} from './lib/orchestration/ticketing.js';
|
|
69
68
|
import { createProvider } from './lib/provider-factory.js';
|
|
69
|
+
import { buildProtectionCtx } from './lib/single-story-sweep/protection-ctx.js';
|
|
70
70
|
// `sweepMergedStoryBranches` is imported dynamically below — its transitive
|
|
71
71
|
// graph reaches `picomatch` (via `git-cleanup.js`). Loading it statically
|
|
72
72
|
// would crash module resolution before `assertDepsInstalled()` can emit a
|
|
73
73
|
// friendly "run npm install" message.
|
|
74
74
|
import { WorktreeManager } from './lib/worktree-manager.js';
|
|
75
75
|
|
|
76
|
+
// `makeGhRunner` moved to the shared `single-story-sweep/protection-ctx.js`
|
|
77
|
+
// module (Story #4373) so the three boot callers build an identical
|
|
78
|
+
// protection ctx. Re-exported here to preserve its existing import path.
|
|
79
|
+
export { makeGhRunner } from './lib/single-story-sweep/protection-ctx.js';
|
|
80
|
+
|
|
76
81
|
/**
|
|
77
82
|
* Fail fast with a clear, actionable message when project deps are missing.
|
|
78
83
|
* Uses only Node builtins so it stays loadable when `node_modules/` is empty.
|
|
@@ -97,50 +102,6 @@ function assertDepsInstalled(projectRoot) {
|
|
|
97
102
|
|
|
98
103
|
const progress = Logger.createProgress('single-story-init', { stderr: true });
|
|
99
104
|
|
|
100
|
-
/**
|
|
101
|
-
* Build the synchronous `gh` runner the single-story sweep uses for its
|
|
102
|
-
* candidate-protection checks. Exported for testing.
|
|
103
|
-
*
|
|
104
|
-
* Story #2990: the sweep protection-ctx ghRunner stays on raw
|
|
105
|
-
* `spawnSync('gh', …)` (not the `lib/gh-exec.js` async facade) because
|
|
106
|
-
* `executeCleanup` invokes the protection checks inside a synchronous
|
|
107
|
-
* candidate-filter loop. The runner contract is the legacy
|
|
108
|
-
* `(args, opts) => stdout string` shape; converting it to async would
|
|
109
|
-
* ripple into the single-story-sweep planner, which is intentionally out
|
|
110
|
-
* of scope for the callers-only provider migration.
|
|
111
|
-
*
|
|
112
|
-
* Story #4073: the `spawnImpl` seam injects the `spawnSync` boundary so the
|
|
113
|
-
* runner's success/error handling can be unit-tested without a live `gh`
|
|
114
|
-
* binary. It defaults to `child_process.spawnSync` (mirroring the
|
|
115
|
-
* `spawnImpl` seam in `lib/gh-exec.js` and the `runner` seam in
|
|
116
|
-
* `lib/bootstrap/gh-preflight.js`), so the production CLI path is unchanged.
|
|
117
|
-
* The synchronous `spawnSync` shape is preserved deliberately — the
|
|
118
|
-
* candidate-filter loop in `executeCleanup` is synchronous, so converting
|
|
119
|
-
* this to the async `lib/gh-exec.js` facade would ripple into the
|
|
120
|
-
* single-story-sweep planner.
|
|
121
|
-
*
|
|
122
|
-
* @param {string} cwd Repo root used as the default spawn cwd.
|
|
123
|
-
* @param {typeof defaultSpawnSync} [spawnImpl] Injectable spawn boundary —
|
|
124
|
-
* defaults to `child_process.spawnSync`. Tests pass a fake to assert the
|
|
125
|
-
* error/exit-code handling without spawning a real child process.
|
|
126
|
-
* @returns {(args: string[], opts?: { cwd?: string }) => string}
|
|
127
|
-
*/
|
|
128
|
-
export function makeGhRunner(cwd, spawnImpl = defaultSpawnSync) {
|
|
129
|
-
return (args, opts) => {
|
|
130
|
-
const result = spawnImpl('gh', args, {
|
|
131
|
-
cwd: opts?.cwd ?? cwd,
|
|
132
|
-
encoding: 'utf-8',
|
|
133
|
-
shell: false,
|
|
134
|
-
});
|
|
135
|
-
if (result.status !== 0) {
|
|
136
|
-
throw new Error(
|
|
137
|
-
`gh ${args.join(' ')} exit ${result.status}: ${result.stderr ?? ''}`,
|
|
138
|
-
);
|
|
139
|
-
}
|
|
140
|
-
return result.stdout ?? '';
|
|
141
|
-
};
|
|
142
|
-
}
|
|
143
|
-
|
|
144
105
|
/**
|
|
145
106
|
* Validate that the fetched ticket is a standalone Story this script can
|
|
146
107
|
* deliver. Throws with the canonical operator-facing message otherwise.
|
|
@@ -222,12 +183,7 @@ export async function reapMergedStoryBranches({
|
|
|
222
183
|
info: (m) => progress('CLEANUP', m),
|
|
223
184
|
warn: (m) => progress('CLEANUP', `⚠️ ${m}`),
|
|
224
185
|
},
|
|
225
|
-
protectionCtx: {
|
|
226
|
-
repoRoot: cwd,
|
|
227
|
-
gitSpawn,
|
|
228
|
-
ghRunner: makeGhRunner(cwd),
|
|
229
|
-
getTicket: (id) => provider.getTicket(id),
|
|
230
|
-
},
|
|
186
|
+
protectionCtx: buildProtectionCtx({ cwd, provider }),
|
|
231
187
|
lockPath,
|
|
232
188
|
lockTimeoutMs,
|
|
233
189
|
});
|
|
@@ -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
|
-
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|