mandrel 1.87.0 → 1.89.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 (140) hide show
  1. package/.agents/README.md +18 -13
  2. package/.agents/audit-checklists/architecture.md +24 -0
  3. package/.agents/audit-checklists/clean-code.md +24 -0
  4. package/.agents/audit-checklists/dependencies.md +14 -0
  5. package/.agents/audit-checklists/devops.md +17 -0
  6. package/.agents/audit-checklists/documentation.md +22 -0
  7. package/.agents/audit-checklists/lighthouse.md +15 -0
  8. package/.agents/audit-checklists/navigability.md +14 -0
  9. package/.agents/audit-checklists/performance.md +22 -0
  10. package/.agents/audit-checklists/privacy.md +21 -0
  11. package/.agents/audit-checklists/quality.md +18 -0
  12. package/.agents/audit-checklists/security.md +22 -0
  13. package/.agents/audit-checklists/seo.md +16 -0
  14. package/.agents/audit-checklists/sre.md +24 -0
  15. package/.agents/audit-checklists/ux-ui.md +21 -0
  16. package/.agents/docs/SDLC.md +63 -16
  17. package/.agents/docs/configuration.md +5 -3
  18. package/.agents/instructions.md +51 -21
  19. package/.agents/personas/architect.md +10 -7
  20. package/.agents/personas/engineer.md +4 -3
  21. package/.agents/personas/project-manager.md +5 -2
  22. package/.agents/personas/refactorer.md +5 -3
  23. package/.agents/rules/git-conventions.md +77 -0
  24. package/.agents/schemas/agentrc.schema.json +16 -4
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +38 -0
  28. package/.agents/schemas/signal-event.schema.json +28 -13
  29. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  30. package/.agents/scripts/check-context-budget.js +320 -0
  31. package/.agents/scripts/diagnose-friction.js +4 -4
  32. package/.agents/scripts/epic-audit-prepare.js +30 -2
  33. package/.agents/scripts/epic-audit-recheck.js +46 -13
  34. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  35. package/.agents/scripts/epic-plan-spec.js +4 -8
  36. package/.agents/scripts/generate-lens-checklists.js +180 -0
  37. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  38. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  39. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  40. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  41. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  42. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  43. package/.agents/scripts/lib/cli-args.js +8 -0
  44. package/.agents/scripts/lib/config/explain.js +4 -0
  45. package/.agents/scripts/lib/config/runners.js +21 -2
  46. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  47. package/.agents/scripts/lib/config-settings-schema-delivery.js +23 -3
  48. package/.agents/scripts/lib/config-settings-schema-quality.js +7 -0
  49. package/.agents/scripts/lib/doc-tiers.js +291 -0
  50. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  51. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  52. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  53. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +66 -20
  54. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  55. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  56. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  57. package/.agents/scripts/lib/gates/friction.js +15 -5
  58. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  59. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  60. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  61. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  62. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  63. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  64. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  65. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  66. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  67. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  68. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  69. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  70. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +188 -0
  71. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  72. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +69 -8
  73. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +11 -2
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +39 -3
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  79. package/.agents/scripts/lib/orchestration/merge-block-class.js +218 -0
  80. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  81. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  82. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  83. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  84. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  85. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  86. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  87. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  88. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  89. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  91. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  92. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  93. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  94. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  95. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  96. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  97. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  98. package/.agents/scripts/lib/planning-corpus.js +306 -0
  99. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  100. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  101. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  102. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  103. package/.agents/scripts/lib/signals/schema.js +56 -81
  104. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  105. package/.agents/scripts/lib/story-plan.js +3 -0
  106. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  107. package/.agents/scripts/lifecycle-emit.js +39 -8
  108. package/.agents/scripts/providers/github/issues.js +12 -1
  109. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  110. package/.agents/scripts/retro-run.js +51 -0
  111. package/.agents/scripts/signals-view.js +1 -1
  112. package/.agents/scripts/single-story-close.js +20 -1
  113. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  114. package/.agents/scripts/story-close.js +48 -0
  115. package/.agents/scripts/story-plan.js +51 -12
  116. package/.agents/scripts/validate-docs-freshness.js +69 -15
  117. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  118. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  119. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  120. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  121. package/.agents/skills/skills.index.json +3 -3
  122. package/.agents/workflows/audit-documentation.md +82 -2
  123. package/.agents/workflows/helpers/code-review.md +193 -44
  124. package/.agents/workflows/helpers/deliver-epic.md +128 -39
  125. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  126. package/.agents/workflows/helpers/epic-audit.md +116 -283
  127. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  128. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  129. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  130. package/.agents/workflows/helpers/plan-epic.md +141 -105
  131. package/.agents/workflows/helpers/plan-story.md +32 -0
  132. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  133. package/.agents/workflows/loops/nightly-audit.md +9 -7
  134. package/docs/CHANGELOG.md +29 -0
  135. package/lib/cli/doctor.js +44 -0
  136. package/package.json +4 -3
  137. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  138. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -207
  139. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  140. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Story #3845 / Epic #3823. The audit-results graduator
