mandrel 2.65.0 → 2.66.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 (56) hide show
  1. package/.agents/agents/acceptance-critic.md +5 -5
  2. package/.agents/agents/auditor.md +17 -18
  3. package/.agents/agents/plan-critic.md +5 -5
  4. package/.agents/agents/story-worker.md +5 -5
  5. package/.agents/docs/execution-reference.md +27 -5
  6. package/.agents/instructions.md +10 -12
  7. package/.agents/rules/ci-remediation.md +3 -3
  8. package/.agents/rules/gherkin-standards.md +3 -2
  9. package/.agents/rules/git-conventions-reference.md +12 -3
  10. package/.agents/rules/git-conventions.md +9 -7
  11. package/.agents/rules/testing-standards.md +8 -7
  12. package/.agents/runtime-deps.json +1 -1
  13. package/.agents/scripts/bootstrap.js +94 -89
  14. package/.agents/scripts/lib/baselines/duplication-scanner.js +17 -7
  15. package/.agents/scripts/lib/bootstrap/project-bootstrap.js +78 -78
  16. package/.agents/scripts/lib/cli/standard-args.js +60 -76
  17. package/.agents/scripts/lib/cli-args.js +26 -0
  18. package/.agents/scripts/lib/config/gates/shared.js +3 -3
  19. package/.agents/scripts/lib/feedback-loop/graduate-steps.js +205 -0
  20. package/.agents/scripts/lib/feedback-loop/graduator-core.js +47 -782
  21. package/.agents/scripts/lib/feedback-loop/graduator-gh.js +449 -0
  22. package/.agents/scripts/lib/observability/close-telemetry.js +330 -0
  23. package/.agents/scripts/lib/observability/runtime-friction.js +2 -0
  24. package/.agents/scripts/lib/observability/signal-validator.js +17 -5
  25. package/.agents/scripts/lib/orchestration/code-review.js +22 -0
  26. package/.agents/scripts/lib/orchestration/plan-metrics.js +76 -63
  27. package/.agents/scripts/lib/orchestration/review-providers/review-provider-factory.js +23 -0
  28. package/.agents/scripts/lib/orchestration/run-epilogue.js +6 -0
  29. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +2 -0
  30. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +349 -263
  31. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +21 -7
  32. package/.agents/scripts/lib/orchestration/single-story-close/phases/review-override.js +4 -0
  33. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +327 -314
  34. package/.agents/scripts/lib/orchestration/ticket-validator.js +19 -36
  35. package/.agents/scripts/lib/signals/detectors/common.js +63 -51
  36. package/.agents/scripts/lib/transpile.js +28 -3
  37. package/.agents/scripts/single-story-close.js +10 -2
  38. package/.agents/scripts/single-story-confirm-merge.js +267 -238
  39. package/.agents/skills/core/idea-refinement/SKILL.md +6 -6
  40. package/.agents/skills/stack/qa/qa-harness/SKILL.md +1 -2
  41. package/.agents/workflows/audit-architecture.md +5 -4
  42. package/.agents/workflows/audit-documentation.md +5 -5
  43. package/.agents/workflows/audit-performance.md +10 -10
  44. package/.agents/workflows/helpers/acceptance-self-eval.md +8 -8
  45. package/.agents/workflows/helpers/audit-lens-core.md +30 -57
  46. package/.agents/workflows/helpers/deliver-digest.md +2 -2
  47. package/.agents/workflows/helpers/deliver-reference.md +3 -1
  48. package/.agents/workflows/helpers/deliver-story.md +6 -1
  49. package/.agents/workflows/helpers/parallel-tooling.md +16 -18
  50. package/.agents/workflows/mandrel-deliver.md +1 -1
  51. package/.agents/workflows/mandrel-plan.md +6 -5
  52. package/docs/CHANGELOG.md +26 -0
  53. package/lib/cli/guarded-sync.js +87 -0
  54. package/lib/cli/sync-agents.js +9 -92
  55. package/lib/cli/sync-commands.js +9 -101
  56. package/package.json +2 -2
@@ -5,19 +5,38 @@
5
5
  * (content-hash markers survive sibling reordering).
6
6
  */
7
7
 
8
- import { spawn as defaultSpawn } from 'node:child_process';
9
8
  import { createHash } from 'node:crypto';
