mandrel 2.21.0 → 2.22.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 (38) hide show
  1. package/.agents/README.md +1 -1
  2. package/.agents/agents/story-worker.md +5 -0
  3. package/.agents/instructions.md +14 -17
  4. package/.agents/rules/git-conventions.md +1 -1
  5. package/.agents/rules/known-tooling-behavior.md +114 -0
  6. package/.agents/scripts/check-context-budget.js +134 -2
  7. package/.agents/scripts/lib/audit-suite/selector.js +275 -162
  8. package/.agents/scripts/lib/config/temp-paths.js +51 -7
  9. package/.agents/scripts/lib/feedback-loop/graduator-core.js +604 -57
  10. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +72 -21
  11. package/.agents/scripts/lib/label-constants.js +12 -1
  12. package/.agents/scripts/lib/observability/runtime-friction.js +13 -1
  13. package/.agents/scripts/lib/observability/signals-writer.js +133 -14
  14. package/.agents/scripts/lib/observability/source-classifier.js +131 -1
  15. package/.agents/scripts/lib/orchestration/code-review.js +12 -0
  16. package/.agents/scripts/lib/orchestration/complexity-gate.js +51 -46
  17. package/.agents/scripts/lib/orchestration/resolve-stories.js +17 -14
  18. package/.agents/scripts/lib/orchestration/retro-proposals.js +0 -0
  19. package/.agents/scripts/lib/orchestration/review-providers/degraded-gates.js +222 -0
  20. package/.agents/scripts/lib/orchestration/review-providers/findings-renderer.js +18 -3
  21. package/.agents/scripts/lib/orchestration/review-providers/native.js +82 -126
  22. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +10 -0
  23. package/.agents/scripts/lib/orchestration/review-providers/scoped-lint.js +300 -0
  24. package/.agents/scripts/lib/orchestration/run-epilogue.js +51 -1
  25. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +18 -8
  26. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-outcome.js +66 -0
  27. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +5 -1
  28. package/.agents/scripts/lib/orchestration/story-follow-ups.js +305 -10
  29. package/.agents/scripts/lib/story-body/story-body.js +248 -174
  30. package/.agents/scripts/resolve-stories.js +52 -33
  31. package/.agents/scripts/single-story-confirm-merge.js +5 -7
  32. package/.agents/workflows/helpers/deliver-digest.md +8 -6
  33. package/.agents/workflows/helpers/deliver-reference.md +15 -12
  34. package/.agents/workflows/helpers/deliver-story-reference.md +23 -21
  35. package/.agents/workflows/helpers/deliver-story.md +2 -2
  36. package/.agents/workflows/helpers/plan-reference.md +5 -4
  37. package/docs/CHANGELOG.md +20 -0
  38. package/package.json +1 -1
@@ -47,6 +47,8 @@
47
47
  import { spawn as defaultSpawn } from 'node:child_process';
48
48
  import { createHash } from 'node:crypto';
49
49
 
50
+ import { inNodeTestContext } from '../config/temp-paths.js';
51
+ import { LABEL_COLORS } from '../label-constants.js';
50
52
  import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