5
5
  * (`audit-results-graduator.js`) and the code-review graduator
6
- * (`code-review-graduator.js`) duplicated ~90% of their mechanism: a
6
+ * (the since-retired `code-review-graduator.js`) duplicated ~90% of their mechanism: a
7
7
  * `spawn`-based child runner, the `git cat-file` path probe, the
8
8
  * `gh search issues` idempotency probe, the `gh issue create` filer, the
9
9
  * `isAutoFileEnabled` toggle reader, and the route → probe → file
@@ -14,20 +14,111 @@
14
14
  * The third `runGh` spawn copy in `prior-feedback-fetcher.js` is also
15
15
  * collapsed onto the single `runChild` helper here.
16
16
  *
17
- * Behaviour-preserving: the parametrized `graduate()` walk reproduces the
18
- * exact envelope, skip reasons, label sets, titles, and bodies the two
19
- * standalone graduators produced before consolidation. The only injected
20
- * seams are the parser (`parseFindings`) and the per-finding
21
- * `bodyBuilder`; everything mechanical is shared.
17
+ * Story #4415 / Epic #4406 hardens the walk so it is bounded and
18
+ * replay-safe the shared mechanism the retro auto-filer will reuse:
19
+ *
20
+ * - **Content-hash idempotency markers.** Follow-up markers derive from
21
+ * a `category|path|title` digest (`contentFingerprint`) instead of a
22
+ * `(epicId, parse-index)` ordinal, so a finding keeps its marker when
23
+ * sibling findings are added, removed, or reordered in the source
24
+ * comment, and two distinct findings never collide.
25
+ * - **Legacy-marker recognition.** The idempotency probe also checks the
26
+ * legacy `(epicId, parse-index)` marker so findings filed before the
27
+ * fingerprint cutover are not re-filed.
28
+ * - **Bounded spawns.** `runChild` enforces a caller-overridable timeout
29
+ * (default 30000 ms) and kills a child that overruns instead of
30
+ * hanging finalize forever.
31
+ * - **Probe-error vs confirmed-missing.** A `git cat-file` spawn failure
32
+ * (or timeout) records the finding as skipped `probe-error`, not the
33
+ * confirmed-missing `file-removed`.
34
+ * - **Per-run filing cap.** `graduate()` stops filing once
35
+ * `maxFilingsPerRun` issues are created and records the excess as
36
+ * skipped `cap-reached`.
37
+ * - **Pre-parsed / path-less seam.** `graduate()` accepts a pre-parsed
38
+ * `findings` array (bypassing structured-comment parsing), and a
39
+ * path-less finding skips the path-exists gate instead of being
40
+ * misclassified `file-removed` — the seam the retro auto-filer
41
+ * consumes.
42
+ * - **Durable cross-repo deferral.** Cross-repo-deferred findings are
43
+ * upserted into a structured comment on the Epic instead of only a
44
+ * log line.
22
45
  */
23
46
 
24
47
  import { spawn as defaultSpawn } from 'node:child_process';
48
+ import { createHash } from 'node:crypto';
25
49
 
26
50
  import { classifyPathSource as defaultClassifier } from '../observability/source-classifier.js';
