mandrel 2.23.0 → 2.25.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/agents/acceptance-critic.md +13 -19
- package/.agents/agents/auditor.md +16 -19
- package/.agents/agents/story-worker.md +41 -54
- package/.agents/audit-checklists/architecture.md +5 -1
- package/.agents/audit-checklists/quality.md +3 -0
- package/.agents/docs/agentrc-reference.json +2 -1
- package/.agents/docs/configuration.md +2 -1
- package/.agents/docs/workflows.md +1 -1
- package/.agents/rules/ci-remediation.md +68 -3
- package/.agents/schemas/agentrc.schema.json +6 -1
- package/.agents/schemas/baselines/baseline-envelope.schema.json +4 -0
- package/.agents/schemas/baselines/crap.schema.json +5 -1
- package/.agents/schemas/crap-report.schema.json +37 -0
- package/.agents/schemas/story-deliver-terminal.schema.json +6 -1
- package/.agents/scripts/acceptance-eval.js +35 -9
- package/.agents/scripts/audit-labels-bootstrap.js +16 -95
- package/.agents/scripts/audit-to-stories.js +33 -3
- package/.agents/scripts/check-context-budget.js +95 -14
- package/.agents/scripts/check-test-temp-hygiene.js +83 -0
- package/.agents/scripts/cleanup-repo-test-temp.js +7 -5
- package/.agents/scripts/deliver-light.js +22 -45
- package/.agents/scripts/deliver-recover.js +13 -0
- package/.agents/scripts/lib/audit-suite/lens-diff-floor.js +10 -25
- package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +177 -0
- package/.agents/scripts/lib/audit-to-stories/build-story-body.js +35 -3
- package/.agents/scripts/lib/audit-to-stories/group-findings.js +20 -11
- package/.agents/scripts/lib/audit-to-stories/parse-audit-md.js +23 -9
- package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +9 -2
- package/.agents/scripts/lib/baseline-loader.js +0 -0
- package/.agents/scripts/lib/baselines/git-base.js +0 -0
- package/.agents/scripts/lib/baselines/kinds/crap.js +396 -51
- package/.agents/scripts/lib/baselines/kinds/maintainability.js +20 -32
- package/.agents/scripts/lib/baselines/preview-gates.js +58 -15
- package/.agents/scripts/lib/baselines/reader.js +10 -0
- package/.agents/scripts/lib/baselines/writer.js +7 -3
- package/.agents/scripts/lib/config-settings-schema-delivery.js +5 -0
- package/.agents/scripts/lib/crap-engine.js +97 -19
- package/.agents/scripts/lib/crap-utils.js +73 -13
- package/.agents/scripts/lib/env-loader.js +46 -16
- package/.agents/scripts/lib/escomplex-ast-compat.js +360 -0
- package/.agents/scripts/lib/findings/route-finding.js +123 -17
- package/.agents/scripts/lib/findings/severity.js +80 -2
- package/.agents/scripts/lib/git/cached-fetch.js +0 -0
- package/.agents/scripts/lib/label-constants.js +17 -0
- package/.agents/scripts/lib/maintainability-engine.js +83 -11
- package/.agents/scripts/lib/maintainability-unscorable.js +60 -0
- package/.agents/scripts/lib/maintainability-utils.js +14 -5
- package/.agents/scripts/lib/observability/runtime-friction.js +8 -0
- package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +110 -17
- package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +548 -0
- package/.agents/scripts/lib/orchestration/deliver-recover.js +2 -2
- package/.agents/scripts/lib/orchestration/diff-magnitude.js +283 -0
- package/.agents/scripts/lib/orchestration/lifecycle/listeners/watcher.js +71 -20
- package/.agents/scripts/lib/orchestration/light-backstop.js +124 -0
- package/.agents/scripts/lib/orchestration/light-escalation.js +248 -0
- package/.agents/scripts/lib/orchestration/light-suitability.js +254 -51
- package/.agents/scripts/lib/orchestration/plan-context.js +12 -13
- package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +7 -0
- package/.agents/scripts/lib/orchestration/plan-persist/story-ops.js +13 -1
- package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
- package/.agents/scripts/lib/orchestration/run-scoped-config.js +276 -0
- package/.agents/scripts/lib/orchestration/single-story-close/phases/base-sync.js +41 -10
- package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +96 -34
- package/.agents/scripts/lib/orchestration/single-story-close/phases/post-land.js +70 -2
- package/.agents/scripts/lib/orchestration/single-story-close/phases/pull-request.js +129 -21
- package/.agents/scripts/lib/orchestration/single-story-close/runner.js +73 -18
- package/.agents/scripts/lib/reserved-test-ids.js +77 -0
- package/.agents/scripts/lib/story-plan.js +137 -42
- package/.agents/scripts/lib/templates/decomposer-prompts.js +4 -4
- package/.agents/scripts/lib/ticket-body-sections.js +1 -1
- package/.agents/scripts/lib/util/poll-loop.js +18 -2
- package/.agents/scripts/lib/wave-runner/live-probe.js +7 -1
- package/.agents/scripts/lib/wave-runner/ready-set.js +73 -9
- package/.agents/scripts/lib/workers/combined-mi-crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/crap-worker.js +6 -3
- package/.agents/scripts/lib/workers/maintainability-worker.js +14 -9
- package/.agents/scripts/pr-watch-with-update.js +568 -213
- package/.agents/scripts/quality-preview.js +25 -0
- package/.agents/scripts/run-tests.js +10 -0
- package/.agents/scripts/single-story-close.js +6 -0
- package/.agents/scripts/single-story-init.js +7 -0
- package/.agents/scripts/stories-wave-tick.js +95 -10
- package/.agents/scripts/story-plan.js +65 -9
- package/.agents/scripts/update-crap-baseline.js +42 -4
- package/.agents/scripts/update-maintainability-baseline.js +31 -4
- package/.agents/templates/single-story-body.md +16 -8
- package/.agents/workflows/audit-architecture.md +69 -13
- package/.agents/workflows/audit-quality.md +26 -0
- package/.agents/workflows/audit-to-stories.md +30 -4
- package/.agents/workflows/deliver.md +81 -95
- package/.agents/workflows/helpers/audit-lens-core.md +25 -4
- package/.agents/workflows/helpers/deliver-light.md +21 -4
- package/.agents/workflows/helpers/deliver-reference.md +18 -3
- package/.agents/workflows/helpers/deliver-story-reference.md +127 -46
- package/.agents/workflows/helpers/deliver-story.md +104 -119
- package/.agents/workflows/helpers/plan-reference.md +34 -0
- package/.agents/workflows/plan.md +60 -76
- package/docs/CHANGELOG.md +38 -0
- package/lib/cli/update.js +14 -11
- package/lib/cli/version-check.js +9 -1
- package/package.json +1 -1
|
@@ -0,0 +1,548 @@
|
|
|
1
|
+
// .agents/scripts/lib/orchestration/ci-rerun-guard.js
|
|
2
|
+
/**
|
|
3
|
+
* ci-rerun-guard.js — the machine enforcement behind the no-rerun MUST in
|
|
4
|
+
* [`rules/ci-remediation.md`](../../../rules/ci-remediation.md) § Verifier
|
|
5
|
+
* (Story #4865). Owns the CI failure digest (write / read / retire), the
|
|
6
|
+
* head-SHA discriminator, the auto-merge disarm, and the blocked-delivery
|
|
7
|
+
* escalation. `pr-watch-with-update.js` is the enforcement point; this
|
|
8
|
+
* module is the mechanism it drives.
|
|
9
|
+
*
|
|
10
|
+
* **Why the first red, and not the rerun-green.** The obvious design —
|
|
11
|
+
* observe a green that arrives on a re-run of the same commit, then disarm
|
|
12
|
+
* auto-merge and block — cannot enforce anything. GitHub's native
|
|
13
|
+
* auto-merge fires server-side the moment branch protection is satisfied,
|
|
14
|
+
* so it races the watcher's next poll: by the time the rerun-green is
|
|
15
|
+
* observed, the PR may already be merged. The only race-free observation
|
|
16
|
+
* point is the **first red**, which necessarily precedes any green. So the
|
|
17
|
+
* watcher disarms on red and records the PR head SHA; the head SHA is then
|
|
18
|
+
* read at **re-arm** time, not at merge time.
|
|
19
|
+
*
|
|
20
|
+
* **Head SHA is the discriminator.** A green on a *different* head SHA is a
|
|
21
|
+
* fix at source — legal, and it retires the digest. A green on the *same*
|
|
22
|
+
* head SHA is a re-run of a failed job, which the rule forbids: the
|
|
23
|
+
* delivery hard-stops at `agent::blocked` and a `meta::framework-gap` issue
|
|
24
|
+
* (carrying the run link and failure signature the digest already holds) is
|
|
25
|
+
* required before it can proceed.
|
|
26
|
+
*
|
|
27
|
+
* **Fail closed on an unverifiable green.** A digest whose head SHA is
|
|
28
|
+
* missing, or a current head SHA that `gh` could not resolve, leaves no
|
|
29
|
+
* evidence that the red was fixed at source. An unresolved red plus no
|
|
30
|
+
* evidence of a new commit is treated as a violation rather than waved
|
|
31
|
+
* through — the same unknown-is-blocking posture the arming probe takes on
|
|
32
|
+
* an unrecognized check state.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { spawnSync } from 'node:child_process';
|
|
36
|
+
import {
|
|
37
|
+
existsSync,
|
|
38
|
+
mkdirSync,
|
|
39
|
+
readFileSync,
|
|
40
|
+
rmSync,
|
|
41
|
+
writeFileSync,
|
|
42
|
+
} from 'node:fs';
|
|
43
|
+
import path from 'node:path';
|
|
44
|
+
import { resolveConfig } from '../config-resolver.js';
|
|
45
|
+
import { Logger } from '../Logger.js';
|
|
46
|
+
import { createProvider } from '../provider-factory.js';
|
|
47
|
+
import {
|
|
48
|
+
STATE_LABELS,
|
|
49
|
+
transitionTicketState,
|
|
50
|
+
upsertStructuredComment,
|
|
51
|
+
} from './ticketing.js';
|
|
52
|
+
|
|
53
|
+
/** How many superseded unresolved reds a digest carries before the oldest is dropped. */
|
|
54
|
+
const MAX_PRIOR_REDS = 10;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Resolve which ticket the digest is keyed to. Story #4539: the digest used
|
|
58
|
+
* to be Epic-scoped by filename and returned `null` without an epic id — so
|
|
59
|
+
* on the v2 Story path (which has no Epic and invokes the watch with `--pr`
|
|
60
|
+
* alone) a red check wrote no digest at all, despite the module header
|
|
61
|
+
* advertising one. v2.0.0 removed the Epic tier; Story scope is the only
|
|
62
|
+
* scope.
|
|
63
|
+
*
|
|
64
|
+
* @param {{ storyId?: number|string|null }} opts
|
|
65
|
+
* @returns {{ kind: 'story', id: number } | null}
|
|
66
|
+
*/
|
|
67
|
+
export function resolveDigestScope({ storyId = null } = {}) {
|
|
68
|
+
if (storyId == null || String(storyId).length === 0) return null;
|
|
69
|
+
const parsed = Number.parseInt(String(storyId), 10);
|
|
70
|
+
return Number.isInteger(parsed) && parsed > 0
|
|
71
|
+
? { kind: 'story', id: parsed }
|
|
72
|
+
: null;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Coarse failure classification from a failing-check name. Pure —
|
|
77
|
+
* exported for tests. Deliberately shallow: it steers the operator's
|
|
78
|
+
* next move (which `/loop` unit to reach for), not a root-cause verdict.
|
|
79
|
+
*
|
|
80
|
+
* @param {string} name failing required-check name.
|
|
81
|
+
* @returns {'test'|'lint'|'baseline'|'build'|'unknown'}
|
|
82
|
+
*/
|
|
83
|
+
export function classifyFailure(name) {
|
|
84
|
+
const n = String(name ?? '').toLowerCase();
|
|
85
|
+
if (/lint|format|biome|markdownlint/.test(n)) return 'lint';
|
|
86
|
+
if (/baseline|coverage|crap|maintainab|duplicat/.test(n)) return 'baseline';
|
|
87
|
+
if (/build|compile|typecheck|bundle/.test(n)) return 'build';
|
|
88
|
+
if (/test|spec|validate|ci|check/.test(n)) return 'test';
|
|
89
|
+
return 'unknown';
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Resolve the `.json` / `.md` digest paths for a scope. Returns `null` when
|
|
94
|
+
* no scope can be keyed (the digest is scoped by filename and has nothing
|
|
95
|
+
* to key on).
|
|
96
|
+
*
|
|
97
|
+
* Module-private: `writeCiDigest` / `readCiDigest` / `retireCiDigest` are the
|
|
98
|
+
* only callers and are the surface worth pinning, so the keying is asserted
|
|
99
|
+
* through them rather than through an export nothing in production reaches.
|
|
100
|
+
*
|
|
101
|
+
* @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
|
|
102
|
+
* @returns {{ scope: { kind: 'story', id: number }, jsonPath: string, mdPath: string } | null}
|
|
103
|
+
*/
|
|
104
|
+
function ciDigestPaths({ storyId = null, tempRoot, cwd }) {
|
|
105
|
+
const scope = resolveDigestScope({ storyId });
|
|
106
|
+
if (!scope) return null;
|
|
107
|
+
const dir = path.isAbsolute(tempRoot) ? tempRoot : path.join(cwd, tempRoot);
|
|
108
|
+
const base = `${scope.kind}-${scope.id}-ci-digest`;
|
|
109
|
+
return {
|
|
110
|
+
scope,
|
|
111
|
+
jsonPath: path.join(dir, `${base}.json`),
|
|
112
|
+
mdPath: path.join(dir, `${base}.md`),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Default `gh run view --log-failed` spawn — pulls the tail of the failed
|
|
118
|
+
* job log so the digest carries an actionable excerpt. Best-effort:
|
|
119
|
+
* returns an empty tail when the run id is unknown or `gh` errors.
|
|
120
|
+
* Injected into `writeCiDigest` so tests never shell out.
|
|
121
|
+
*/
|
|
122
|
+
function ghRunLogTail({ runId, cwd, spawnFn = spawnSync, maxLines = 40 }) {
|
|
123
|
+
if (!runId) return '';
|
|
124
|
+
const result = spawnFn('gh', ['run', 'view', String(runId), '--log-failed'], {
|
|
125
|
+
cwd,
|
|
126
|
+
encoding: 'utf-8',
|
|
127
|
+
shell: false,
|
|
128
|
+
maxBuffer: 10 * 1024 * 1024,
|
|
129
|
+
});
|
|
130
|
+
const out = (result.stdout ?? '').trim();
|
|
131
|
+
if (out.length === 0) return '';
|
|
132
|
+
const lines = out.split('\n');
|
|
133
|
+
return lines.slice(-maxLines).join('\n');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Resolve the failing check's run identity — the GitHub Actions run id AND
|
|
138
|
+
* the run URL. Best-effort via `gh pr checks --json name,link`: the `link`
|
|
139
|
+
* field carries the run URL whose trailing path segment is the run id. The
|
|
140
|
+
* URL matters as much as the id, because the `meta::framework-gap` issue a
|
|
141
|
+
* rerun violation demands must carry a run **link**.
|
|
142
|
+
*
|
|
143
|
+
* @returns {{ runId: string|null, url: string|null }}
|
|
144
|
+
*/
|
|
145
|
+
function resolveFailingCheckRun({
|
|
146
|
+
prRef,
|
|
147
|
+
checkName,
|
|
148
|
+
cwd,
|
|
149
|
+
spawnFn = spawnSync,
|
|
150
|
+
}) {
|
|
151
|
+
const result = spawnFn('gh', ['pr', 'checks', prRef, '--json', 'name,link'], {
|
|
152
|
+
cwd,
|
|
153
|
+
encoding: 'utf-8',
|
|
154
|
+
shell: false,
|
|
155
|
+
});
|
|
156
|
+
try {
|
|
157
|
+
const parsed = JSON.parse((result.stdout ?? '').trim() || '[]');
|
|
158
|
+
const entry = Array.isArray(parsed)
|
|
159
|
+
? parsed.find((e) => e?.name === checkName)
|
|
160
|
+
: null;
|
|
161
|
+
const link = entry?.link ? String(entry.link) : null;
|
|
162
|
+
const m = link ? /\/runs\/(\d+)/.exec(link) : null;
|
|
163
|
+
return { runId: m ? m[1] : null, url: link };
|
|
164
|
+
} catch {
|
|
165
|
+
return { runId: null, url: null };
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the PR's current head SHA (`headRefOid`) — the discriminator
|
|
171
|
+
* between a legal fix-at-source green and a forbidden rerun green. Returns
|
|
172
|
+
* `null` when `gh` fails or the payload is unparseable; callers treat an
|
|
173
|
+
* unresolvable head as unverifiable, never as "changed".
|
|
174
|
+
*
|
|
175
|
+
* @param {{ prRef: string, cwd: string, spawnFn?: typeof spawnSync }} opts
|
|
176
|
+
* @returns {string|null}
|
|
177
|
+
*/
|
|
178
|
+
export function resolvePrHeadSha({ prRef, cwd, spawnFn = spawnSync }) {
|
|
179
|
+
const result = spawnFn('gh', ['pr', 'view', prRef, '--json', 'headRefOid'], {
|
|
180
|
+
cwd,
|
|
181
|
+
encoding: 'utf-8',
|
|
182
|
+
shell: false,
|
|
183
|
+
});
|
|
184
|
+
if ((result?.status ?? 1) !== 0) return null;
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse((result.stdout ?? '').trim() || '{}');
|
|
187
|
+
const sha = parsed?.headRefOid;
|
|
188
|
+
return typeof sha === 'string' && sha.length > 0 ? sha : null;
|
|
189
|
+
} catch {
|
|
190
|
+
return null;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A `gh` refusal that means auto-merge was never armed in the first place —
|
|
196
|
+
* there is nothing to disarm, so the PR is already in the un-armed posture
|
|
197
|
+
* the guard wants. Distinguishing this from a genuine disarm failure is
|
|
198
|
+
* load-bearing: an armed PR that could not be disarmed is a blocker, while
|
|
199
|
+
* a never-armed PR is the desired end state.
|
|
200
|
+
*/
|
|
201
|
+
const NOT_ARMED = /not enabled|isn't enabled|is not set|no auto-?merge/i;
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Disarm GitHub native auto-merge for the PR — the race-free response to the
|
|
205
|
+
* first red. Never throws.
|
|
206
|
+
*
|
|
207
|
+
* @param {{ prRef: string, cwd: string, spawnFn?: typeof spawnSync }} opts
|
|
208
|
+
* @returns {{ disarmed: boolean, alreadyUnarmed: boolean, detail: string }}
|
|
209
|
+
* `disarmed` is true when the PR is (now) un-armed; `alreadyUnarmed`
|
|
210
|
+
* distinguishes "there was nothing armed" from an executed disarm.
|
|
211
|
+
*/
|
|
212
|
+
export function disarmAutoMerge({ prRef, cwd, spawnFn = spawnSync }) {
|
|
213
|
+
let result;
|
|
214
|
+
try {
|
|
215
|
+
result = spawnFn('gh', ['pr', 'merge', prRef, '--disable-auto'], {
|
|
216
|
+
cwd,
|
|
217
|
+
encoding: 'utf-8',
|
|
218
|
+
shell: false,
|
|
219
|
+
});
|
|
220
|
+
} catch (err) {
|
|
221
|
+
return {
|
|
222
|
+
disarmed: false,
|
|
223
|
+
alreadyUnarmed: false,
|
|
224
|
+
detail: `gh-spawn-error: ${err?.message ?? err}`,
|
|
225
|
+
};
|
|
226
|
+
}
|
|
227
|
+
const status = result?.status ?? 1;
|
|
228
|
+
const stderr = String(result?.stderr ?? '').trim();
|
|
229
|
+
if (status === 0) {
|
|
230
|
+
return { disarmed: true, alreadyUnarmed: false, detail: 'disarmed' };
|
|
231
|
+
}
|
|
232
|
+
if (NOT_ARMED.test(stderr)) {
|
|
233
|
+
return {
|
|
234
|
+
disarmed: true,
|
|
235
|
+
alreadyUnarmed: true,
|
|
236
|
+
detail: `auto-merge was not armed: ${stderr.slice(0, 160)}`,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
return {
|
|
240
|
+
disarmed: false,
|
|
241
|
+
alreadyUnarmed: false,
|
|
242
|
+
detail: `gh-exit-${status}: ${stderr.slice(0, 200)}`,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Read the CI failure digest for a scope, or `null` when none exists (or it
|
|
248
|
+
* is unreadable / malformed — an unparseable digest carries no enforceable
|
|
249
|
+
* evidence, so it is treated as absent).
|
|
250
|
+
*
|
|
251
|
+
* @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
|
|
252
|
+
* @returns {object|null}
|
|
253
|
+
*/
|
|
254
|
+
export function readCiDigest({ storyId = null, tempRoot, cwd }) {
|
|
255
|
+
const paths = ciDigestPaths({ storyId, tempRoot, cwd });
|
|
256
|
+
if (!paths || !existsSync(paths.jsonPath)) return null;
|
|
257
|
+
try {
|
|
258
|
+
const parsed = JSON.parse(readFileSync(paths.jsonPath, 'utf8'));
|
|
259
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
260
|
+
} catch {
|
|
261
|
+
return null;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Retire the digest for a scope — the red it recorded was resolved at
|
|
267
|
+
* source. Best-effort; returns the paths removed (or `null`).
|
|
268
|
+
*
|
|
269
|
+
* @param {{ storyId?: number|string|null, tempRoot: string, cwd: string }} opts
|
|
270
|
+
* @returns {{ jsonPath: string, mdPath: string } | null}
|
|
271
|
+
*/
|
|
272
|
+
export function retireCiDigest({ storyId = null, tempRoot, cwd }) {
|
|
273
|
+
const paths = ciDigestPaths({ storyId, tempRoot, cwd });
|
|
274
|
+
if (!paths) return null;
|
|
275
|
+
rmSync(paths.jsonPath, { force: true });
|
|
276
|
+
rmSync(paths.mdPath, { force: true });
|
|
277
|
+
return { jsonPath: paths.jsonPath, mdPath: paths.mdPath };
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Condense a digest into the record kept in a successor digest's
|
|
282
|
+
* `priorReds`, so a second red on a new head never silently erases the
|
|
283
|
+
* evidence of an unresolved earlier one.
|
|
284
|
+
*/
|
|
285
|
+
function summarizeRed(digest) {
|
|
286
|
+
return {
|
|
287
|
+
headSha: digest?.headSha ?? null,
|
|
288
|
+
failingCheck: digest?.failingCheck ?? null,
|
|
289
|
+
runId: digest?.runId ?? null,
|
|
290
|
+
runUrl: digest?.runUrl ?? null,
|
|
291
|
+
generatedAt: digest?.generatedAt ?? null,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Carry forward the unresolved reds an incoming digest must not lose. A
|
|
297
|
+
* re-red on the SAME head is the same unresolved red observed again, so it
|
|
298
|
+
* is not duplicated into the history.
|
|
299
|
+
*/
|
|
300
|
+
function carryPriorReds(previous, headSha) {
|
|
301
|
+
if (!previous) return [];
|
|
302
|
+
const inherited = Array.isArray(previous.priorReds) ? previous.priorReds : [];
|
|
303
|
+
const history =
|
|
304
|
+
previous.headSha && previous.headSha === headSha
|
|
305
|
+
? inherited
|
|
306
|
+
: [...inherited, summarizeRed(previous)];
|
|
307
|
+
return history.slice(-MAX_PRIOR_REDS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Render the human-readable digest. Names the head SHA, because that is the
|
|
312
|
+
* field the green path adjudicates on.
|
|
313
|
+
*/
|
|
314
|
+
function renderDigestMarkdown(digest, failures) {
|
|
315
|
+
return [
|
|
316
|
+
`# CI failure digest — Story #${digest.storyId} (PR #${digest.prNumber})`,
|
|
317
|
+
'',
|
|
318
|
+
`- **Failing check:** \`${digest.failingCheck}\` (${digest.failingOutcome})`,
|
|
319
|
+
`- **Head SHA:** ${digest.headSha ?? 'unresolved'}`,
|
|
320
|
+
`- **Run id:** ${digest.runId ?? 'unresolved'}`,
|
|
321
|
+
`- **Run link:** ${digest.runUrl ?? 'unresolved'}`,
|
|
322
|
+
`- **Classification:** ${digest.classification}`,
|
|
323
|
+
`- **Generated:** ${digest.generatedAt}`,
|
|
324
|
+
'',
|
|
325
|
+
failures.length > 1
|
|
326
|
+
? `Other non-green checks: ${failures
|
|
327
|
+
.slice(1)
|
|
328
|
+
.map((f) => `\`${f.name}\`=${f.outcome}`)
|
|
329
|
+
.join(', ')}`
|
|
330
|
+
: '',
|
|
331
|
+
digest.priorReds.length > 0
|
|
332
|
+
? `Unresolved earlier red(s): ${digest.priorReds
|
|
333
|
+
.map((r) => `\`${r.failingCheck}\`@${r.headSha ?? 'unknown'}`)
|
|
334
|
+
.join(', ')}`
|
|
335
|
+
: '',
|
|
336
|
+
'',
|
|
337
|
+
'A green on THIS head SHA is a re-run of a failed job and is forbidden',
|
|
338
|
+
'(`.agents/rules/ci-remediation.md` § Verifier). Fix at source and push a',
|
|
339
|
+
'new commit — the head SHA moving is what clears this digest.',
|
|
340
|
+
'',
|
|
341
|
+
'## `gh run view --log-failed` tail',
|
|
342
|
+
'',
|
|
343
|
+
'```text',
|
|
344
|
+
digest.logTail || '(no failed-log output available)',
|
|
345
|
+
'```',
|
|
346
|
+
'',
|
|
347
|
+
].join('\n');
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Write the CI failure digest (`.json` + `.md`) for a red watch. Returns
|
|
352
|
+
* the two paths written, or `null` when no story id was supplied (the
|
|
353
|
+
* digest is scoped by filename and has nothing to key on).
|
|
354
|
+
*
|
|
355
|
+
* @param {object} opts
|
|
356
|
+
* @param {number|string|null} [opts.storyId] The v2 delivery scope.
|
|
357
|
+
* @param {number} opts.prNumber
|
|
358
|
+
* @param {string|null} [opts.headSha] PR head SHA at the moment of the red.
|
|
359
|
+
* @param {Array<{name:string, outcome:string}>} opts.failures
|
|
360
|
+
* @param {string} opts.tempRoot
|
|
361
|
+
* @param {string} opts.cwd
|
|
362
|
+
* @param {string} opts.prRef
|
|
363
|
+
* @param {Function} [opts.checkRunFn]
|
|
364
|
+
* @param {Function} [opts.logTailFn]
|
|
365
|
+
* @returns {{ jsonPath: string, mdPath: string } | null}
|
|
366
|
+
*/
|
|
367
|
+
export function writeCiDigest({
|
|
368
|
+
storyId = null,
|
|
369
|
+
prNumber,
|
|
370
|
+
headSha = null,
|
|
371
|
+
failures,
|
|
372
|
+
tempRoot,
|
|
373
|
+
cwd,
|
|
374
|
+
prRef,
|
|
375
|
+
checkRunFn = resolveFailingCheckRun,
|
|
376
|
+
logTailFn = ghRunLogTail,
|
|
377
|
+
}) {
|
|
378
|
+
const paths = ciDigestPaths({ storyId, tempRoot, cwd });
|
|
379
|
+
if (!paths) return null;
|
|
380
|
+
const primary = failures[0] ?? { name: 'unknown', outcome: 'failure' };
|
|
381
|
+
const checkRun = checkRunFn({ prRef, checkName: primary.name, cwd }) ?? {};
|
|
382
|
+
const logTail = logTailFn({ runId: checkRun.runId, cwd });
|
|
383
|
+
const previous = readCiDigest({ storyId, tempRoot, cwd });
|
|
384
|
+
const digest = {
|
|
385
|
+
storyId: paths.scope.id,
|
|
386
|
+
prNumber,
|
|
387
|
+
headSha,
|
|
388
|
+
failingCheck: primary.name,
|
|
389
|
+
failingOutcome: primary.outcome,
|
|
390
|
+
runId: checkRun.runId ?? null,
|
|
391
|
+
runUrl: checkRun.url ?? null,
|
|
392
|
+
failingCheckRun: {
|
|
393
|
+
name: primary.name,
|
|
394
|
+
outcome: primary.outcome,
|
|
395
|
+
runId: checkRun.runId ?? null,
|
|
396
|
+
url: checkRun.url ?? null,
|
|
397
|
+
},
|
|
398
|
+
classification: classifyFailure(primary.name),
|
|
399
|
+
allFailures: failures,
|
|
400
|
+
priorReds: carryPriorReds(previous, headSha),
|
|
401
|
+
logTail,
|
|
402
|
+
generatedAt: new Date().toISOString(),
|
|
403
|
+
};
|
|
404
|
+
mkdirSync(path.dirname(paths.jsonPath), { recursive: true });
|
|
405
|
+
writeFileSync(paths.jsonPath, `${JSON.stringify(digest, null, 2)}\n`);
|
|
406
|
+
writeFileSync(paths.mdPath, renderDigestMarkdown(digest, failures));
|
|
407
|
+
return { jsonPath: paths.jsonPath, mdPath: paths.mdPath };
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Adjudicate an all-green watch against any digest recorded for the scope.
|
|
412
|
+
*
|
|
413
|
+
* @param {{ digest: object|null, headSha: string|null }} opts
|
|
414
|
+
* @returns {{ verdict: 'clean'|'fix-at-source'|'rerun'|'unverifiable', reason: string }}
|
|
415
|
+
* - `clean` — no digest: this delivery never went red.
|
|
416
|
+
* - `fix-at-source` — the head SHA moved since the red; legal.
|
|
417
|
+
* - `rerun` — green on the SAME head SHA; forbidden.
|
|
418
|
+
* - `unverifiable` — an unresolved red with no head-SHA evidence either
|
|
419
|
+
* side; fail closed and treat it as a rerun.
|
|
420
|
+
*/
|
|
421
|
+
export function classifyGreenVerdict({ digest, headSha }) {
|
|
422
|
+
if (!digest) return { verdict: 'clean', reason: 'no digest for this scope' };
|
|
423
|
+
const recorded = digest.headSha ?? null;
|
|
424
|
+
if (!recorded || !headSha) {
|
|
425
|
+
return {
|
|
426
|
+
verdict: 'unverifiable',
|
|
427
|
+
reason: `cannot prove the red was fixed at source (digest head=${recorded ?? 'unknown'}, current head=${headSha ?? 'unresolved'})`,
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
if (recorded === headSha) {
|
|
431
|
+
return {
|
|
432
|
+
verdict: 'rerun',
|
|
433
|
+
reason: `green on the SAME head SHA the red was recorded against (${headSha})`,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
return {
|
|
437
|
+
verdict: 'fix-at-source',
|
|
438
|
+
reason: `head SHA moved ${recorded} → ${headSha}`,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* The failure signature a `meta::framework-gap` issue must carry: the first
|
|
444
|
+
* distinctive line of the captured failed-job log.
|
|
445
|
+
*/
|
|
446
|
+
function failureSignature(digest) {
|
|
447
|
+
const tail = String(digest?.logTail ?? '');
|
|
448
|
+
const line = tail
|
|
449
|
+
.split('\n')
|
|
450
|
+
.map((l) => l.trim())
|
|
451
|
+
.find((l) => l.length > 0);
|
|
452
|
+
return line ?? '(no failed-log output captured)';
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Render the rerun-violation report — used verbatim as the friction comment
|
|
457
|
+
* body and, line by line, as the watcher's stderr report.
|
|
458
|
+
*
|
|
459
|
+
* @param {{ digest: object, headSha: string|null, prNumber: number, reason: string }} opts
|
|
460
|
+
* @returns {string}
|
|
461
|
+
*/
|
|
462
|
+
export function formatRerunViolation({ digest, headSha, prNumber, reason }) {
|
|
463
|
+
return [
|
|
464
|
+
'### Forbidden CI re-run detected — delivery blocked',
|
|
465
|
+
'',
|
|
466
|
+
`Required check \`${digest.failingCheck}\` went red on PR #${prNumber}, and the checks are`,
|
|
467
|
+
`now green with no new commit: ${reason}.`,
|
|
468
|
+
'',
|
|
469
|
+
`- **Head SHA:** ${headSha ?? 'unresolved'}`,
|
|
470
|
+
`- **Run link:** ${digest.runUrl ?? `run id ${digest.runId ?? 'unresolved'}`}`,
|
|
471
|
+
`- **Failure signature:** \`${failureSignature(digest)}\``,
|
|
472
|
+
`- **Classification:** ${digest.classification ?? 'unknown'}`,
|
|
473
|
+
'',
|
|
474
|
+
'A green reached by re-running a failed job masks the defect and is',
|
|
475
|
+
'prohibited by `.agents/rules/ci-remediation.md` § Verifier. Native',
|
|
476
|
+
'auto-merge was disarmed when the check first went red, so nothing merged.',
|
|
477
|
+
'',
|
|
478
|
+
'**To proceed**, do exactly one of:',
|
|
479
|
+
'',
|
|
480
|
+
'1. Fix the root cause on `story-<id>` and push a new commit — the head SHA',
|
|
481
|
+
' moving is what clears the block.',
|
|
482
|
+
'2. File a `meta::framework-gap` issue carrying the run link and failure',
|
|
483
|
+
' signature above when the root cause is outside this delivery, then',
|
|
484
|
+
' resume.',
|
|
485
|
+
].join('\n');
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Hard-stop the delivery: post the `friction` comment and flip the Story to
|
|
490
|
+
* `agent::blocked`. Both halves are best-effort — a failure to reach GitHub
|
|
491
|
+
* must not turn the block into a crash, and the watcher's non-zero exit is
|
|
492
|
+
* what actually stops the delivery.
|
|
493
|
+
*
|
|
494
|
+
* Routed through `transitionTicketState` rather than a bare label write so
|
|
495
|
+
* the Projects v2 column sync runs (Story #2548 / #4539).
|
|
496
|
+
*
|
|
497
|
+
* @param {{
|
|
498
|
+
* storyId: number|string,
|
|
499
|
+
* body: string,
|
|
500
|
+
* provider?: object,
|
|
501
|
+
* config?: object,
|
|
502
|
+
* logger?: object,
|
|
503
|
+
* }} opts
|
|
504
|
+
* @returns {Promise<{ blocked: boolean, commented: boolean }>}
|
|
505
|
+
*/
|
|
506
|
+
export async function blockStoryDelivery({
|
|
507
|
+
storyId,
|
|
508
|
+
body,
|
|
509
|
+
provider,
|
|
510
|
+
config,
|
|
511
|
+
logger = Logger,
|
|
512
|
+
}) {
|
|
513
|
+
const scope = resolveDigestScope({ storyId });
|
|
514
|
+
if (!scope) {
|
|
515
|
+
logger?.error?.(
|
|
516
|
+
'[ci-rerun-guard] no Story id — cannot flip agent::blocked; the non-zero exit is the only stop.',
|
|
517
|
+
);
|
|
518
|
+
return { blocked: false, commented: false };
|
|
519
|
+
}
|
|
520
|
+
let ticketing;
|
|
521
|
+
try {
|
|
522
|
+
ticketing = provider ?? createProvider(config ?? resolveConfig());
|
|
523
|
+
} catch (err) {
|
|
524
|
+
logger?.error?.(
|
|
525
|
+
`[ci-rerun-guard] could not resolve the ticketing provider: ${err?.message ?? err}`,
|
|
526
|
+
);
|
|
527
|
+
return { blocked: false, commented: false };
|
|
528
|
+
}
|
|
529
|
+
let commented = false;
|
|
530
|
+
try {
|
|
531
|
+
await upsertStructuredComment(ticketing, scope.id, 'friction', body);
|
|
532
|
+
commented = true;
|
|
533
|
+
} catch (err) {
|
|
534
|
+
logger?.error?.(
|
|
535
|
+
`[ci-rerun-guard] failed to post the friction comment: ${err?.message ?? err}`,
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
let blocked = false;
|
|
539
|
+
try {
|
|
540
|
+
await transitionTicketState(ticketing, scope.id, STATE_LABELS.BLOCKED, {});
|
|
541
|
+
blocked = true;
|
|
542
|
+
} catch (err) {
|
|
543
|
+
logger?.error?.(
|
|
544
|
+
`[ci-rerun-guard] failed to flip Story #${scope.id} to blocked: ${err?.message ?? err}`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
return { blocked, commented };
|
|
548
|
+
}
|
|
@@ -406,8 +406,8 @@ export function decideRecovery({
|
|
|
406
406
|
nextCommand: NEXT_COMMANDS.watchCi(storyId, pr.number),
|
|
407
407
|
detail:
|
|
408
408
|
`PR #${pr.number} has a red required check. Waiting cannot help — fix the ` +
|
|
409
|
-
`failure and push a new commit on \`story-${storyId}\`;
|
|
410
|
-
`
|
|
409
|
+
`failure and push a new commit on \`story-${storyId}\`; the red disarmed ` +
|
|
410
|
+
`auto-merge, and only a green on a new head SHA re-arms it.`,
|
|
411
411
|
evidence,
|
|
412
412
|
};
|
|
413
413
|
}
|