mandrel 2.55.0 → 2.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.agents/docs/agentrc-reference.json +4 -0
  2. package/.agents/docs/configuration.md +3 -0
  3. package/.agents/rules/ci-remediation.md +39 -21
  4. package/.agents/schemas/agentrc.schema.json +19 -0
  5. package/.agents/scripts/audit-to-stories.js +222 -75
  6. package/.agents/scripts/file-ci-gap.js +306 -0
  7. package/.agents/scripts/lib/audit-to-stories/audit-label-taxonomy.js +25 -1
  8. package/.agents/scripts/lib/audit-to-stories/dedupe-against-github.js +40 -52
  9. package/.agents/scripts/lib/audit-to-stories/finding-adapter.js +5 -1
  10. package/.agents/scripts/lib/audit-to-stories/issue-corpus.js +162 -0
  11. package/.agents/scripts/lib/audit-to-stories/issues-file.js +121 -0
  12. package/.agents/scripts/lib/audit-to-stories/ledger-commit.js +1 -1
  13. package/.agents/scripts/lib/audit-to-stories/ledger-record.js +126 -0
  14. package/.agents/scripts/lib/audit-to-stories/seed-from-findings.js +11 -0
  15. package/.agents/scripts/lib/config-settings-schema.js +33 -0
  16. package/.agents/scripts/lib/feedback-loop/graduator-core.js +53 -13
  17. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +71 -25
  18. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +18 -25
  19. package/.agents/scripts/lib/{audit-to-stories/ledger.js → findings/audit-ledger.js} +131 -24
  20. package/.agents/scripts/lib/findings/route-finding.js +38 -0
  21. package/.agents/scripts/lib/generated/agentrc-validator.js +1 -1
  22. package/.agents/scripts/lib/github/framework-repo.js +148 -2
  23. package/.agents/scripts/lib/label-constants.js +6 -1
  24. package/.agents/scripts/lib/observability/source-classifier.js +1 -0
  25. package/.agents/scripts/lib/orchestration/ci-gap-intake.js +605 -0
  26. package/.agents/scripts/lib/orchestration/ci-rerun-guard.js +13 -8
  27. package/.agents/scripts/lib/orchestration/plan-persist/audit-provenance.js +197 -0
  28. package/.agents/scripts/lib/orchestration/plan-persist/run-plan-persist.js +15 -2
  29. package/.agents/scripts/lib/orchestration/run-epilogue.js +4 -4
  30. package/.agents/scripts/lib/orchestration/story-follow-ups.js +32 -20
  31. package/.agents/scripts/pr-watch-with-update.js +3 -2
  32. package/.agents/workflows/audit-to-stories.md +63 -27
  33. package/.agents/workflows/helpers/deliver-story-reference.md +19 -4
  34. package/.agents/workflows/helpers/plan-reference.md +23 -0
  35. package/.agents/workflows/mandrel-plan.md +6 -6
  36. package/docs/CHANGELOG.md +10 -0
  37. package/package.json +1 -1