10
9
 
11
10
  import { inNodeTestContext } from '../config/temp-paths.js';
12
- import { routeOwnership } from '../github/framework-repo.js';
13
- import { LABEL_COLORS } from '../label-constants.js';
14
11
  import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
15
12
  import { upsertStructuredComment } from '../orchestration/ticketing.js';
16
-
17
- const DEFAULT_RUN_CHILD_TIMEOUT_MS = 30000;
13
+ import {
14
+ dedupFinding,
15
+ fileFinding,
16
+ probeFindingPath,
17
+ recordRecurrence,
18
+ routeFinding,
19
+ } from './graduate-steps.js';
20
+ import {
21
+ DEFAULT_RUN_CHILD_TIMEOUT_MS,
22
+ searchFollowUpByMarker,
23
+ } from './graduator-gh.js';
24
+
25
+ export {
26
+ createFollowUpIssue,
27
+ ensureIssueLabels,
28
+ probePathStatus,
29
+ runChild,
30
+ updateFollowUpIssue,
31
+ } from './graduator-gh.js';
18
32
 
19
33
  export const DEFAULT_MAX_FILINGS_PER_RUN = 20;
20
34
 
35
+ /** @returns {Promise<boolean>} */
36
+ export async function probeMarkerExists(opts) {
37
+ return (await searchFollowUpByMarker(opts)) !== null;
38
+ }
39
+
21
40
  /** Registered in `STRUCTURED_COMMENT_TYPES`. */
22
41
  const CROSS_REPO_DEFERRED_COMMENT_TYPE = 'cross-repo-deferred';
23
42
 
@@ -78,93 +97,6 @@ export function contentFingerprint({ category, path, title } = {}) {
78
97
  return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
79
98
  }
80
99
 