51
53
  import {
52
54
  structuredCommentMarker,
@@ -96,6 +98,91 @@ export const DEFAULT_MAX_FILINGS_PER_RUN = 20;
96
98
  */
97
99
  export const CROSS_REPO_DEFERRED_COMMENT_TYPE = 'cross-repo-deferred';
98
100
 
101
+ /**
102
+ * Explicit, greppable opt-back-in to live issue filing from a context the
103
+ * guard below refuses (Story #4837). Deliberately shaped like
104
+ * `MANDREL_TEST_ALLOW_REAL_TEMP` (`config/temp-paths.js`) rather than a new
105
+ * bespoke flag: same env-var idiom, same "an escape hatch must be visible in
106
+ * a grep" posture.
107
+ */
108
+ const ALLOW_LIVE_FILING_ENV = 'MANDREL_ALLOW_LIVE_ISSUE_FILING';
109
+
110
+ /**
111
+ * Skip reason recorded for every finding a refused filing context drops.
112
+ * Module-local: it reaches callers as data on the returned envelope, so
113
+ * exporting the constant would only add a symbol nothing imports.
114
+ */
115
+ const LIVE_FILING_BLOCKED_REASON = 'live-api-guard';
116
+
117
+ /** `NODE_ENV` values that declare this process is not a production run. */
118
+ const NON_PRODUCTION_NODE_ENVS = new Set(['test', 'development']);
119
+
120
+ /**
121
+ * Decide whether this process may let the graduator walk reach the live
122
+ * GitHub API (Story #4837).
123
+ *
124
+ * **Why this exists.** Issues #4833 and #4834 were created against the live
125
+ * `dsj1984/mandrel` tracker — from fixture findings anchored to `epic-101`
126
+ * and `epic-777` — by a development run of the filing path. Nothing in the
127
+ * walk distinguished "a real close is filing a real follow-up" from "someone
128
+ * is exercising this module", so the only thing standing between a test and
129
+ * the production tracker was the author remembering to stub `spawnImpl`.
130
+ *
131
+ * **The seam.** There are exactly two ways to be safe, and this returns
132
+ * `allowed` only for them:
133
+ *
134
+ * 1. `spawnImpl` was injected — the walk then spawns the caller's stub and
135
+ * no child process reaches `gh` at all. This is the seam
136
+ * `.agents/rules/test-seams.md` already mandates, so a well-behaved test
137
+ * is unaffected.
138
+ * 2. The process provably is **not** a test or development context — not a
139
+ * node:test run (per the single shared detector in
140
+ * `config/temp-paths.js#inNodeTestContext`) and not a run that declared
141
+ * itself non-production via `NODE_ENV`.
142
+ *
143
+ * **Fail closed.** Anything else refuses: a test context with the real
144
+ * `spawn` (the #4833/#4834 shape), and — critically — a context that cannot
145
+ * be *decided*, because an unreadable env is not evidence of production. The
146
+ * refusal is a skip, not a throw: observability must never fail a close.
147
+ *
148
+ * @param {object} opts
149
+ * @param {Function} [opts.spawnImpl]
150
+ * @param {NodeJS.ProcessEnv} [opts.env]
151
+ * @param {string[]} [opts.execArgv]
152
+ * @returns {{ allowed: boolean, reason: string|null }}
153
+ */
154
+ function resolveFilingContext({
155
+ spawnImpl,
156
+ env = process.env,
157
+ execArgv = process.execArgv,
158
+ } = {}) {
159
+ const refuse = { allowed: false, reason: LIVE_FILING_BLOCKED_REASON };
160
+ const allow = { allowed: true, reason: null };
161
+
162
+ // An injected seam cannot reach the live API by construction.
163
+ if (typeof spawnImpl === 'function') return allow;
164
+
165
+ // Undecidable context → refuse. Both inputs must be readable for the
166
+ // detector's answer to mean anything.
167
+ if (env === null || typeof env !== 'object' || !Array.isArray(execArgv)) {
168
+ return refuse;
169
+ }
170
+ try {
171
+ if (env[ALLOW_LIVE_FILING_ENV] === '1') return allow;
172
+ if (inNodeTestContext(env, execArgv)) return refuse;
173
+ // A declared non-production environment is the other half of "a test or
174
+ // development context". `lib/test-env.js` stamps `NODE_ENV=test` on the
175
+ // whole suite environment, and `development` is the universal marker for
176
+ // a hand-run session; nothing on the close path sets either.
177
+ return NON_PRODUCTION_NODE_ENVS.has(String(env.NODE_ENV ?? ''))
178
+ ? refuse
179
+ : allow;
180
+ } catch {
181
+ // An env we cannot even read is not evidence of production.
182
+ return refuse;
183
+ }
184
+ }
185
+
99
186
  /**
100
187
  * Compute a stable content fingerprint for a finding from its
101
188
  * `category|path|title` triple. Pure — the digest depends only on the
@@ -281,7 +368,7 @@ export async function probePathStatus({
281
368
  * inside an HTML comment, yet a query that carries the `<!--` / `-->`
282
369
  * delimiters never matches that indexed text (measured against this repo,
283
370
  * Story #4657). Stripping the delimiters and trimming yields the bare marker
284
- * text — `retro-proposal-followup: epic-1-<fp>` — which the index matches.
371
+ * text — `retro-proposal-followup: <fp>` — which the index matches.
285
372
  * The caller-facing marker is left untouched; normalization is the probe's
286
373
  * own concern.
287
374
  *
@@ -294,15 +381,38 @@ function normalizeMarkerQuery(marker) {
294
381
  }
295
382
 
296
383
  /**
297
- * Probe whether a follow-up issue carrying the given idempotency marker
298
- * already exists in the routed repo. Uses `gh search issues` so we hit
299
- * the body field directly, querying the delimiter-stripped marker text
300
- * (see {@link normalizeMarkerQuery}) the raw `<!-- … -->` form never
301
- * matches the index. Returns `true` when at least one match is present;
302
- * degrades to `false` on any spawn/parse error (better to risk a duplicate
303
- * than swallow the finding entirely).
384
+ * Normalize one `gh` issue row (from `search issues` or `issue list`) into
385
+ * the identity the update path needs. `state` is lowercased and defaults to
386
+ * the empty string deliberately NOT to `'open'`: an unknown state must
387
+ * never authorize editing somebody's issue (see
388
+ * {@link resolveFollowUpRecurrence}).
389
+ *
390
+ * @param {object} row
391
+ * @returns {{ number: number|null, state: string, url: string }}
304
392
  */
305
- export async function probeMarkerExists({
393
+ function toFollowUpRef(row) {
394
+ const number = Number(row?.number);
395
+ return {
396
+ number: Number.isInteger(number) && number > 0 ? number : null,
397
+ state: String(row?.state ?? '').toLowerCase(),
398
+ url: typeof row?.url === 'string' ? row.url : '',
399
+ };
400
+ }
401
+
402
+ /**
403
+ * Search the routed repo for a follow-up carrying `marker`, resolving the
404
+ * matched issue's identity rather than a bare yes/no. Uses `gh search issues`
405
+ * so we hit the body field directly, querying the delimiter-stripped marker
406
+ * text (see {@link normalizeMarkerQuery}) — the raw `<!-- … -->` form never
407
+ * matches the index.
408
+ *
409
+ * Returns `null` when nothing matched OR when the probe could not decide
410
+ * (spawn/parse error): the deliberate degrade-toward-filing posture, better
411
+ * to risk a duplicate than swallow the finding entirely.
412
+ *
413
+ * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
414
+ */
415
+ async function searchFollowUpByMarker({
306
416
  marker,
307
417
  owner,
308
418
  repo,
@@ -318,40 +428,60 @@ export async function probeMarkerExists({
318
428
  '--repo',
319
429
  `${owner}/${repo}`,
320
430
  '--json',
321
- 'number',
431
+ 'number,state,url',
322
432
  '--limit',
323
433
  '1',
324
434
  ];
325
435
  const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
326
436
  if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
327
- return false;
437
+ return null;
328
438
  }
329
439
  try {
330
440
  const parsed = JSON.parse(res.stdout || '[]');
331
- return Array.isArray(parsed) && parsed.length > 0;
441
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
442
+ return toFollowUpRef(parsed[0]);
332
443
  } catch {
333
- return false;
444
+ return null;
334
445
  }
335
446
  }
336
447
 
337
448
  /**
338
- * Strongly-consistent confirmation that a follow-up carrying `marker`
339
- * already exists, run ONLY on the would-file path as the last gate before
340
- * creating. `gh search issues` reads an eventually-consistent index whose
341
- * catch-up latency (measured under 20s against this repo, Story #4657) is
342
- * exactly wide enough to miss a byte-identical duplicate filed seconds
343
- * earlier in the same rollup. A label-scoped `gh issue list … --state all`
344
- * is strongly consistent, so it closes that window. The list is narrowed by
345
- * the follow-up's own labels (supplied by the same `spec.buildFollowUp` that
346
- * writes the marker, so the two agree by construction) to keep the read
347
- * bounded, and the marker is matched as a substring of each returned body.
348
- *
349
- * Degrades to `false` (i.e. proceed to file) on any spawn/parse error — the
449
+ * Probe whether a follow-up issue carrying the given idempotency marker
450
+ * already exists in the routed repo. Thin boolean façade over
451
+ * {@link searchFollowUpByMarker} kept as the module's exported probe seam
452
+ * so callers that only need the yes/no do not have to know about the issue
453
+ * identity the recurrence path resolves.
454
+ *
455
+ * @returns {Promise<boolean>}
456
+ */
457
+ export async function probeMarkerExists(opts) {
458
+ return (await searchFollowUpByMarker(opts)) !== null;
459
+ }
460
+
461
+ /**
462
+ * Strongly-consistent lookup of an already-filed follow-up, run ONLY on the
463
+ * would-file path as the last gate before creating. `gh search issues` reads
464
+ * an eventually-consistent index whose catch-up latency (measured under 20s
465
+ * against this repo, Story #4657) is exactly wide enough to miss a
466
+ * byte-identical duplicate filed seconds earlier in the same rollup. A
467
+ * label-scoped `gh issue list … --state all` is strongly consistent, so it
468
+ * closes that window. The list is narrowed by the follow-up's own labels
469
+ * (supplied by the same `spec.buildFollowUp` that writes the marker, so the
470
+ * two agree by construction) to keep the read bounded.
471
+ *
472
+ * `markers` is a LIST of body substrings, any one of which identifies a prior
473
+ * filing (Story #4837). One finding can have been filed under more than one
474
+ * marker shape — the retro graduator's pre-cutover marker embedded the run
475
+ * anchor, so the same finding sits behind `epic-<anchor>-<fp>` on already-
476
+ * filed issues and behind the anchor-free `<fp>` on new ones. Matching any of
477
+ * them is what stops a marker-format change from re-filing the whole backlog.
478
+ *
479
+ * Returns `null` when nothing matched, or on any spawn/parse error — the
350
480
  * deliberate degrade-toward-filing posture: an undecidable probe risks a
351
481
  * duplicate rather than swallowing the finding.
352
482
  *
353
483
  * @param {object} opts
354
- * @param {string} opts.markerthe content-hash marker embedded in the body.
484
+ * @param {string[]} opts.markersbody substrings identifying a prior filing.
355
485
  * @param {string} opts.owner
356
486
  * @param {string} opts.repo
357
487
  * @param {string[]} [opts.labels] — the follow-up's labels; scopes the list.
@@ -359,10 +489,10 @@ export async function probeMarkerExists({
359
489
  * @param {Function} [opts.spawnImpl]
360
490
  * @param {string} [opts.cwd]
361
491
  * @param {number} [opts.timeoutMs]
362
- * @returns {Promise<boolean>}
492
+ * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
363
493
  */
364
- async function confirmMarkerFiled({
365
- marker,
494
+ async function findExistingFollowUp({
495
+ markers,
366
496
  owner,
367
497
  repo,
368
498
  labels,
@@ -371,6 +501,10 @@ async function confirmMarkerFiled({
371
501
  cwd,
372
502
  timeoutMs,
373
503
  }) {
504
+ const tokens = (Array.isArray(markers) ? markers : []).filter(
505
+ (m) => typeof m === 'string' && m.length > 0,
506
+ );
507
+ if (tokens.length === 0) return null;
374
508
  const args = [
375
509
  'issue',
376
510
  'list',
@@ -379,24 +513,261 @@ async function confirmMarkerFiled({
379
513
  '--state',
380
514
  'all',
381
515
  '--json',
382
- 'number,body',
516
+ 'number,body,state,url',
383
517
  ];
384
518
  for (const label of Array.isArray(labels) ? labels : []) {
385
519
  args.push('--label', label);
386
520
  }
387
521
  const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
388
522
  if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
389
- return false;
523
+ return null;
390
524
  }
525
+ let parsed;
391
526
  try {
392
- const parsed = JSON.parse(res.stdout || '[]');
393
- if (!Array.isArray(parsed)) return false;
394
- return parsed.some(
395
- (issue) => typeof issue?.body === 'string' && issue.body.includes(marker),
396
- );
527
+ parsed = JSON.parse(res.stdout || '[]');
397
528
  } catch {
398
- return false;
529
+ return null;
399
530
  }
531
+ if (!Array.isArray(parsed)) return null;
532
+ const matches = parsed.filter(
533
+ (issue) =>
534
+ typeof issue?.body === 'string' &&
535
+ tokens.some((token) => issue.body.includes(token)),
536
+ );
537
+ if (matches.length === 0) return null;
538
+ // Prefer an OPEN match: a recurrence updates the live issue, while a closed
539
+ // one is a decided follow-up. When several match, the open one is the one a
540
+ // human is still looking at.
541
+ const open = matches.find(
542
+ (issue) => String(issue?.state ?? '').toLowerCase() === 'open',
543
+ );
544
+ return toFollowUpRef(open ?? matches[0]);
545
+ }
546
+
547
+ /**
548
+ * Refresh an existing follow-up's body in place — the recurrence path
549
+ * (Story #4837). A finding that recurs is the SAME finding: the loop's job is
550
+ * to keep one issue current, not to mint issue #N+1 whose only new
551
+ * information is that the count went up.
552
+ *
553
+ * Body-only: labels, title, assignees and state are left exactly as a human
554
+ * may have curated them.
555
+ *
556
+ * @returns {Promise<{ url: string|null, error: string|null }>}
557
+ */
558
+ async function updateFollowUpIssue({
559
+ owner,
560
+ repo,
561
+ number,
562
+ body,
563
+ ghPath,
564
+ spawnImpl,
565
+ cwd,
566
+ timeoutMs,
567
+ }) {
568
+ const res = await runChild({
569
+ cmd: ghPath,
570
+ args: [
571
+ 'issue',
572
+ 'edit',
573
+ String(number),
574
+ '--repo',
575
+ `${owner}/${repo}`,
576
+ '--body',
577
+ body,
578
+ ],
579
+ spawnImpl,
580
+ cwd,
581
+ timeoutMs,
582
+ });
583
+ if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
584
+ return {
585
+ url: null,
586
+ error: res.spawnError
587
+ ? `gh issue edit spawn failed: ${res.spawnError.message}`
588
+ : `gh issue edit exited ${res.code}: ${(res.stderr || '').trim()}`,
589
+ };
590
+ }
591
+ return { url: (res.stdout || '').trim(), error: null };
592
+ }
593
+
594
+ /** Prefix of the per-category friction axis the feedback loop mints. */
595
+ const FRICTION_LABEL_PREFIX = 'friction::';
596
+
597
+ /**
598
+ * Resolve the color + description for a label the feedback loop is about to
599
+ * mint. Only two axes reach this path — `meta::*` (routing) and
600
+ * `friction::<category>` (the telemetry bucket) — so the mapping is a
601
+ * two-branch lookup rather than a registry.
602
+ *
603
+ * @param {string} name
604
+ * @returns {{ color: string, description: string }}
605
+ */
606
+ function describeMintedLabel(name) {
607
+ if (name.startsWith(FRICTION_LABEL_PREFIX)) {
608
+ return {
609
+ color: LABEL_COLORS.FRICTION,
610
+ description: `Recurring friction category "${name.slice(FRICTION_LABEL_PREFIX.length)}" (minted by the feedback loop)`,
611
+ };
612
+ }
613
+ return {
614
+ color: LABEL_COLORS.META,
615
+ description: 'Feedback-loop routing axis (minted by the feedback loop)',
616
+ };
617
+ }
618
+
619
+ /**
620
+ * Read the routed repo's live label names once per repo, memoized in
621
+ * `labelCache`. Returns `null` when the set could not be established —
622
+ * "verification unavailable", which is deliberately NOT the same as "the repo
623
+ * has no labels": an unreadable set must not be read as proof that every
624
+ * label is missing.
625
+ *
626
+ * @returns {Promise<{ known: Set<string>|null, error: string|null }>}
627
+ */
628
+ async function readLiveLabelNames({
629
+ owner,
630
+ repo,
631
+ labelCache,
632
+ ghPath,
633
+ spawnImpl,
634
+ cwd,
635
+ timeoutMs,
636
+ }) {
637
+ const key = `${owner}/${repo}`;
638
+ const cached = labelCache?.get(key);
639
+ if (cached) return { known: cached, error: null };
640
+ const res = await runChild({
641
+ cmd: ghPath,
642
+ args: ['label', 'list', '--repo', key, '--limit', '500', '--json', 'name'],
643
+ spawnImpl,
644
+ cwd,
645
+ timeoutMs,
646
+ });
647
+ if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
648
+ return {
649
+ known: null,
650
+ error: `gh label list ${key} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
651
+ };
652
+ }
653
+ let parsed;
654
+ try {
655
+ parsed = JSON.parse(res.stdout || '[]');
656
+ } catch {
657
+ return {
658
+ known: null,
659
+ error: `gh label list ${key} returned unparseable JSON`,
660
+ };
661
+ }
662
+ if (!Array.isArray(parsed)) {
663
+ return { known: null, error: `gh label list ${key} returned a non-array` };
664
+ }
665
+ const known = new Set();
666
+ for (const row of parsed) {
667
+ if (row && typeof row.name === 'string') known.add(row.name);
668
+ }
669
+ labelCache?.set(key, known);
670
+ return { known, error: null };
671
+ }
672
+
673
+ /**
674
+ * Mint any of `labels` the routed repo does not already carry, so
675
+ * `gh issue create --label …` can attach them (Story #4828).
676
+ *
677
+ * **Why this exists.** `gh issue create` resolves every `--label` name against
678
+ * the repo before it creates anything, and fails the whole call when one is
679
+ * absent. The feedback loop mints two axes that no bootstrap can enumerate
680
+ * ahead of time — `meta::consumer-improvement` is absent from `LABEL_TAXONOMY`
681
+ * outright, and `friction::<category>` names come from live telemetry — so on
682
+ * a repo that never had them, *every* filing failed. The failure landed in the
683
+ * graduator's `errors[]`, which the run epilogue did not surface, so a
684
+ * hard-failing feedback loop rendered as `filed: 0`: indistinguishable from
685
+ * "nothing was actionable".
686
+ *
687
+ * Degrades rather than blocks: when the live set cannot be read, this returns
688
+ * no `missing` entries so the caller still attempts the filing (the old
689
+ * behaviour) instead of refusing on an unproven premise.
690
+ *
691
+ * @param {object} opts
692
+ * @param {string} opts.owner
693
+ * @param {string} opts.repo
694
+ * @param {string[]} opts.labels
695
+ * @param {Map<string, Set<string>>} [opts.labelCache] — per-repo live-set memo.
696
+ * @param {string} [opts.ghPath]
697
+ * @param {Function} [opts.spawnImpl]
698
+ * @param {string} [opts.cwd]
699
+ * @param {number} [opts.timeoutMs]
700
+ * @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
701
+ */
702
+ async function ensureIssueLabels({
703
+ owner,
704
+ repo,
705
+ labels,
706
+ labelCache,
707
+ ghPath = 'gh',
708
+ spawnImpl,
709
+ cwd,
710
+ timeoutMs,
711
+ }) {
712
+ const wanted = (Array.isArray(labels) ? labels : []).filter(
713
+ (name) => typeof name === 'string' && name.trim().length > 0,
714
+ );
715
+ if (wanted.length === 0) return { created: [], missing: [], errors: [] };
716
+
717
+ const { known, error } = await readLiveLabelNames({
718
+ owner,
719
+ repo,
720
+ labelCache,
721
+ ghPath,
722
+ spawnImpl,
723
+ cwd,
724
+ timeoutMs,
725
+ });
726
+ // Verification unavailable — attempt the filing anyway rather than refuse.
727
+ if (!known) return { created: [], missing: [], errors: error ? [error] : [] };
728
+
729
+ const created = [];
730
+ const missing = [];
731
+ const errors = [];
732
+ for (const name of wanted) {
733
+ if (known.has(name)) continue;
734
+ const { color, description } = describeMintedLabel(name);
735
+ const res = await runChild({
736
+ cmd: ghPath,
737
+ args: [
738
+ 'label',
739
+ 'create',
740
+ name,
741
+ '--repo',
742
+ `${owner}/${repo}`,
743
+ '--color',
744
+ color.replace(/^#/, ''),
745
+ '--description',
746
+ description,
747
+ ],
748
+ spawnImpl,
749
+ cwd,
750
+ timeoutMs,
751
+ });
752
+ const failed =
753
+ Boolean(res.spawnError) ||
754
+ (typeof res.code === 'number' && res.code !== 0);
755
+ // A concurrent roll-up may have minted it between the list and the create;
756
+ // that is the idempotent outcome, not a failure.
757
+ const raced = /label\b[\s\S]*?already exists/i.test(
758
+ `${res.stderr ?? ''}${res.spawnError?.message ?? ''}`,
759
+ );
760
+ if (!failed || raced) {
761
+ known.add(name);
762
+ if (!raced) created.push(name);
763
+ continue;
764
+ }
765
+ missing.push(name);
766
+ errors.push(
767
+ `gh label create "${name}" in ${owner}/${repo} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
768
+ );
769
+ }
770
+ return { created, missing, errors };
400
771
  }
401
772
 
402
773
  /**
@@ -510,8 +881,12 @@ async function loadGraduateFindings({ epicId, provider, spec }) {
510
881
  * content-hash marker AND the legacy `(epicId, parse-index)` marker so
511
882
  * findings filed before the fingerprint cutover are not re-filed. The
512
883
  * content-hash marker is passed in precomputed so a caller can consult an
513
- * in-process memo before spending a spawn. Returns the `alreadyFiled`
514
- * decision.
884
+ * in-process memo before spending a spawn.
885
+ *
886
+ * Resolves `{ alreadyFiled, existing }` — `existing` carries the matched
887
+ * issue's `{ number, state, url }` when the search index surfaced it, so a
888
+ * recurrence can update that issue rather than only knowing that one exists.
889
+ * `existing` is `null` on the not-found path.
515
890
  */
516
891
  async function resolveAlreadyFiled({
517
892
  finding,
@@ -525,7 +900,7 @@ async function resolveAlreadyFiled({
525
900
  spec,
526
901
  }) {
527
902
  const probe = (marker) =>
528
- probeMarkerExists({
903
+ searchFollowUpByMarker({
529
904
  marker,
530
905
  owner: routedRepo.owner,
531
906
  repo: routedRepo.repo,
@@ -535,18 +910,86 @@ async function resolveAlreadyFiled({
535
910
  timeoutMs,
536
911
  });
537
912
 
538
- if (await probe(contentMarker)) {
539
- return { alreadyFiled: true };
540
- }
913
+ const hit = await probe(contentMarker);
914
+ if (hit) return { alreadyFiled: true, existing: hit };
541
915
  // Legacy recognition — a pre-cutover follow-up carries the ordinal
542
916
  // marker, not the content hash. Skip re-filing when it is present.
543
917
  if (typeof spec.buildLegacyMarker === 'function') {
544
918
  const legacyMarker = spec.buildLegacyMarker(epicId, finding.index);
545
- if (legacyMarker && (await probe(legacyMarker))) {
546
- return { alreadyFiled: true };
919
+ if (legacyMarker) {
920
+ const legacyHit = await probe(legacyMarker);
921
+ if (legacyHit) return { alreadyFiled: true, existing: legacyHit };
547
922
  }
548
923
  }
549
- return { alreadyFiled: false };
924
+ return { alreadyFiled: false, existing: null };
925
+ }
926
+
927
+ /**
928
+ * Resolve what a recurrence of an already-identified follow-up should do
929
+ * (Story #4837), and fold the outcome into the running envelope.
930
+ *
931
+ * An **open** issue with a resolvable number is refreshed in place and
932
+ * recorded on `envelope.filed` with `action: 'updated'` — same array as a
933
+ * creation, because both are live writes this run performed and the roll-up
934
+ * that reports "filed" must count them alike.
935
+ *
936
+ * Anything else records `already-filed` and touches nothing: a closed
937
+ * follow-up is a decided one (reopening it would relitigate a human's call),
938
+ * and an unresolvable number or state is not licence to edit an issue we
939
+ * cannot identify.
940
+ *
941
+ * @returns {Promise<boolean>} `true` when the recurrence was handled here.
942
+ */
943
+ async function resolveFollowUpRecurrence({
944
+ existing,
945
+ body,
946
+ finding,
947
+ source,
948
+ routedRepo,
949
+ envelope,
950
+ decorate,
951
+ skip,
952
+ ghPath,
953
+ spawnImpl,
954
+ cwd,
955
+ timeoutMs,
956
+ }) {
957
+ if (existing.state !== 'open' || existing.number === null) {
958
+ skip('already-filed');
959
+ return true;
960
+ }
961
+ const updated = await updateFollowUpIssue({
962
+ owner: routedRepo.owner,
963
+ repo: routedRepo.repo,
964
+ number: existing.number,
965
+ body,
966
+ ghPath,
967
+ spawnImpl,
968
+ cwd,
969
+ timeoutMs,
970
+ });
971
+ if (updated.error) {
972
+ envelope.errors.push(
973
+ `finding ${finding.index} (${finding.path}): ${updated.error}`,
974
+ );
975
+ return true;
976
+ }
977
+ envelope.filed.push(
978
+ decorate(
979
+ {
980
+ index: finding.index,
981
+ action: 'updated',
982
+ issueNumber: existing.number,
983
+ severity: finding.severity,
984
+ path: finding.path,
985
+ source,
986
+ repo: `${routedRepo.owner}/${routedRepo.repo}`,
987
+ url: updated.url || existing.url || null,
988
+ },
989
+ finding,
990
+ ),
991
+ );
992
+ return true;
550
993
  }
551
994
 
552
995
  /**
@@ -572,6 +1015,7 @@ async function processGraduateFinding({
572
1015
  maxFilingsPerRun,
573
1016
  crossRepoDeferred,
574
1017
  filedMarkers,
1018
+ labelCache,
575
1019
  logger,
576
1020
  spec,
577
1021
  }) {
@@ -629,7 +1073,7 @@ async function processGraduateFinding({
629
1073
  // race the eventually-consistent search index cannot.
630
1074
  if (filedMarkers?.has(contentMarker)) return skip('already-filed');
631
1075
 
632
- const { alreadyFiled } = await resolveAlreadyFiled({
1076
+ const { alreadyFiled, existing } = await resolveAlreadyFiled({
633
1077
  finding,
634
1078
  epicId,
635
1079
  routedRepo,
@@ -640,16 +1084,11 @@ async function processGraduateFinding({
640
1084
  timeoutMs,
641
1085
  spec,
642
1086
  });
643
- if (alreadyFiled) return skip('already-filed');
644
-
645
- // Per-run filing cap — count only actual filings (already-filed and
646
- // skipped findings do not consume the budget). The excess is surfaced so
647
- // a re-run picks it up next time.
648
- if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
649
1087
 
650
1088
  // Resolve the follow-up (title/body/labels) BEFORE the dedup decision so
651
1089
  // the strong read can scope its `gh issue list` by the very labels this
652
- // filing would carry (they agree with the marker by construction).
1090
+ // filing would carry (they agree with the marker by construction), and so
1091
+ // the recurrence path has the refreshed body to write.
653
1092
  const { title, body, labels } = spec.buildFollowUp({
654
1093
  finding,
655
1094
  source,
@@ -657,14 +1096,62 @@ async function processGraduateFinding({
657
1096
  idMarker: contentMarker,
658
1097
  });
659
1098
 
1099
+ if (alreadyFiled) {
1100
+ filedMarkers?.add(contentMarker);
1101
+ await resolveFollowUpRecurrence({
1102
+ existing,
1103
+ body,
1104
+ finding,
1105
+ source,
1106
+ routedRepo,
1107
+ envelope,
1108
+ decorate,
1109
+ skip,
1110
+ ghPath,
1111
+ spawnImpl,
1112
+ cwd,
1113
+ timeoutMs,
1114
+ });
1115
+ return;
1116
+ }
1117
+
1118
+ // Per-run filing cap — bounds the live writes this run performs. Checked
1119
+ // only on the would-file path: an `already-filed` finding resolved above
1120
+ // never reaches here. The excess is surfaced so a re-run picks it up.
1121
+ if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
1122
+
1123
+ // Mint any routing label the repo does not carry yet (Story #4828). This
1124
+ // runs BEFORE the strong read as well as before the create: `gh issue list
1125
+ // --label <absent>` exits 0 with `[]`, so an absent label silently degrades
1126
+ // the dedup confirm too, not just the filing.
1127
+ const ensured = await ensureIssueLabels({
1128
+ owner: routedRepo.owner,
1129
+ repo: routedRepo.repo,
1130
+ labels,
1131
+ labelCache,
1132
+ ghPath,
1133
+ spawnImpl,
1134
+ cwd,
1135
+ timeoutMs,
1136
+ });
1137
+ envelope.errors.push(...ensured.errors);
1138
+ if (ensured.missing.length > 0) {
1139
+ // `gh issue create` would reject the whole call on the absent name; say
1140
+ // which label blocked it rather than replaying an opaque CLI failure.
1141
+ return skip('label-ensure-failed');
1142
+ }
1143
+
660
1144
  // Strong read (would-file path only, Story #4657): the search probe reads
661
1145
  // an eventually-consistent index that can miss a byte-identical duplicate
662
1146
  // filed seconds earlier. Confirm against a strongly-consistent,
663
1147
  // label-scoped `gh issue list` before creating. Skipped entirely on the
664
1148
  // already-filed path above, so it never fires when the search probe
665
1149
  // already matched.
666
- const confirmed = await confirmMarkerFiled({
667
- marker: contentMarker,
1150
+ const confirmed = await findExistingFollowUp({
1151
+ markers:
1152
+ typeof spec.buildMatchTokens === 'function'
1153
+ ? spec.buildMatchTokens({ epicId, finding, contentMarker })
1154
+ : [contentMarker],
668
1155
  owner: routedRepo.owner,
669
1156
  repo: routedRepo.repo,
670
1157
  labels,
@@ -675,7 +1162,21 @@ async function processGraduateFinding({
675
1162
  });
676
1163
  if (confirmed) {
677
1164
  filedMarkers?.add(contentMarker);
678
- return skip('already-filed');
1165
+ await resolveFollowUpRecurrence({
1166
+ existing: confirmed,
1167
+ body,
1168
+ finding,
1169
+ source,
1170
+ routedRepo,
1171
+ envelope,
1172
+ decorate,
1173
+ skip,
1174
+ ghPath,
1175
+ spawnImpl,
1176
+ cwd,
1177
+ timeoutMs,
1178
+ });
1179
+ return;
679
1180
  }
680
1181
 
681
1182
  const created = await createFollowUpIssue({
@@ -700,6 +1201,7 @@ async function processGraduateFinding({
700
1201
  decorate(
701
1202
  {
702
1203
  index: finding.index,
1204
+ action: 'created',
703
1205
  severity: finding.severity,
704
1206
  path: finding.path,
705
1207
  source,
@@ -780,6 +1282,11 @@ async function persistCrossRepoDeferred({
780
1282
  * marker embedded in (and searched for in) follow-up bodies.
781
1283
  * - `buildLegacyMarker(epicId, index)` — the pre-cutover ordinal marker,
782
1284
  * probed for idempotency so legacy filings are not duplicated.
1285
+ * - `buildMatchTokens({ epicId, finding, contentMarker })` — optional; the
1286
+ * body substrings the strong read accepts as proof of a prior filing.
1287
+ * Defaults to `[contentMarker]`. A graduator whose marker format changed
1288
+ * returns the old shape here too, so the cutover recognizes the backlog
1289
+ * it already filed instead of duplicating it.
783
1290
  * - `buildFollowUp({ finding, source, epicId, idMarker })` — returns
784
1291
  * `{ title, body, labels }` for the issue to file.
785
1292
  * - `buildCrossRepoLog({ finding, routedRepo, source })` — returns the
@@ -811,9 +1318,18 @@ async function persistCrossRepoDeferred({
811
1318
  * calls in one logical invocation (e.g. the retro graduator's two source
812
1319
  * buckets) so a marker filed in one call short-circuits a repeat in the
813
1320
  * next without a spawn. Defaults to a fresh per-call Set.
1321
+ * @param {Map<string, Set<string>>} [opts.labelCache] — per-repo memo of the
1322
+ * live label set, so the just-in-time label mint (Story #4828) costs one
1323
+ * `gh label list` per routed repo rather than one per finding. Share it
1324
+ * across the calls of one logical invocation, like `filedMarkers`.
1325
+ * @param {NodeJS.ProcessEnv} [opts.env] — injectable for the blast-radius
1326
+ * guard's context decision; defaults to `process.env`.
1327
+ * @param {string[]} [opts.execArgv] — ditto; defaults to `process.execArgv`.
814
1328
  * @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
815
1329
  * @param {object} opts.spec — the per-graduator behaviour bundle
816
1330
  * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
1331
+ * Each `filed` record carries `action: 'created'|'updated'` — both are live
1332
+ * writes this run performed, so a roll-up counting "filed" counts both.
817
1333
  */
818
1334
  export async function graduate({
819
1335
  epicId,
@@ -830,6 +1346,9 @@ export async function graduate({
830
1346
  maxFilingsPerRun = DEFAULT_MAX_FILINGS_PER_RUN,
831
1347
  findings: preParsedFindings,
832
1348
  filedMarkers = new Set(),
1349
+ labelCache = new Map(),
1350
+ env,
1351
+ execArgv,
833
1352
  logger,
834
1353
  spec,
835
1354
  }) {
@@ -859,6 +1378,33 @@ export async function graduate({
859
1378
  findings = loaded.findings;
860
1379
  }
861
1380
 
1381
+ // Blast-radius guard (Story #4837): decided ONCE per walk, before the
1382
+ // first spawn, so a refused context costs no child process at all.
1383
+ const filing = resolveFilingContext({
1384
+ spawnImpl,
1385
+ ...(env === undefined ? {} : { env }),
1386
+ ...(execArgv === undefined ? {} : { execArgv }),
1387
+ });
1388
+ if (!filing.allowed) {
1389
+ logger?.warn?.(
1390
+ `[${spec.fnName}] refusing to reach the live GitHub API: no injected spawn seam in a test or undecidable context. Set ${ALLOW_LIVE_FILING_ENV}=1 to override deliberately.`,
1391
+ );
1392
+ for (const finding of findings) {
1393
+ envelope.skipped.push(
1394
+ decorate(
1395
+ {
1396
+ index: finding.index,
1397
+ reason: filing.reason,
1398
+ path: finding.path,
1399
+ severity: finding.severity,
1400
+ },
1401
+ finding,
1402
+ ),
1403
+ );
1404
+ }
1405
+ return envelope;
1406
+ }
1407
+
862
1408
  const crossRepoDeferred = [];
863
1409
  for (const finding of findings) {
864
1410
  await processGraduateFinding({
@@ -877,6 +1423,7 @@ export async function graduate({
877
1423
  maxFilingsPerRun,
878
1424
  crossRepoDeferred,
879
1425
  filedMarkers,
1426
+ labelCache,
880
1427
  logger,
881
1428
  spec,
882
1429
  });