@@ -0,0 +1,605 @@
1
+ /**
2
+ * ci-gap-intake.js — the mechanism behind `rules/ci-remediation.md` Option 2.
3
+ *
4
+ * ## What was here before: nothing
5
+ *
6
+ * The rule told a delivering agent to "file a `meta::framework-gap` issue
7
+ * carrying the run link and the failure signature". That sentence was the
8
+ * whole implementation. The agent hand-ran `gh issue create` in whatever
9
+ * repository it happened to be standing in, which produced two failures every
10
+ * time:
11
+ *
12
+ * 1. **Unactionable.** A hand-typed issue has no `## Spec`, no
13
+ * `acceptance[]` / `verify[]` and no `agent::*` label, so
14
+ * `/mandrel-deliver` cannot take it. `/mandrel-plan` Phase 0 re-surfaces
15
+ * it as a recurring-defect row forever and nothing ever acts on it.
16
+ * 2. **Mis-routed.** It lands in the consumer's repo no matter who owns the
17
+ * fault — including when the root cause is a shared base config or the
18
+ * runner host, which no amount of work in the consumer repo can fix.
19
+ *
20
+ * ## Two phases, never one
21
+ *
22
+ * Delivery files an **intake issue**; planning graduates it. This module
23
+ * never invokes `/mandrel-plan`, never blocks on it, and never authors a
24
+ * Story body. It runs mid-delivery while the Story is heading to
25
+ * `agent::blocked` with nobody at the keyboard, so a synchronous planning
26
+ * call would couple the CI path to an interactive workflow. Instead the
27
+ * intake issue is written in a shape Phase 0 can recognise (the
28
+ * {@link CI_GAP_INTAKE_MARKER}) and `/mandrel-plan <id>` rewrites it into a
29
+ * Story on the next planning pass.
30
+ *
31
+ * ## Dedup is the point, not a nicety
32
+ *
33
+ * Runner-host contention is a repeat offender: the same signature reds an
34
+ * unrelated PR weekly. The Nth occurrence must land on the existing ticket —
35
+ * an occurrence count is the evidence that turns "a flake" into "a defect
36
+ * worth someone's afternoon". Routing goes through `findings/route-finding.js`,
37
+ * the same fingerprint dedup `audit-to-stories` and `qa-explore` share, so
38
+ * there is one dedup implementation in the repo rather than three.
39
+ *
40
+ * Pure orchestration: **no network I/O lives here.** Every GitHub side effect
41
+ * flows through injected ports (`searchIssues`, `createIssue`, `updateIssue`),
42
+ * so the unit tests run with no network and the CLI wires them to the
43
+ * provider.
44
+ */
45
+
46
+ import {
47
+ fingerprintFinding,
48
+ fingerprintFooter,
49
+ routeFinding,
50
+ } from '../findings/route-finding.js';
51
+ import { OWNERSHIP_BUCKETS, routeOwnership } from '../github/framework-repo.js';
52
+ import { META_LABELS } from '../label-constants.js';
53
+
54
+ /**
55
+ * Body marker identifying a CI-gap intake issue. `/mandrel-plan` Phase 0
56
+ * keys graduation off it, so it is a contract: bump the version suffix only
57
+ * alongside a reader that understands both.
58
+ */
59
+ export const CI_GAP_INTAKE_MARKER = '<!-- ci-gap-intake: v1 -->';
60
+
61
+ /**
62
+ * The verdicts that route to Option 2 in `rules/ci-remediation.md`. Closed by
63
+ * construction: this is the rule's own set, not a second taxonomy invented
64
+ * here. Each becomes a `friction::<verdict>` label, which is what
65
+ * `prior-feedback-fetcher.js` already counts into `recurringDefectClasses[]`.
66
+ */
67
+ export const INTAKE_VERDICTS = Object.freeze([
68
+ 'pre-existing',
69
+ 'capacity',
70
+ 'unreproducible-tier',
71
+ ]);
72
+
73
+ /**
74
+ * The Option-1 verdict. Named rather than merely absent so refusing it can
75
+ * say *why*: a defect in the diff under review is fixed at source on the
76
+ * branch, and filing an intake issue for it would launder a real defect into
77
+ * someone else's backlog.
78
+ */
79
+ export const REFUSED_VERDICT = 'defect-in-diff';
80
+
81
+ /** Ownership bucket → the `meta::*` label that marks who owns the work. */
82
+ const BUCKET_META_LABEL = Object.freeze({
83
+ consumer: META_LABELS.CONSUMER_IMPROVEMENT,
84
+ framework: META_LABELS.FRAMEWORK_GAP,
85
+ platform: META_LABELS.PLATFORM_GAP,
86
+ });
87
+
88
+ /**
89
+ * Validate a verdict against the rule's Option-2 set.
90
+ *
91
+ * Throws rather than defaulting: a mis-verdicted filing routes real work to
92
+ * the wrong owner, and the caller can always fix the flag.
93
+ *
94
+ * @param {string} verdict
95
+ * @returns {string} the validated verdict
96
+ */
97
+ function assertIntakeVerdict(verdict) {
98
+ if (verdict === REFUSED_VERDICT) {
99
+ throw new Error(
100
+ `verdict "${REFUSED_VERDICT}" routes to Option 1 (fix at source on the branch), not to an intake filing — see .agents/rules/ci-remediation.md`,
101
+ );
102
+ }
103
+ if (!INTAKE_VERDICTS.includes(verdict)) {
104
+ throw new Error(
105
+ `unknown verdict "${verdict}" — expected one of ${INTAKE_VERDICTS.join(', ')}`,
106
+ );
107
+ }
108
+ return verdict;
109
+ }
110
+
111
+ /**
112
+ * Validate an ownership bucket against the closed set.
113
+ *
114
+ * @param {string} bucket
115
+ * @returns {string} the validated bucket
116
+ */
117
+ function assertOwnershipBucket(bucket) {
118
+ if (!OWNERSHIP_BUCKETS.includes(bucket)) {
119
+ throw new Error(
120
+ `unknown ownership bucket "${bucket}" — expected one of ${OWNERSHIP_BUCKETS.join(', ')}`,
121
+ );
122
+ }
123
+ return bucket;
124
+ }
125
+
126
+ /**
127
+ * The failure signature: the first distinctive line of the captured
128
+ * failed-job log. Mirrors `ci-rerun-guard.js`'s reading of the same digest
129
+ * field — this is the line a human recognises a repeat occurrence by.
130
+ *
131
+ * @param {object} digest
132
+ * @returns {string}
133
+ */
134
+ function failureSignature(digest) {
135
+ const line = String(digest?.logTail ?? '')
136
+ .split('\n')
137
+ .map((l) => l.trim())
138
+ .find((l) => l.length > 0);
139
+ return line ?? '(no failed-log output captured)';
140
+ }
141
+
142
+ /**
143
+ * Normalise a signature line into fingerprint-stable text.
144
+ *
145
+ * Run-specific noise — timestamps, ports, run ids, hex object ids, absolute
146
+ * temp paths — makes two occurrences of the SAME defect fingerprint
147
+ * differently, which is precisely the dedup failure this module exists to
148
+ * avoid. Stripping it is what lets the Nth occurrence find the first.
149
+ *
150
+ * @param {string} line
151
+ * @returns {string}
152
+ */
153
+ function normaliseSignature(line) {
154
+ return String(line ?? '')
155
+ .replace(/\b\d{4}-\d{2}-\d{2}[T ][\d:.]+Z?\b/g, '<ts>')
156
+ .replace(/\b[0-9a-f]{7,40}\b/gi, '<sha>')
157
+ .replace(/:\d{2,5}\b/g, ':<port>')
158
+ .replace(/\b\d+(\.\d+)?(ms|s)\b/g, '<duration>')
159
+ .replace(/\b\d+\b/g, '<n>')
160
+ .replace(/\s+/g, ' ')
161
+ .trim()
162
+ .slice(0, 200);
163
+ }
164
+
165
+ /**
166
+ * Build the canonical finding the dedup layer fingerprints.
167
+ *
168
+ * `area` folds in the verdict so the same signature reached under two
169
+ * different verdicts stays two findings — the verdict is who owns the fix,
170
+ * and merging those would merge two different pieces of work.
171
+ *
172
+ * @param {object} opts
173
+ * @param {object} opts.digest — the CI failure digest.
174
+ * @param {string} opts.verdict
175
+ * @param {string} opts.bucket
176
+ * @returns {object} canonical finding for `route-finding.js`
177
+ */
178
+ function buildIntakeFinding({ digest, verdict, bucket }) {
179
+ return {
180
+ title: normaliseSignature(failureSignature(digest)),
181
+ area: `ci:${verdict}`,
182
+ primaryFile: String(digest?.failingCheck ?? 'unknown-check'),
183
+ severity: 'high',
184
+ labels: [BUCKET_META_LABEL[bucket], `friction::${verdict}`],
185
+ };
186
+ }
187
+
188
+ /**
189
+ * Render one `## Occurrences` row.
190
+ *
191
+ * @param {object} occurrence
192
+ * @returns {string}
193
+ */
194
+ function renderOccurrenceRow({ at, runUrl, headSha, prNumber }) {
195
+ const cells = [
196
+ at ?? new Date().toISOString(),
197
+ runUrl ? `[run](${runUrl})` : '_(no run link)_',
198
+ headSha ? `\`${String(headSha).slice(0, 12)}\`` : '_(unresolved)_',
199
+ prNumber ? `#${prNumber}` : '_(n/a)_',
200
+ ];
201
+ return `| ${cells.join(' | ')} |`;
202
+ }
203
+
204
+ /** Header of the occurrences table, matched verbatim when appending a row. */
205
+ const OCCURRENCES_HEADER = [
206
+ '## Occurrences',
207
+ '',
208
+ '| When | Run | Head SHA | PR |',
209
+ '| --- | --- | --- | --- |',
210
+ ].join('\n');
211
+
212
+ /**
213
+ * Render the `## Routing` block — the half of this module that exists purely
214
+ * so a mis-route can never again be silent. It states the bucket, the
215
+ * destination, and, when there is no destination, the exact config key that
216
+ * would give it one.
217
+ *
218
+ * @param {object} routing
219
+ * @returns {string}
220
+ */
221
+ function renderRouting(routing) {
222
+ const lines = ['## Routing', '', `- **Owner:** \`${routing.bucket}\``];
223
+ if (!routing.routable) {
224
+ lines.push(
225
+ `- **Status:** \`unroutable\` — \`${routing.missingKey}\` is unset in \`.agentrc.json\`, so this was filed in the repository that surfaced it instead of the repository that owns it.`,
226
+ '- **To fix the routing:** set that key and re-file, or move this issue by hand.',
227
+ );
228
+ } else if (routing.deferredFrom) {
229
+ lines.push(
230
+ `- **Status:** \`deferred to ${routing.deferredFrom}\` — the write to that repository was refused, so this was filed locally.`,
231
+ `- **Refusal:** \`${routing.deferralReason}\``,
232
+ '- **To fix the routing:** grant a token with write scope on the target repository and re-file, or move this issue by hand.',
233
+ );
234
+ } else {
235
+ lines.push(
236
+ `- **Status:** \`routed\` — filed in \`${routing.routedRepo.owner}/${routing.routedRepo.repo}\`.`,
237
+ );
238
+ }
239
+ return lines.join('\n');
240
+ }
241
+
242
+ /**
243
+ * Render the intake issue body.
244
+ *
245
+ * @param {object} opts
246
+ * @returns {string}
247
+ */
248
+ function renderIntakeBody({
249
+ digest,
250
+ verdict,
251
+ evidence,
252
+ routing,
253
+ fingerprint,
254
+ occurrence,
255
+ }) {
256
+ const runRef =
257
+ digest?.runUrl ??
258
+ (digest?.runId ? `run id ${digest.runId}` : '_(run link unresolved)_');
259
+ return [
260
+ CI_GAP_INTAKE_MARKER,
261
+ '',
262
+ '> Intake issue filed by the CI-remediation path. It is **not** an',
263
+ "> executable Story: run `/mandrel-plan <this issue's number>` to graduate",
264
+ '> it into one.',
265
+ '',
266
+ '## Signature',
267
+ '',
268
+ `- **Failing check:** \`${digest?.failingCheck ?? 'unknown'}\``,
269
+ `- **Run:** ${runRef}`,
270
+ `- **Classification:** ${digest?.classification ?? 'unknown'}`,
271
+ '',
272
+ '```text',
273
+ failureSignature(digest),
274
+ '```',
275
+ '',
276
+ '## Verdict',
277
+ '',
278
+ `- **Verdict:** \`${verdict}\` (see \`.agents/rules/ci-remediation.md\`)`,
279
+ `- **Proof reading:** ${evidence?.trim() ? evidence.trim() : '_(none supplied — this verdict is unproven and should be re-triaged)_'}`,
280
+ '',
281
+ renderRouting(routing),
282
+ '',
283
+ OCCURRENCES_HEADER,
284
+ renderOccurrenceRow(occurrence),
285
+ '',
286
+ fingerprintFooter([fingerprint]),
287
+ ].join('\n');
288
+ }
289
+
290
+ /**
291
+ * Append one occurrence row to an existing intake body.
292
+ *
293
+ * Appends to the existing table when the body carries one, and otherwise
294
+ * grafts a fresh table onto the end — an intake issue an operator has
295
+ * hand-edited must still accumulate occurrences rather than silently losing
296
+ * them.
297
+ *
298
+ * @param {string} body — the existing issue body.
299
+ * @param {object} occurrence
300
+ * @returns {string}
301
+ */
302
+ function appendOccurrence(body, occurrence) {
303
+ const row = renderOccurrenceRow(occurrence);
304
+ const existing = String(body ?? '');
305
+ if (!existing.includes('## Occurrences')) {
306
+ return [existing.trimEnd(), '', OCCURRENCES_HEADER, row].join('\n');
307
+ }
308
+ const lines = existing.split('\n');
309
+ // The table runs to the first blank line after the header; inserting there
310
+ // (rather than appending to the body) keeps any footer below it intact.
311
+ const headerIdx = lines.findIndex((l) => l.trim() === '## Occurrences');
312
+ let insertAt = lines.length;
313
+ for (let i = headerIdx + 1; i < lines.length; i += 1) {
314
+ const isRow = lines[i].trimStart().startsWith('|');
315
+ if (isRow) insertAt = i + 1;
316
+ else if (insertAt !== lines.length) break;
317
+ }
318
+ lines.splice(insertAt, 0, row);
319
+ return lines.join('\n');
320
+ }
321
+
322
+ /**
323
+ * Compose the intake issue title. Verdict-prefixed so a tracker list reads as
324
+ * a triage queue rather than a wall of stack traces.
325
+ *
326
+ * @param {object} opts
327
+ * @returns {string}
328
+ */
329
+ function buildIntakeTitle({ digest, verdict }) {
330
+ const signature = failureSignature(digest).slice(0, 110);
331
+ return `CI gap (${verdict}): \`${digest?.failingCheck ?? 'unknown check'}\` — ${signature}`;
332
+ }
333
+
334
+ /**
335
+ * Append this occurrence to the intake issue already tracking the signature.
336
+ *
337
+ * The Nth occurrence is the evidence that turns "a flake" into a defect worth
338
+ * someone's afternoon, so it lands on the existing ticket rather than minting
339
+ * a second one.
340
+ *
341
+ * @param {object} opts
342
+ * @returns {Promise<object>} the intake result envelope.
343
+ */
344
+ async function recordRecurrence({
345
+ matchedIssue,
346
+ occurrence,
347
+ routing,
348
+ labels,
349
+ title,
350
+ fingerprint,
351
+ ports,
352
+ errors,
353
+ }) {
354
+ const updatedBody = appendOccurrence(matchedIssue.body, occurrence);
355
+ const res = await ports.updateIssue({
356
+ owner: routing.routedRepo.owner,
357
+ repo: routing.routedRepo.repo,
358
+ number: matchedIssue.number,
359
+ body: updatedBody,
360
+ });
361
+ if (res?.error) errors.push(`issue update failed: ${res.error}`);
362
+ return {
363
+ decision: 'update-existing',
364
+ issue: {
365
+ number: matchedIssue.number,
366
+ url: res?.url ?? matchedIssue.url ?? null,
367
+ },
368
+ routing,
369
+ labels,
370
+ title,
371
+ body: updatedBody,
372
+ fingerprint,
373
+ dryRun: false,
374
+ errors,
375
+ };
376
+ }
377
+
378
+ /**
379
+ * Create the intake issue in the routed repository, degrading to a local
380
+ * filing when that write is refused.
381
+ *
382
+ * A cross-repo write needs a token scoped to the target repo, which an
383
+ * operator may simply not have granted. The refusal becomes a local filing
384
+ * that names the repo the work belongs in — never a silent drop, and never a
385
+ * pretend-success. `routing` is mutated so the caller's envelope and the
386
+ * re-rendered body agree on where this landed.
387
+ *
388
+ * @param {object} opts
389
+ * @param {Function} opts.render — re-render the body for a mutated routing.
390
+ * @returns {Promise<{ created: object, body: string }>}
391
+ */
392
+ async function createWithDegrade({
393
+ routing,
394
+ currentRepo,
395
+ title,
396
+ body,
397
+ labels,
398
+ ports,
399
+ logger,
400
+ render,
401
+ }) {
402
+ const created = await ports.createIssue({
403
+ owner: routing.routedRepo.owner,
404
+ repo: routing.routedRepo.repo,
405
+ title,
406
+ body,
407
+ labels,
408
+ });
409
+ if (!created?.error || !routing.crossRepo) return { created, body };
410
+
411
+ const target = `${routing.routedRepo.owner}/${routing.routedRepo.repo}`;
412
+ logger?.warn?.(
413
+ `[ci-gap-intake] cross-repo write to ${target} refused (${created.error}) — filing in ${currentRepo.owner}/${currentRepo.repo} and recording the deferral.`,
414
+ );
415
+ routing.deferredFrom = target;
416
+ routing.deferralReason = created.error;
417
+ routing.routedRepo = currentRepo;
418
+ routing.crossRepo = false;
419
+ const localBody = render(routing);
420
+ const retried = await ports.createIssue({
421
+ owner: currentRepo.owner,
422
+ repo: currentRepo.repo,
423
+ title,
424
+ body: localBody,
425
+ labels,
426
+ });
427
+ return { created: retried, body: localBody };
428
+ }
429
+
430
+ /**
431
+ * File (or update) the intake issue for one CI-gap verdict.
432
+ *
433
+ * Never throws for an I/O reason — every port failure lands in `errors[]` so
434
+ * a filing fault cannot itself fail the delivery that is already blocking.
435
+ * An invalid verdict or bucket DOES throw: that is operator input, caught
436
+ * before any side effect, and silently proceeding would file the wrong thing.
437
+ *
438
+ * @param {object} opts
439
+ * @param {object} opts.digest — CI failure digest (from `ci-rerun-guard.js`).
440
+ * @param {string} opts.verdict — one of {@link INTAKE_VERDICTS}.
441
+ * @param {string} opts.bucket — ownership bucket (`consumer|framework|platform`).
442
+ * @param {string} [opts.evidence] — the verdict's proof reading.
443
+ * @param {object} opts.repos — resolved ownership buckets.
444
+ * @param {{owner: string, repo: string}} opts.currentRepo
445
+ * @param {number|null} [opts.prNumber]
446
+ * @param {boolean} [opts.dryRun] — compose everything, write nothing.
447
+ * @param {object} opts.ports — `{ searchIssues, createIssue, updateIssue }`.
448
+ * @param {object} [opts.logger]
449
+ * @param {string} [opts.now] — ISO timestamp seam for deterministic tests.
450
+ * @returns {Promise<object>} the intake result envelope.
451
+ */
452
+ export async function fileCiGapIntake({
453
+ digest,
454
+ verdict,
455
+ bucket,
456
+ evidence = '',
457
+ repos,
458
+ currentRepo,
459
+ prNumber = null,
460
+ dryRun = false,
461
+ ports = {},
462
+ logger,
463
+ now,
464
+ }) {
465
+ assertIntakeVerdict(verdict);
466
+ assertOwnershipBucket(bucket);
467
+
468
+ const errors = [];
469
+ const occurrence = {
470
+ at: now ?? new Date().toISOString(),
471
+ runUrl: digest?.runUrl ?? null,
472
+ headSha: digest?.headSha ?? null,
473
+ prNumber,
474
+ };
475
+
476
+ const routed = routeOwnership({ bucket, repos, currentRepo });
477
+ // An unroutable bucket files where the run is standing and says so in the
478
+ // body — the one thing it must never do is look routed.
479
+ const routing = {
480
+ bucket,
481
+ routable: routed.routable,
482
+ missingKey: routed.missingKey,
483
+ crossRepo: routed.crossRepo,
484
+ routedRepo: routed.routable ? routed.routedRepo : currentRepo,
485
+ deferredFrom: null,
486
+ deferralReason: null,
487
+ };
488
+ if (!routed.routable) {
489
+ logger?.warn?.(
490
+ `[ci-gap-intake] ${bucket} bucket is unroutable: ${routed.missingKey} is unset — filing in ${currentRepo.owner}/${currentRepo.repo} and saying so in the body.`,
491
+ );
492
+ }
493
+
494
+ const finding = buildIntakeFinding({ digest, verdict, bucket });
495
+ const labels = finding.labels;
496
+ const title = buildIntakeTitle({ digest, verdict });
497
+
498
+ let decision = 'new';
499
+ let matchedIssue = null;
500
+ let fingerprint = fingerprintFinding(finding).full;
501
+ try {
502
+ const routedDecision = await routeFinding(finding, {
503
+ searchIssues: ports.searchIssues,
504
+ });
505
+ decision = routedDecision.decision;
506
+ matchedIssue = routedDecision.matchedIssue;
507
+ fingerprint = routedDecision.fingerprint;
508
+ } catch (err) {
509
+ // A degraded lookup must not mint a duplicate on a repeat offender, so
510
+ // the filing stops here rather than guessing `new`.
511
+ errors.push(`dedup lookup failed: ${err?.message ?? err}`);
512
+ return {
513
+ decision: 'lookup-failed',
514
+ issue: null,
515
+ routing,
516
+ labels,
517
+ title,
518
+ body: null,
519
+ fingerprint,
520
+ dryRun,
521
+ errors,
522
+ };
523
+ }
524
+
525
+ const body = renderIntakeBody({
526
+ digest,
527
+ verdict,
528
+ evidence,
529
+ routing,
530
+ fingerprint,
531
+ occurrence,
532
+ });
533
+
534
+ // An open match is an occurrence of a defect already tracked; only a `new`
535
+ // or a regression of something closed long ago earns a fresh ticket.
536
+ const isRecurrence =
537
+ (decision === 'update-existing' || decision === 'duplicate') &&
538
+ matchedIssue?.number;
539
+
540
+ if (dryRun) {
541
+ return {
542
+ decision,
543
+ issue: matchedIssue ? { number: matchedIssue.number } : null,
544
+ routing,
545
+ labels,
546
+ title,
547
+ body: isRecurrence
548
+ ? appendOccurrence(matchedIssue.body, occurrence)
549
+ : body,
550
+ fingerprint,
551
+ dryRun: true,
552
+ errors,
553
+ };
554
+ }
555
+
556
+ if (isRecurrence) {
557
+ return recordRecurrence({
558
+ matchedIssue,
559
+ occurrence,
560
+ routing,
561
+ labels,
562
+ title,
563
+ fingerprint,
564
+ ports,
565
+ errors,
566
+ });
567
+ }
568
+
569
+ const { created, body: finalBody } = await createWithDegrade({
570
+ routing,
571
+ currentRepo,
572
+ title,
573
+ body,
574
+ labels,
575
+ ports,
576
+ logger,
577
+ render: (r) =>
578
+ renderIntakeBody({
579
+ digest,
580
+ verdict,
581
+ evidence,
582
+ routing: r,
583
+ fingerprint,
584
+ occurrence,
585
+ }),
586
+ });
587
+
588
+ if (created?.error) {
589
+ errors.push(`issue create failed: ${created.error}`);
590
+ }
591
+
592
+ return {
593
+ decision: created?.error ? 'create-failed' : 'new',
594
+ issue: created?.error
595
+ ? null
596
+ : { number: created?.number ?? null, url: created?.url ?? null },
597
+ routing,
598
+ labels,
599
+ title,
600
+ body: finalBody,
601
+ fingerprint,
602
+ dryRun: false,
603
+ errors,
604
+ };
605
+ }
@@ -20,9 +20,9 @@
20
20
  * **Head SHA is the discriminator.** A green on a *different* head SHA is a