51
+ import {
52
+ structuredCommentMarker,
53
+ upsertStructuredComment,
54
+ } from '../orchestration/ticketing.js';
55
+
56
+ /**
57
+ * The single structured-comment marker for the unified `verification-results`
58
+ * findings contract (Story #4411, Epic #4405). It replaces the two retired
59
+ * per-graduator markers (the former code-review and audit-results
60
+ * structured-comment markers). Both feedback-loop graduators
61
+ * search the Epic's comments for this one marker so they file follow-ups
62
+ * from the same unified source comment that `runCodeReview` upserts (comment
63
+ * type `verification-results`). Derived from the canonical
64
+ * `structuredCommentMarker` builder so the read-side marker stays byte-stable
65
+ * with the write side rather than being hand-copied.
66
+ */
67
+ export const VERIFICATION_RESULTS_MARKER = structuredCommentMarker(
68
+ 'verification-results',
69
+ );
70
+
71
+ /**
72
+ * The single "no source comment" skip reason for the unified contract. Both
73
+ * graduators surface this reason when the Epic carries no
74
+ * `verification-results` comment, replacing the two retired
75
+ * `no-code-review-comment` / `no-audit-results-comment` reasons.
76
+ */
77
+ export const NO_VERIFICATION_RESULTS_COMMENT_REASON =
78
+ 'no-verification-results-comment';
79
+
80
+ /**
81
+ * Default child-process timeout. A hung `gh`/`git` spawn previously blocked
82
+ * finalize indefinitely; the walk now caps every spawn at this bound unless
83
+ * a caller overrides it.
84
+ */
85
+ export const DEFAULT_RUN_CHILD_TIMEOUT_MS = 30000;
86
+
87
+ /**
88
+ * Default per-run filing cap. `graduate()` files at most this many follow-up
89
+ * issues per invocation; the remainder is recorded as skipped `cap-reached`.
90
+ */
91
+ export const DEFAULT_MAX_FILINGS_PER_RUN = 20;
92
+
93
+ /**
94
+ * Structured-comment type used to durably persist cross-repo-deferred
95
+ * findings on the Epic. Registered in `STRUCTURED_COMMENT_TYPES`.
96
+ */
97
+ export const CROSS_REPO_DEFERRED_COMMENT_TYPE = 'cross-repo-deferred';
98
+
99
+ /**
100
+ * Compute a stable content fingerprint for a finding from its
101
+ * `category|path|title` triple. Pure — the digest depends only on the
102
+ * finding content, never on its position in the source comment, so the
103
+ * marker survives sibling insert/remove/reorder churn. Distinct triples
104
+ * yield distinct digests (SHA-256, truncated to 16 hex chars for a compact
105
+ * marker that still has a negligible collision probability across a single
106
+ * Epic's finding set).
107
+ *
108
+ * @param {{ category?: unknown, path?: unknown, title?: unknown }} parts
109
+ * @returns {string} 16-char lowercase hex digest.
110
+ */
111
+ export function contentFingerprint({ category, path, title } = {}) {
112
+ const norm = (v) => (typeof v === 'string' ? v : v == null ? '' : String(v));
113
+ const canonical = `${norm(category)}|${norm(path)}|${norm(title)}`;
114
+ return createHash('sha256').update(canonical).digest('hex').slice(0, 16);
115
+ }
27
116
 
28
117
  /**
29
- * Spawn a child process and resolve to `{ code, stdout, stderr, spawnError }`.
30
- * Never throws — spawn-time errors are captured as `spawnError`.
118
+ * Spawn a child process and resolve to
119
+ * `{ code, stdout, stderr, spawnError, timedOut }`. Never throws — spawn-time
120
+ * errors are captured as `spawnError` and an overrun is captured as
121
+ * `timedOut: true` with a matching `spawnError`.
31
122
  *
32
123
  * This is the single spawn helper for the feedback-loop modules. Both
33
124
  * graduators and `prior-feedback-fetcher.js` route their child-process
@@ -36,14 +127,26 @@ import { classifyPathSource as defaultClassifier } from '../observability/source
36
127
  * options (e.g. omitting `cwd`) pass `undefined` and the option is
37
128
  * dropped by the child_process layer.
38
129
  *
130
+ * A caller-overridable `timeoutMs` (default {@link DEFAULT_RUN_CHILD_TIMEOUT_MS})
131
+ * bounds the wait: when it elapses the child is SIGKILL'd and the promise
132
+ * resolves with `{ code: null, timedOut: true, spawnError }` rather than
133
+ * hanging. Pass `0`/`Infinity` to disable the watchdog.
134
+ *
39
135
  * @param {object} opts
40
136
  * @param {string} opts.cmd — binary to spawn (e.g. "git", "gh")
41
137
  * @param {string[]} opts.args — positional + flag arguments
42
138
  * @param {Function} [opts.spawnImpl] — test seam; defaults to node:child_process spawn
43
139
  * @param {string} [opts.cwd] — working directory for the child
44
- * @returns {Promise<{ code: number|null, stdout: string, stderr: string, spawnError: Error|null }>}
140
+ * @param {number} [opts.timeoutMs] watchdog bound in ms
141
+ * @returns {Promise<{ code: number|null, stdout: string, stderr: string, spawnError: Error|null, timedOut: boolean }>}
45
142
  */