81
- /**
82
- * The feedback loop's single spawn helper. Never throws: spawn errors land in
83
- * `spawnError`; an overrun is SIGKILL'd and resolves `timedOut: true`.
84
- * `timeoutMs` of `0`/`Infinity` disables the watchdog.
85
- *
86
- * @param {object} opts
87
- * @param {string} opts.cmd
88
- * @param {string[]} opts.args
89
- * @param {Function} [opts.spawnImpl]
90
- * @param {string} [opts.cwd]
91
- * @param {number} [opts.timeoutMs]
92
- * @returns {Promise<{ code: number|null, stdout: string, stderr: string, spawnError: Error|null, timedOut: boolean }>}
93
- */
94
- export function runChild({
95
- cmd,
96
- args,
97
- spawnImpl = defaultSpawn,
98
- cwd,
99
- timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
100
- }) {
101
- return new Promise((resolve) => {
102
- let child;
103
- try {
104
- child = spawnImpl(cmd, args, {
105
- stdio: ['ignore', 'pipe', 'pipe'],
106
- cwd,
107
- });
108
- } catch (err) {
109
- resolve({
110
- code: null,
111
- stdout: '',
112
- stderr: '',
113
- spawnError: err,
114
- timedOut: false,
115
- });
116
- return;
117
- }
118
- let stdout = '';
119
- let stderr = '';
120
- let spawnError = null;
121
- let settled = false;
122
- let timer = null;
123
- const finish = (result) => {
124
- if (settled) return;
125
- settled = true;
126
- if (timer) clearTimeout(timer);
127
- resolve(result);
128
- };
129
- if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
130
- timer = setTimeout(() => {
131
- try {
132
- child.kill?.('SIGKILL');
133
- } catch {
134
- // already dead / stub child
135
- }
136
- finish({
137
- code: null,
138
- stdout,
139
- stderr,
140
- spawnError: Object.assign(
141
- new Error(
142
- `child process '${cmd}' exceeded ${timeoutMs}ms and was killed`,
143
- ),
144
- { code: 'ETIMEDOUT' },
145
- ),
146
- timedOut: true,
147
- });
148
- }, timeoutMs);
149
- // Deliberately NOT unref'd: a child whose handles close early (or a
150
- // stub) leaves the loop idle, and an unref'd watchdog would never fire.
151
- // `finish()` always clears it.
152
- }
153
- child.stdout?.on('data', (chunk) => {
154
- stdout += chunk.toString();
155
- });
156
- child.stderr?.on('data', (chunk) => {
157
- stderr += chunk.toString();
158
- });
159
- child.on('error', (err) => {
160
- spawnError = err;
161
- });
162
- child.on('close', (code) => {
163
- finish({ code, stdout, stderr, spawnError, timedOut: false });
164
- });
165
- });
166
- }
167
-
168
100
  /**
169
101
  * Opt-in `delivery.feedbackLoop.<key>` reader: only an explicit `true`
170
102
  * enables filing, because unattended filings were dominated by noise.
@@ -178,416 +110,6 @@ export function makeIsAutoFileEnabled(toggleKey) {
178
110
  };
179
111
  }
180
112
 
181
- /**
182
- * `git cat-file -e <ref>:<path>`; a spawn failure/timeout is `probeError`,
183
- * distinct from a confirmed-missing file.
184
- *
185
- * @param {object} opts
186
- * @param {string} opts.ref
187
- * @param {string} opts.path
188
- * @param {Function} [opts.spawnImpl]
189
- * @param {string} [opts.cwd]
190
- * @param {number} [opts.timeoutMs]
191
- * @returns {Promise<{ exists: boolean, probeError: boolean }>}
192
- */
193
- export async function probePathStatus({
194
- ref,
195
- path,
196
- spawnImpl,
197
- cwd,
198
- timeoutMs,
199
- }) {
200
- const res = await runChild({
201
- cmd: 'git',
202
- args: ['cat-file', '-e', `${ref}:${path}`],
203
- spawnImpl,
204
- cwd,
205
- timeoutMs,
206
- });
207
- if (res.spawnError || res.timedOut) {
208
- return { exists: false, probeError: true };
209
- }
210
- return { exists: res.code === 0, probeError: false };
211
- }
212
-
213
- /**
214
- * GitHub search indexes the text inside an HTML-comment marker, but a query
215
- * carrying the `<!--`/`-->` delimiters never matches it; strip them.
216
- *
217
- * @param {string} marker
218
- * @returns {string}
219
- */
220
- function normalizeMarkerQuery(marker) {
221
- if (typeof marker !== 'string') return '';
222
- return marker.replaceAll('<!--', '').replaceAll('-->', '').trim();
223
- }
224
-
225
- /**
226
- * `state` defaults to `''`, never `'open'`: an unknown state must not
227
- * authorize editing an issue.
228
- *
229
- * @param {object} row
230
- * @returns {{ number: number|null, state: string, url: string }}
231
- */
232
- function toFollowUpRef(row) {
233
- const number = Number(row?.number);
234
- return {
235
- number: Number.isInteger(number) && number > 0 ? number : null,
236
- state: String(row?.state ?? '').toLowerCase(),
237
- url: typeof row?.url === 'string' ? row.url : '',
238
- };
239
- }
240
-
241
- /**
242
- * `null` on no match OR an undecidable probe — degrade toward filing: a
243
- * duplicate beats a swallowed finding.
244
- *
245
- * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
246
- */
247
- async function searchFollowUpByMarker({
248
- marker,
249
- owner,
250
- repo,
251
- ghPath,
252
- spawnImpl,
253
- cwd,
254
- timeoutMs,
255
- }) {
256
- const args = [
257
- 'search',
258
- 'issues',
259
- normalizeMarkerQuery(marker),
260
- '--repo',
261
- `${owner}/${repo}`,
262
- '--json',
263
- 'number,state,url',
264
- '--limit',
265
- '1',
266
- ];
267
- const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
268
- if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
269
- return null;
270
- }
271
- try {
272
- const parsed = JSON.parse(res.stdout || '[]');
273
- if (!Array.isArray(parsed) || parsed.length === 0) return null;
274
- return toFollowUpRef(parsed[0]);
275
- } catch {
276
- return null;
277
- }
278
- }
279
-
280
- /** @returns {Promise<boolean>} */
281
- export async function probeMarkerExists(opts) {
282
- return (await searchFollowUpByMarker(opts)) !== null;
283
- }
284
-
285
- /**
286
- * Strongly-consistent last gate before creating: the search index can lag
287
- * long enough to miss a duplicate filed seconds earlier, while a
288
- * label-scoped `gh issue list --state all` cannot. Any of `markers` matching
289
- * counts, so a marker-format change does not re-file the backlog. `null` on
290
- * no match or error (degrade toward filing).
291
- *
292
- * @param {object} opts
293
- * @param {string[]} opts.markers
294
- * @param {string} opts.owner
295
- * @param {string} opts.repo
296
- * @param {string[]} [opts.labels]
297
- * @param {string} [opts.ghPath]
298
- * @param {Function} [opts.spawnImpl]
299
- * @param {string} [opts.cwd]
300
- * @param {number} [opts.timeoutMs]
301
- * @returns {Promise<{ number: number|null, state: string, url: string }|null>}
302
- */
303
- async function findExistingFollowUp({
304
- markers,
305
- owner,
306
- repo,
307
- labels,
308
- ghPath,
309
- spawnImpl,
310
- cwd,
311
- timeoutMs,
312
- }) {
313
- const tokens = (Array.isArray(markers) ? markers : []).filter(
314
- (m) => typeof m === 'string' && m.length > 0,
315
- );
316
- if (tokens.length === 0) return null;
317
- const args = [
318
- 'issue',
319
- 'list',
320
- '--repo',
321
- `${owner}/${repo}`,
322
- '--state',
323
- 'all',
324
- '--json',
325
- 'number,body,state,url',
326
- ];
327
- for (const label of Array.isArray(labels) ? labels : []) {
328
- args.push('--label', label);
329
- }
330
- const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
331
- if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
332
- return null;
333
- }
334
- let parsed;
335
- try {
336
- parsed = JSON.parse(res.stdout || '[]');
337
- } catch {
338
- return null;
339
- }
340
- if (!Array.isArray(parsed)) return null;
341
- const matches = parsed.filter(
342
- (issue) =>
343
- typeof issue?.body === 'string' &&
344
- tokens.some((token) => issue.body.includes(token)),
345
- );
346
- if (matches.length === 0) return null;
347
- // Prefer an open match: a closed one is a decided follow-up.
348
- const open = matches.find(
349
- (issue) => String(issue?.state ?? '').toLowerCase() === 'open',
350
- );
351
- return toFollowUpRef(open ?? matches[0]);
352
- }
353
-
354
- /**
355
- * Recurrence path: refresh the body only, leaving human-curated labels,
356
- * title, assignees and state untouched.
357
- *
358
- * @returns {Promise<{ url: string|null, error: string|null }>}
359
- */
360
- export async function updateFollowUpIssue({
361
- owner,
362
- repo,
363
- number,
364
- body,
365
- ghPath,
366
- spawnImpl,
367
- cwd,
368
- timeoutMs,
369
- }) {
370
- const res = await runChild({
371
- cmd: ghPath,
372
- args: [
373
- 'issue',
374
- 'edit',
375
- String(number),
376
- '--repo',
377
- `${owner}/${repo}`,
378
- '--body',
379
- body,
380
- ],
381
- spawnImpl,
382
- cwd,
383
- timeoutMs,
384
- });
385
- if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
386
- return {
387
- url: null,
388
- error: res.spawnError
389
- ? `gh issue edit spawn failed: ${res.spawnError.message}`
390
- : `gh issue edit exited ${res.code}: ${(res.stderr || '').trim()}`,
391
- };
392
- }
393
- return { url: (res.stdout || '').trim(), error: null };
394
- }
395
-
396
- const FRICTION_LABEL_PREFIX = 'friction::';
397
-
398
- /**
399
- * Only `meta::*` and `friction::<category>` labels reach this path.
400
- *
401
- * @param {string} name
402
- * @returns {{ color: string, description: string }}
403
- */
404
- function describeMintedLabel(name) {
405
- if (name.startsWith(FRICTION_LABEL_PREFIX)) {
406
- return {
407
- color: LABEL_COLORS.FRICTION,
408
- description: `Recurring friction category "${name.slice(FRICTION_LABEL_PREFIX.length)}" (minted by the feedback loop)`,
409
- };
410
- }
411
- return {
412
- color: LABEL_COLORS.META,
413
- description: 'Feedback-loop routing axis (minted by the feedback loop)',
414
- };
415
- }
416
-
417
- /**
418
- * Memoized per repo. `known: null` means unverifiable, not "no labels".
419
- *
420
- * @returns {Promise<{ known: Set<string>|null, error: string|null }>}
421
- */
422
- async function readLiveLabelNames({
423
- owner,
424
- repo,
425
- labelCache,
426
- ghPath,
427
- spawnImpl,
428
- cwd,
429
- timeoutMs,
430
- }) {
431
- const key = `${owner}/${repo}`;
432
- const cached = labelCache?.get(key);
433
- if (cached) return { known: cached, error: null };
434
- const res = await runChild({
435
- cmd: ghPath,
436
- args: ['label', 'list', '--repo', key, '--limit', '500', '--json', 'name'],
437
- spawnImpl,
438
- cwd,
439
- timeoutMs,
440
- });
441
- if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
442
- return {
443
- known: null,
444
- error: `gh label list ${key} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
445
- };
446
- }
447
- let parsed;
448
- try {
449
- parsed = JSON.parse(res.stdout || '[]');
450
- } catch {
451
- return {
452
- known: null,
453
- error: `gh label list ${key} returned unparseable JSON`,
454
- };
455
- }
456
- if (!Array.isArray(parsed)) {
457
- return { known: null, error: `gh label list ${key} returned a non-array` };
458
- }
459
- const known = new Set();
460
- for (const row of parsed) {
461
- if (row && typeof row.name === 'string') known.add(row.name);
462
- }
463
- labelCache?.set(key, known);
464
- return { known, error: null };
465
- }
466
-
467
- /**
468
- * Mint absent labels: `gh issue create` fails the whole call on any unknown
469
- * `--label`, and `friction::<category>` names come from live telemetry so no
470
- * bootstrap can pre-create them. An unreadable live set reports nothing
471
- * `missing`, so the caller still attempts the filing.
472
- *
473
- * @param {object} opts
474
- * @param {string} opts.owner
475
- * @param {string} opts.repo
476
- * @param {string[]} opts.labels
477
- * @param {Map<string, Set<string>>} [opts.labelCache]
478
- * @param {string} [opts.ghPath]
479
- * @param {Function} [opts.spawnImpl]
480
- * @param {string} [opts.cwd]
481
- * @param {number} [opts.timeoutMs]
482
- * @returns {Promise<{ created: string[], missing: string[], errors: string[] }>}
483
- */
484
- export async function ensureIssueLabels({
485
- owner,
486
- repo,
487
- labels,
488
- labelCache,
489
- ghPath = 'gh',
490
- spawnImpl,
491
- cwd,
492
- timeoutMs,
493
- }) {
494
- const wanted = (Array.isArray(labels) ? labels : []).filter(
495
- (name) => typeof name === 'string' && name.trim().length > 0,
496
- );
497
- if (wanted.length === 0) return { created: [], missing: [], errors: [] };
498
-
499
- const { known, error } = await readLiveLabelNames({
500
- owner,
501
- repo,
502
- labelCache,
503
- ghPath,
504
- spawnImpl,
505
- cwd,
506
- timeoutMs,
507
- });
508
- if (!known) return { created: [], missing: [], errors: error ? [error] : [] };
509
-
510
- const created = [];
511
- const missing = [];
512
- const errors = [];
513
- for (const name of wanted) {
514
- if (known.has(name)) continue;
515
- const { color, description } = describeMintedLabel(name);
516
- const res = await runChild({
517
- cmd: ghPath,
518
- args: [
519
- 'label',
520
- 'create',
521
- name,
522
- '--repo',
523
- `${owner}/${repo}`,
524
- '--color',
525
- color.replace(/^#/, ''),
526
- '--description',
527
- description,
528
- ],
529
- spawnImpl,
530
- cwd,
531
- timeoutMs,
532
- });
533
- const failed =
534
- Boolean(res.spawnError) ||
535
- (typeof res.code === 'number' && res.code !== 0);
536
- // A concurrent mint between list and create is success.
537
- const raced = /label\b[\s\S]*?already exists/i.test(
538
- `${res.stderr ?? ''}${res.spawnError?.message ?? ''}`,
539
- );
540
- if (!failed || raced) {
541
- known.add(name);
542
- if (!raced) created.push(name);
543
- continue;
544
- }
545
- missing.push(name);
546
- errors.push(
547
- `gh label create "${name}" in ${owner}/${repo} failed: ${res.spawnError?.message ?? (res.stderr || '').trim()}`,
548
- );
549
- }
550
- return { created, missing, errors };
551
- }
552
-
553
- /** Resolves `{ url, error }`; exactly one is non-null. */
554
- export async function createFollowUpIssue({
555
- owner,
556
- repo,
557
- title,
558
- body,
559
- labels,
560
- ghPath,
561
- spawnImpl,
562
- cwd,
563
- timeoutMs,
564
- }) {
565
- const args = [
566
- 'issue',
567
- 'create',
568
- '--repo',
569
- `${owner}/${repo}`,
570
- '--title',
571
- title,
572
- '--body',
573
- body,
574
- ];
575
- for (const label of labels) {
576
- args.push('--label', label);
577
- }
578
- const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
579
- if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
580
- return {
581
- url: null,
582
- error: res.spawnError
583
- ? `gh issue create spawn failed: ${res.spawnError.message}`
584
- : `gh issue create exited ${res.code}: ${(res.stderr || '').trim()}`,
585
- };
586
- }
587
- const url = (res.stdout || '').trim();
588
- return { url, error: null };
589
- }
590
-
591
113
  /**
592
114
  * `null` when all pass, else a partial envelope to short-circuit on. The
593
115
  * provider is deliberately not gated: only the best-effort cross-repo upsert
@@ -610,125 +132,8 @@ function checkGraduatePreconditions({ epicId, currentRepo, config, spec }) {
610
132
  return null;
611
133
  }
612
134
 
613
- /**
614
- * Checks the content-hash marker, then the legacy ordinal marker so
615
- * pre-fingerprint filings are not re-filed. `existing` identifies the match
616
- * so a recurrence can update it.
617
- */
618
- async function resolveAlreadyFiled({
619
- finding,
620
- epicId,
621
- routedRepo,
622
- contentMarker,
623
- ghPath,
624
- spawnImpl,
625
- cwd,
626
- timeoutMs,
627
- spec,
628
- }) {
629
- const probe = (marker) =>
630
- searchFollowUpByMarker({
631
- marker,
632
- owner: routedRepo.owner,
633
- repo: routedRepo.repo,
634
- ghPath,
635
- spawnImpl,
636
- cwd,
637
- timeoutMs,
638
- });
639
-
640
- const hit = await probe(contentMarker);
641
- if (hit) return { alreadyFiled: true, existing: hit };
642
- if (typeof spec.buildLegacyMarker === 'function') {
643
- const legacyMarker = spec.buildLegacyMarker(epicId, finding.index);
644
- if (legacyMarker) {
645
- const legacyHit = await probe(legacyMarker);
646
- if (legacyHit) return { alreadyFiled: true, existing: legacyHit };
647
- }
648
- }
649
- return { alreadyFiled: false, existing: null };
650
- }
651
-
652
- /**
653
- * An open, numbered match is refreshed and recorded on `filed` as
654
- * `action: 'updated'` (a live write, counted like a creation). Anything else
655
- * is `already-filed` and untouched: a closed follow-up is a human's decision.
656
- *
657
- * @returns {Promise<boolean>} `true` when the recurrence was handled here.
658
- */
659
- async function resolveFollowUpRecurrence({
660
- existing,
661
- body,
662
- finding,
663
- source,
664
- routedRepo,
665
- envelope,
666
- decorate,
667
- skip,
668
- ghPath,
669
- spawnImpl,
670
- cwd,
671
- timeoutMs,
672
- }) {
673
- if (existing.state !== 'open' || existing.number === null) {
674
- skip('already-filed');
675
- return true;
676
- }
677
- const updated = await updateFollowUpIssue({
678
- owner: routedRepo.owner,
679
- repo: routedRepo.repo,
680
- number: existing.number,
681
- body,
682
- ghPath,
683
- spawnImpl,
684
- cwd,
685
- timeoutMs,
686
- });
687
- if (updated.error) {
688
- envelope.errors.push(
689
- `finding ${finding.index} (${finding.path}): ${updated.error}`,
690
- );
691
- return true;
692
- }
693
- envelope.filed.push(
694
- decorate(
695
- {
696
- index: finding.index,
697
- action: 'updated',
698
- issueNumber: existing.number,
699
- severity: finding.severity,
700
- path: finding.path,
701
- source,
702
- repo: `${routedRepo.owner}/${routedRepo.repo}`,
703
- url: updated.url || existing.url || null,
704
- },
705
- finding,
706
- ),
707
- );
708
- return true;
709
- }
710
-
711
- async function processGraduateFinding({
712
- finding,
713
- envelope,
714
- decorate,
715
- epicId,
716
- currentRepo,
717
- repos,
718
- classifier,
719
- gitRef,
720
- ghPath,
721
- spawnImpl,
722
- cwd,
723
- timeoutMs,
724
- maxFilingsPerRun,
725
- crossRepoDeferred,
726
- filedMarkers,
727
- labelCache,
728
- logger,
729
- spec,
730
- }) {
731
- const skip = (reason) =>
135
+ function makeSkip({ envelope, decorate, finding }) {
136
+ return (reason) =>
732
137
  envelope.skipped.push(
733
138
  decorate(
734
139
  {
@@ -740,174 +145,36 @@ async function processGraduateFinding({
740
145
  finding,
741
146
  ),
742
147
  );
148
+ }
743
149
 
744
- // A path-less finding would misclassify as `file-removed`; skip the probe.
745
- const hasPath =
746
- typeof finding.path === 'string' && finding.path.trim().length > 0;
747
- if (hasPath) {
748
- const { exists, probeError } = await probePathStatus({
749
- ref: gitRef,
750
- path: finding.path,
751
- spawnImpl,
752
- cwd,
753
- timeoutMs,
754
- });
755
- if (probeError) return skip('probe-error');
756
- if (!exists) return skip('file-removed');
757
- }
150
+ /** Walk one finding; every outcome lands on `ctx.envelope`. */
151
+ async function processGraduateFinding(ctx) {
152
+ const { finding, epicId, spec } = ctx;
153
+ const skip = makeSkip(ctx);
154
+ const pathSkip = await probeFindingPath(ctx);
155
+ if (pathSkip) return skip(pathSkip);
758
156
 
759
- const source = classifier(finding.path, null);
760
- // Unroutable findings are deferred and named, never re-pointed at the
761
- // consumer's repo.
762
- const routing = routeOwnership({ bucket: source, repos, currentRepo });
763
- if (!routing.routable) {
764
- const logLine = `[${spec.fnName}] unroutable ${source} finding (${routing.missingKey} is unset) — not filed: ${finding.title ?? finding.path ?? `finding ${finding.index}`}`;
765
- logger?.warn?.(logLine);
766
- crossRepoDeferred.push({
767
- finding,
768
- routedRepo: null,
769
- source,
770
- logLine,
771
- missingKey: routing.missingKey,
772
- });
773
- return skip('unroutable');
774
- }
775
- const routedRepo = routing.routedRepo;
776
- if (routing.crossRepo) {
777
- const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
778
- logger?.info?.(logLine);
779
- crossRepoDeferred.push({ finding, routedRepo, source, logLine });
780
- return skip('cross-repo-deferred');
157
+ const route = routeFinding(ctx);
158
+ if (route.deferred) {
159
+ ctx.crossRepoDeferred.push(route.deferred);
160
+ return skip(route.skipReason);
781
161
  }
782
162
 
783
163
  const contentMarker = spec.buildContentMarker(epicId, finding);
784
-
785
164
  // In-process memo closes the same-invocation race the search index cannot.
786
- if (filedMarkers?.has(contentMarker)) return skip('already-filed');
787
-
788
- const { alreadyFiled, existing } = await resolveAlreadyFiled({
789
- finding,
790
- epicId,
791
- routedRepo,
792
- contentMarker,
793
- ghPath,
794
- spawnImpl,
795
- cwd,
796
- timeoutMs,
797
- spec,
798
- });
165
+ if (ctx.filedMarkers?.has(contentMarker)) return skip('already-filed');
799
166
 
800
- // Built before dedup: the strong read scopes by these labels and a
801
- // recurrence writes this body.
802
- const { title, body, labels } = spec.buildFollowUp({
167
+ const walk = { ...ctx, ...route, contentMarker, skip };
168
+ const existing = await dedupFinding(walk);
169
+ // The strong read scopes by these labels and a recurrence writes this body.
170
+ const followUp = spec.buildFollowUp({
803
171
  finding,
804
- source,
172
+ source: route.source,
805
173
  epicId,
806
174
  idMarker: contentMarker,
807
175
  });
808
-
809
- if (alreadyFiled) {
810
- filedMarkers?.add(contentMarker);
811
- await resolveFollowUpRecurrence({
812
- existing,
813
- body,
814
- finding,
815
- source,
816
- routedRepo,
817
- envelope,
818
- decorate,
819
- skip,
820
- ghPath,
821
- spawnImpl,
822
- cwd,
823
- timeoutMs,
824
- });
825
- return;
826
- }
827
-
828
- if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
829
-
830
- // Before the strong read too: `gh issue list --label <absent>` exits 0
831
- // with `[]`, silently defeating the dedup confirm.
832
- const ensured = await ensureIssueLabels({
833
- owner: routedRepo.owner,
834
- repo: routedRepo.repo,
835
- labels,
836
- labelCache,
837
- ghPath,
838
- spawnImpl,
839
- cwd,
840
- timeoutMs,
841
- });
842
- envelope.errors.push(...ensured.errors);
843
- if (ensured.missing.length > 0) {
844
- return skip('label-ensure-failed');
845
- }
846
-
847
- const confirmed = await findExistingFollowUp({
848
- markers:
849
- typeof spec.buildMatchTokens === 'function'
850
- ? spec.buildMatchTokens({ epicId, finding, contentMarker })
851
- : [contentMarker],
852
- owner: routedRepo.owner,
853
- repo: routedRepo.repo,
854
- labels,
855
- ghPath,
856
- spawnImpl,
857
- cwd,
858
- timeoutMs,
859
- });
860
- if (confirmed) {
861
- filedMarkers?.add(contentMarker);
862
- await resolveFollowUpRecurrence({
863
- existing: confirmed,
864
- body,
865
- finding,
866
- source,
867
- routedRepo,
868
- envelope,
869
- decorate,
870
- skip,
871
- ghPath,
872
- spawnImpl,
873
- cwd,
874
- timeoutMs,
875
- });
876
- return;
877
- }
878
-
879
- const created = await createFollowUpIssue({
880
- owner: routedRepo.owner,
881
- repo: routedRepo.repo,
882
- title,
883
- body,
884
- labels,
885
- ghPath,
886
- spawnImpl,
887
- cwd,
888
- timeoutMs,
889
- });
890
- if (created.error) {
891
- envelope.errors.push(
892
- `finding ${finding.index} (${finding.path}): ${created.error}`,
893
- );
894
- return;
895
- }
896
- filedMarkers?.add(contentMarker);
897
- envelope.filed.push(
898
- decorate(
899
- {
900
- index: finding.index,
901
- action: 'created',
902
- severity: finding.severity,
903
- path: finding.path,
904
- source,
905
- repo: `${routedRepo.owner}/${routedRepo.repo}`,
906
- url: created.url,
907
- },
908
- finding,
909
- ),
910
- );
176
+ if (existing) return recordRecurrence(walk, existing, followUp.body);
177
+ return fileFinding(walk, followUp);
911
178
  }
912
179
 
913
180
  function renderCrossRepoDeferredBody(deferred, spec) {
@@ -1062,6 +329,7 @@ export async function graduate({
1062
329
  framework: frameworkRepo ?? null,
1063
330
  platform: platformRepo ?? null,
1064
331
  };
332
+ const gh = { ghPath, spawnImpl, cwd, timeoutMs };
1065
333
  const crossRepoDeferred = [];
1066
334
  for (const finding of findings) {
1067
335
  await processGraduateFinding({
@@ -1073,10 +341,7 @@ export async function graduate({
1073
341
  repos,
1074
342
  classifier,
1075
343
  gitRef,
1076
- ghPath,
1077
- spawnImpl,
1078
- cwd,
1079
- timeoutMs,
344
+ gh,
1080
345
  maxFilingsPerRun,
1081
346
  crossRepoDeferred,
1082
347
  filedMarkers,