21
21
  * fix at source — legal, and it retires the digest. A green on the *same*
22
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.
23
+ * delivery hard-stops at `agent::blocked` and a CI-gap intake filing
24
+ * (`file-ci-gap.js`, carrying the run link and failure signature the digest
25
+ * already holds) is required before it can proceed.
26
26
  *
27
27
  * **Fail closed on an unverifiable green.** A digest whose head SHA is
28
28
  * missing, or a current head SHA that `gh` could not resolve, leaves no
@@ -136,7 +136,7 @@ function ghRunLogTail({ runId, cwd, spawnFn, maxLines = 40 }) {
136
136
  * Resolve the failing check's run identity — the GitHub Actions run id AND
137
137
  * the run URL. Best-effort via `gh pr checks --json name,link`: the `link`
138
138
  * field carries the run URL whose trailing path segment is the run id. The
139
- * URL matters as much as the id, because the `meta::framework-gap` issue a
139
+ * URL matters as much as the id, because the intake issue a
140
140
  * rerun violation demands must carry a run **link**.
141
141
  *
142
142
  * @returns {{ runId: string|null, url: string|null }}
@@ -441,7 +441,8 @@ export function classifyGreenVerdict({ digest, headSha }) {
441
441
  }
442
442
 
443
443
  /**
444
- * The failure signature a `meta::framework-gap` issue must carry: the first
444
+ * The failure signature an intake filing must carry (see `file-ci-gap.js`):
445
+ * the first
445
446
  * distinctive line of the captured failed-job log.
446
447
  */
447
448
  function failureSignature(digest) {
@@ -480,9 +481,13 @@ export function formatRerunViolation({ digest, headSha, prNumber, reason }) {
480
481
  '',
481
482
  '1. Fix the root cause on `story-<id>` and push a new commit — the head SHA',
482
483
  ' moving is what clears the block.',
483
- '2. File a `meta::framework-gap` issue carrying the run link and failure',
484
- ' signature above when the root cause is outside this delivery, then',
485
- ' resume.',
484
+ '2. When the root cause is outside this delivery, file the routed intake',
485
+ ' issue — `node .agents/scripts/file-ci-gap.js --story <id> --verdict',
486
+ ' <pre-existing|capacity|unreproducible-tier> --owner',
487
+ ' <consumer|framework|platform> --evidence "<proof reading>"` — then',
488
+ ' resume. It carries the run link and signature above, routes the filing',
489
+ ' to whoever owns the fault, and updates the existing ticket when this',
490
+ ' signature has been seen before.',
486
491
  ].join('\n');
487
492
  }
488
493