46
- export function runChild({ cmd, args, spawnImpl = defaultSpawn, cwd }) {
143
+ export function runChild({
144
+ cmd,
145
+ args,
146
+ spawnImpl = defaultSpawn,
147
+ cwd,
148
+ timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
149
+ }) {
47
150
  return new Promise((resolve) => {
48
151
  let child;
49
152
  try {
@@ -52,12 +155,55 @@ export function runChild({ cmd, args, spawnImpl = defaultSpawn, cwd }) {
52
155
  cwd,
53
156
  });
54
157
  } catch (err) {
55
- resolve({ code: null, stdout: '', stderr: '', spawnError: err });
158
+ resolve({
159
+ code: null,
160
+ stdout: '',
161
+ stderr: '',
162
+ spawnError: err,
163
+ timedOut: false,
164
+ });
56
165
  return;
57
166
  }
58
167
  let stdout = '';
59
168
  let stderr = '';
60
169
  let spawnError = null;
170
+ let settled = false;
171
+ let timer = null;
172
+ const finish = (result) => {
173
+ if (settled) return;
174
+ settled = true;
175
+ if (timer) clearTimeout(timer);
176
+ resolve(result);
177
+ };
178
+ if (Number.isFinite(timeoutMs) && timeoutMs > 0) {
179
+ timer = setTimeout(() => {
180
+ try {
181
+ child.kill?.('SIGKILL');
182
+ } catch {
183
+ // Killing an already-dead / stub child is a no-op we ignore.
184
+ }
185
+ finish({
186
+ code: null,
187
+ stdout,
188
+ stderr,
189
+ spawnError: Object.assign(
190
+ new Error(
191
+ `child process '${cmd}' exceeded ${timeoutMs}ms and was killed`,
192
+ ),
193
+ { code: 'ETIMEDOUT' },
194
+ ),
195
+ timedOut: true,
196
+ });
197
+ }, timeoutMs);
198
+ // Intentionally NOT unref'd: this is a watchdog timer that MUST keep
199
+ // the event loop alive until it fires (or the child settles). A real
200
+ // spawned child keeps the loop alive via its stdio handles, but a
201
+ // child whose handles close early — or a stub in tests — leaves the
202
+ // loop idle; an unref'd timer would then never fire, so the timeout
203
+ // silently would not bound a hung spawn (and the awaiting promise
204
+ // would hang forever). `finish()` always clearTimeout()s it, so the
205
+ // ref'd timer never outlives its purpose.
206
+ }
61
207
  child.stdout?.on('data', (chunk) => {
62
208
  stdout += chunk.toString();
63
209
  });
@@ -68,7 +214,7 @@ export function runChild({ cmd, args, spawnImpl = defaultSpawn, cwd }) {
68
214
  spawnError = err;
69
215
  });
70
216
  child.on('close', (code) => {
71
- resolve({ code, stdout, stderr, spawnError });
217
+ finish({ code, stdout, stderr, spawnError, timedOut: false });
72
218
  });
73
219
  });
74
220
  }
@@ -79,7 +225,7 @@ export function runChild({ cmd, args, spawnImpl = defaultSpawn, cwd }) {
79
225
  * toggle defaults to `true` and only an explicit `false` disables it.
80
226
  *
81
227
  * @param {string} toggleKey — key under `config.delivery.feedbackLoop`
82
- * (e.g. "auditResultsAutoFile", "codeReviewAutoFile")
228
+ * (e.g. "auditResultsAutoFile", "retroProposals")
83
229
  * @returns {(config: object|undefined|null) => boolean}
84
230
  */
85
231
  export function makeIsAutoFileEnabled(toggleKey) {
@@ -91,26 +237,40 @@ export function makeIsAutoFileEnabled(toggleKey) {
91
237
  }
92
238
 
93
239
  /**
94
- * Probe whether the cited path exists in the merged tree at the given
95
- * git ref via `git cat-file -e <ref>:<path>`. Resolves `true` when the
96
- * file is present, `false` otherwise — a spawn failure degrades to
97
- * `false` (we cannot prove existence, so the finding skips with
98
- * `file-removed`).
240
+ * Probe whether the cited path exists in the merged tree at the given git
241
+ * ref, distinguishing a confirmed-missing file from a probe failure.
242
+ * Resolves `{ exists, probeError }`:
243
+ *
244
+ * - `git cat-file -e <ref>:<path>` exit 0 → `{ exists: true, probeError: false }`
245
+ * - clean non-zero exit (file genuinely absent) → `{ exists: false, probeError: false }`
246
+ * - spawn failure / timeout (cannot decide) → `{ exists: false, probeError: true }`
99
247
  *
100
248
  * @param {object} opts
101
249
  * @param {string} opts.ref
102
250
  * @param {string} opts.path
103
251
  * @param {Function} [opts.spawnImpl]
104
252
  * @param {string} [opts.cwd]
253
+ * @param {number} [opts.timeoutMs]
254
+ * @returns {Promise<{ exists: boolean, probeError: boolean }>}
105
255
  */
106
- export async function probePathExists({ ref, path, spawnImpl, cwd }) {
256
+ export async function probePathStatus({
257
+ ref,
258
+ path,
259
+ spawnImpl,
260
+ cwd,
261
+ timeoutMs,
262
+ }) {
107
263
  const res = await runChild({
108
264
  cmd: 'git',
109
265
  args: ['cat-file', '-e', `${ref}:${path}`],
110
266
  spawnImpl,
111
267
  cwd,
268
+ timeoutMs,
112
269
  });
113
- return res.code === 0;
270
+ if (res.spawnError || res.timedOut) {
271
+ return { exists: false, probeError: true };
272
+ }
273
+ return { exists: res.code === 0, probeError: false };
114
274
  }
115
275
 
116
276
  /**
@@ -127,6 +287,7 @@ export async function probeMarkerExists({
127
287
  ghPath,
128
288
  spawnImpl,
129
289
  cwd,
290
+ timeoutMs,
130
291
  }) {
131
292
  const args = [
132
293
  'search',
@@ -139,7 +300,7 @@ export async function probeMarkerExists({
139
300
  '--limit',
140
301
  '1',
141
302
  ];
142
- const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd });
303
+ const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
143
304
  if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
144
305
  return false;
145
306
  }
@@ -166,6 +327,7 @@ export async function createFollowUpIssue({
166
327
  ghPath,
167
328
  spawnImpl,
168
329
  cwd,
330
+ timeoutMs,
169
331
  }) {
170
332
  const args = [
171
333
  'issue',
@@ -180,7 +342,7 @@ export async function createFollowUpIssue({
180
342
  for (const label of labels) {
181
343
  args.push('--label', label);
182
344
  }
183
- const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd });
345
+ const res = await runChild({ cmd: ghPath, args, spawnImpl, cwd, timeoutMs });
184
346
  if (res.spawnError || (typeof res.code === 'number' && res.code !== 0)) {
185
347
  return {
186
348
  url: null,
@@ -193,51 +355,6 @@ export async function createFollowUpIssue({
193
355
  return { url, error: null };
194
356
  }
195
357
 
196
- /**
197
- * Parametrized graduator walk. Reads the Epic's structured comment via
198
- * the injected provider, parses non-blocking findings with the injected
199
- * `parseFindings`, then for each finding runs the shared route → path
200
- * probe → idempotency probe → file sequence. Never throws — every
201
- * failure path is captured in `errors[]`.
202
- *
203
- * The per-graduator variation lives entirely in the injected callbacks:
204
- *
205
- * - `parseFindings(body)` — turns the rendered comment into findings.
206
- * Each finding MUST carry `{ severity, path, summary, index }` and
207
- * MAY carry additional fields (e.g. `lens`) that the builder uses.
208
- * - `buildIdempotencyMarker(epicId, index)` — the HTML-comment marker
209
- * embedded in (and searched for in) follow-up bodies.
210
- * - `buildFollowUp({ finding, source, epicId })` — returns
211
- * `{ title, body, labels }` for the issue to file.
212
- * - `buildCrossRepoLog({ finding, routedRepo })` — returns the
213
- * human-readable would-be-command string logged on a cross-repo skip.
214
- * - `decorateRecord(record, finding)` — copies finding-specific fields
215
- * (e.g. `lens`) onto a `skipped`/`filed` record before it is pushed.
216
- *
217
- * @param {object} opts
218
- * @param {number} opts.epicId
219
- * @param {object} opts.provider — exposes `getTicketComments(ticketId)`
220
- * @param {object} [opts.config]
221
- * @param {{owner: string, repo: string}} opts.currentRepo
222
- * @param {{owner: string, repo: string}} [opts.frameworkRepo]
223
- * @param {string} [opts.gitRef='HEAD']
224
- * @param {Function} [opts.classifier=classifyPathSource]
225
- * @param {string} [opts.ghPath='gh']
226
- * @param {Function} [opts.spawnImpl]
227
- * @param {string} [opts.cwd]
228
- * @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
229
- * @param {object} opts.spec — the per-graduator behaviour bundle
230
- * @param {string} opts.spec.fnName — name used in error-message prefixes
231
- * @param {(config: object|undefined|null) => boolean} opts.spec.isAutoFileEnabled
232
- * @param {string} opts.spec.commentMarker — structured-comment marker to match
233
- * @param {string} opts.spec.noCommentReason — skip reason when absent
234
- * @param {Function} opts.spec.parseFindings
235
- * @param {(epicId: number, index: number) => string} opts.spec.buildIdempotencyMarker
236
- * @param {Function} opts.spec.buildFollowUp
237
- * @param {Function} opts.spec.buildCrossRepoLog
238
- * @param {(record: object, finding: object) => object} [opts.spec.decorateRecord]
239
- * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
240
- */
241
358
  /**
242
359
  * Validate the `graduate` preconditions (toggle, epicId, provider shape,
243
360
  * currentRepo shape). Returns `null` when all preconditions pass, or a
@@ -301,10 +418,55 @@ async function loadGraduateFindings({ epicId, provider, spec }) {
301
418
  return { findings };
302
419
  }
303
420
 
421
+ /**
422
+ * Probe whether a finding was already filed, checking both the current
423
+ * content-hash marker AND the legacy `(epicId, parse-index)` marker so
424
+ * findings filed before the fingerprint cutover are not re-filed. Returns
425
+ * the content-hash marker (embedded in a freshly filed body) alongside the
426
+ * `alreadyFiled` decision.
427
+ */
428
+ async function resolveAlreadyFiled({
429
+ finding,
430
+ epicId,
431
+ routedRepo,
432
+ ghPath,
433
+ spawnImpl,
434
+ cwd,
435
+ timeoutMs,
436
+ spec,
437
+ }) {
438
+ const contentMarker = spec.buildContentMarker(epicId, finding);
439
+ const probe = (marker) =>
440
+ probeMarkerExists({
441
+ marker,
442
+ owner: routedRepo.owner,
443
+ repo: routedRepo.repo,
444
+ ghPath,
445
+ spawnImpl,
446
+ cwd,
447
+ timeoutMs,
448
+ });
449
+
450
+ if (await probe(contentMarker)) {
451
+ return { alreadyFiled: true, contentMarker };
452
+ }
453
+ // Legacy recognition — a pre-cutover follow-up carries the ordinal
454
+ // marker, not the content hash. Skip re-filing when it is present.
455
+ if (typeof spec.buildLegacyMarker === 'function') {
456
+ const legacyMarker = spec.buildLegacyMarker(epicId, finding.index);
457
+ if (legacyMarker && (await probe(legacyMarker))) {
458
+ return { alreadyFiled: true, contentMarker };
459
+ }
460
+ }
461
+ return { alreadyFiled: false, contentMarker };
462
+ }
463
+
304
464
  /**
305
465
  * Route a single finding (path-exists probe → repo routing → idempotency
306
- * probe → file) and fold the outcome into the running envelope. Story #4075
307
- * extracted from `graduate`'s per-finding loop body.
466
+ * probe → cap → file) and fold the outcome into the running envelope. Story
467
+ * #4075 extracted this from `graduate`'s per-finding loop; Story #4415
468
+ * hardened it (path-less seam, probe-error distinction, legacy-marker
469
+ * recognition, filing cap, and cross-repo-deferred collection).
308
470
  */
309
471
  async function processGraduateFinding({
310
472
  finding,
@@ -318,6 +480,9 @@ async function processGraduateFinding({
318
480
  ghPath,
319
481
  spawnImpl,
320
482
  cwd,
483
+ timeoutMs,
484
+ maxFilingsPerRun,
485
+ crossRepoDeferred,
321
486
  logger,
322
487
  spec,
323
488
  }) {
@@ -334,13 +499,24 @@ async function processGraduateFinding({
334
499
  ),
335
500
  );
336
501
 
337
- const exists = await probePathExists({
338
- ref: gitRef,
339
- path: finding.path,
340
- spawnImpl,
341
- cwd,
342
- });
343
- if (!exists) return skip('file-removed');
502
+ // Path-less findings (the retro auto-filer seam) are not file-scoped, so
503
+ // the path-exists gate does not apply — probing an empty path would
504
+ // misclassify them `file-removed`. Only file-scoped findings are probed.
505
+ const hasPath =
506
+ typeof finding.path === 'string' && finding.path.trim().length > 0;
507
+ if (hasPath) {
508
+ const { exists, probeError } = await probePathStatus({
509
+ ref: gitRef,
510
+ path: finding.path,
511
+ spawnImpl,
512
+ cwd,
513
+ timeoutMs,
514
+ });
515
+ // A probe failure is not proof the file is gone — record it distinctly
516
+ // so a transient git/spawn fault does not masquerade as a removal.
517
+ if (probeError) return skip('probe-error');
518
+ if (!exists) return skip('file-removed');
519
+ }
344
520
 
345
521
  const source = classifier(finding.path, null);
346
522
  const routedRepo =
@@ -349,26 +525,34 @@ async function processGraduateFinding({
349
525
  routedRepo.owner !== currentRepo.owner ||
350
526
  routedRepo.repo !== currentRepo.repo;
351
527
  if (isCrossRepo) {
352
- logger?.info?.(spec.buildCrossRepoLog({ finding, routedRepo, source }));
528
+ const logLine = spec.buildCrossRepoLog({ finding, routedRepo, source });
529
+ logger?.info?.(logLine);
530
+ crossRepoDeferred.push({ finding, routedRepo, source, logLine });
353
531
  return skip('cross-repo-deferred');
354
532
  }
355
533
 
356
- const idMarker = spec.buildIdempotencyMarker(epicId, finding.index);
357
- const alreadyFiled = await probeMarkerExists({
358
- marker: idMarker,
359
- owner: routedRepo.owner,
360
- repo: routedRepo.repo,
534
+ const { alreadyFiled, contentMarker } = await resolveAlreadyFiled({
535
+ finding,
536
+ epicId,
537
+ routedRepo,
361
538
  ghPath,
362
539
  spawnImpl,
363
540
  cwd,
541
+ timeoutMs,
542
+ spec,
364
543
  });
365
544
  if (alreadyFiled) return skip('already-filed');
366
545
 
546
+ // Per-run filing cap — count only actual filings (already-filed and
547
+ // skipped findings do not consume the budget). The excess is surfaced so
548
+ // a re-run picks it up next time.
549
+ if (envelope.filed.length >= maxFilingsPerRun) return skip('cap-reached');
550
+
367
551
  const { title, body, labels } = spec.buildFollowUp({
368
552
  finding,
369
553
  source,
370
554
  epicId,
371
- idMarker,
555
+ idMarker: contentMarker,
372
556
  });
373
557
  const created = await createFollowUpIssue({
374
558
  owner: routedRepo.owner,
@@ -379,6 +563,7 @@ async function processGraduateFinding({
379
563
  ghPath,
380
564
  spawnImpl,
381
565
  cwd,
566
+ timeoutMs,
382
567
  });
383
568
  if (created.error) {
384
569
  envelope.errors.push(
@@ -401,6 +586,105 @@ async function processGraduateFinding({
401
586
  );
402
587
  }
403
588
 
589
+ /**
590
+ * Render the durable cross-repo-deferred comment body from the collected
591
+ * deferrals. Each row names the finding path/severity plus the would-be
592
+ * `gh issue create` command so an operator (or a later cross-repo pass)
593
+ * can act on it.
594
+ */
595
+ function renderCrossRepoDeferredBody(deferred, spec) {
596
+ const header =
597
+ spec.crossRepoCommentHeader ??
598
+ '### Cross-repo-deferred findings\n\nThese findings route to a different repository and were **not** filed here. They are recorded for a cross-repo follow-up pass.';
599
+ const rows = deferred.map(({ finding, routedRepo, logLine }) => {
600
+ const path =
601
+ typeof finding.path === 'string' && finding.path.length > 0
602
+ ? `\`${finding.path}\``
603
+ : '_(no path)_';
604
+ return [
605
+ `- ${path} (severity: ${finding.severity ?? 'n/a'}) → ${routedRepo.owner}/${routedRepo.repo}`,
606
+ ` - ${logLine}`,
607
+ ].join('\n');
608
+ });
609
+ return [header, '', ...rows].join('\n');
610
+ }
611
+
612
+ /**
613
+ * Durably persist the cross-repo-deferred findings as a structured comment
614
+ * on the Epic (upserted — one comment per graduator, refreshed in place).
615
+ * Best-effort: a provider that cannot post comments is a no-op, and an
616
+ * upsert failure lands in `envelope.errors` rather than throwing.
617
+ */
618
+ async function persistCrossRepoDeferred({
619
+ epicId,
620
+ provider,
621
+ crossRepoDeferred,
622
+ spec,
623
+ envelope,
624
+ }) {
625
+ if (typeof provider?.postComment !== 'function') return;
626
+ try {
627
+ const body = renderCrossRepoDeferredBody(crossRepoDeferred, spec);
628
+ await upsertStructuredComment(
629
+ provider,
630
+ epicId,
631
+ CROSS_REPO_DEFERRED_COMMENT_TYPE,
632
+ body,
633
+ spec.crossRepoCommentAttrs ?? null,
634
+ );
635
+ } catch (err) {
636
+ envelope.errors.push(
637
+ `cross-repo-deferred comment upsert failed: ${err?.message ?? err}`,
638
+ );
639
+ }
640
+ }
641
+
642
+ /**
643
+ * Parametrized graduator walk. Parses non-blocking findings (from the
644
+ * Epic's structured comment, or a pre-parsed `findings` array), then for
645
+ * each finding runs the shared route → path probe → idempotency probe →
646
+ * cap → file sequence. Never throws — every failure path is captured in
647
+ * `errors[]`.
648
+ *
649
+ * The per-graduator variation lives entirely in the injected callbacks:
650
+ *
651
+ * - `parseFindings(body)` — turns the rendered comment into findings.
652
+ * Each finding MUST carry `{ severity, path, summary, index }` and
653
+ * MAY carry additional fields (e.g. `lens`) that the builder uses.
654
+ * - `buildContentMarker(epicId, finding)` — the content-hash HTML-comment
655
+ * marker embedded in (and searched for in) follow-up bodies.
656
+ * - `buildLegacyMarker(epicId, index)` — the pre-cutover ordinal marker,
657
+ * probed for idempotency so legacy filings are not duplicated.
658
+ * - `buildFollowUp({ finding, source, epicId, idMarker })` — returns
659
+ * `{ title, body, labels }` for the issue to file.
660
+ * - `buildCrossRepoLog({ finding, routedRepo, source })` — returns the
661
+ * human-readable would-be-command string for a cross-repo skip.
662
+ * - `decorateRecord(record, finding)` — copies finding-specific fields
663
+ * (e.g. `lens`) onto a `skipped`/`filed` record before it is pushed.
664
+ * - `crossRepoCommentAttrs` — discriminator attrs for the durable
665
+ * cross-repo-deferred comment (so the two graduators do not clobber
666
+ * each other's comment).
667
+ *
668
+ * @param {object} opts
669
+ * @param {number} opts.epicId
670
+ * @param {object} opts.provider — exposes `getTicketComments(ticketId)`
671
+ * @param {object} [opts.config]
672
+ * @param {{owner: string, repo: string}} opts.currentRepo
673
+ * @param {{owner: string, repo: string}} [opts.frameworkRepo]
674
+ * @param {string} [opts.gitRef='HEAD']
675
+ * @param {Function} [opts.classifier=classifyPathSource]
676
+ * @param {string} [opts.ghPath='gh']
677
+ * @param {Function} [opts.spawnImpl]
678
+ * @param {string} [opts.cwd]
679
+ * @param {number} [opts.timeoutMs] — per-spawn watchdog bound
680
+ * @param {number} [opts.maxFilingsPerRun] — per-run filing cap
681
+ * @param {Array<object>} [opts.findings] — pre-parsed findings; when
682
+ * provided, the structured-comment read/parse is bypassed (the retro
683
+ * auto-filer seam).
684
+ * @param {{info?: Function, warn?: Function, debug?: Function}} [opts.logger]
685
+ * @param {object} opts.spec — the per-graduator behaviour bundle
686
+ * @returns {Promise<{ filed: object[], skipped: object[], errors: string[] }>}
687
+ */
404
688
  export async function graduate({
405
689
  epicId,
406
690
  provider,
@@ -412,6 +696,9 @@ export async function graduate({
412
696
  ghPath = 'gh',
413
697
  spawnImpl,
414
698
  cwd,
699
+ timeoutMs = DEFAULT_RUN_CHILD_TIMEOUT_MS,
700
+ maxFilingsPerRun = DEFAULT_MAX_FILINGS_PER_RUN,
701
+ findings: preParsedFindings,
415
702
  logger,
416
703
  spec,
417
704
  }) {
@@ -430,10 +717,19 @@ export async function graduate({
430
717
  });
431
718
  if (precondition) return { ...envelope, ...precondition };
432
719
 
433
- const loaded = await loadGraduateFindings({ epicId, provider, spec });
434
- if (!loaded.findings) return { ...envelope, ...loaded };
720
+ let findings;
721
+ if (Array.isArray(preParsedFindings)) {
722
+ // Pre-parsed seam (retro auto-filer): bypass the structured-comment
723
+ // read + parse entirely and file the supplied findings directly.
724
+ findings = preParsedFindings;
725
+ } else {
726
+ const loaded = await loadGraduateFindings({ epicId, provider, spec });
727
+ if (!loaded.findings) return { ...envelope, ...loaded };
728
+ findings = loaded.findings;
729
+ }
435
730
 
436
- for (const finding of loaded.findings) {
731
+ const crossRepoDeferred = [];
732
+ for (const finding of findings) {
437
733
  await processGraduateFinding({
438
734
  finding,
439
735
  envelope,
@@ -446,10 +742,23 @@ export async function graduate({
446
742
  ghPath,
447
743
  spawnImpl,
448
744
  cwd,
745
+ timeoutMs,
746
+ maxFilingsPerRun,
747
+ crossRepoDeferred,
449
748
  logger,
450
749
  spec,
451
750
  });
452
751
  }
453
752
 
753
+ if (crossRepoDeferred.length > 0) {
754
+ await persistCrossRepoDeferred({
755
+ epicId,
756
+ provider,
757
+ crossRepoDeferred,
758
+ spec,
759
+ envelope,
760
+ });
761
+ }
762
+
454
763
  return envelope;
455
764
  }