mandrel 1.88.0 → 1.90.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 (145) 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 +62 -27
  17. package/.agents/docs/configuration.md +5 -4
  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 +10 -6
  25. package/.agents/schemas/audit-rules.json +16 -2
  26. package/.agents/schemas/audit-rules.schema.json +7 -6
  27. package/.agents/schemas/lifecycle/epic.blocked.schema.json +1 -1
  28. package/.agents/schemas/lifecycle/merge.unlanded.schema.json +39 -0
  29. package/.agents/schemas/signal-event.schema.json +28 -13
  30. package/.agents/scripts/acceptance-spec-reconciler.js +6 -4
  31. package/.agents/scripts/check-context-budget.js +320 -0
  32. package/.agents/scripts/coverage-capture.js +17 -0
  33. package/.agents/scripts/diagnose-friction.js +4 -4
  34. package/.agents/scripts/epic-audit-prepare.js +30 -2
  35. package/.agents/scripts/epic-audit-recheck.js +46 -13
  36. package/.agents/scripts/epic-deliver-prepare.js +80 -8
  37. package/.agents/scripts/epic-plan-spec.js +4 -8
  38. package/.agents/scripts/generate-lens-checklists.js +180 -0
  39. package/.agents/scripts/lib/audit-suite/checklist-threading.js +300 -0
  40. package/.agents/scripts/lib/audit-suite/findings.js +27 -0
  41. package/.agents/scripts/lib/audit-suite/index.js +9 -0
  42. package/.agents/scripts/lib/audit-suite/lens-checklist.js +212 -0
  43. package/.agents/scripts/lib/audit-suite/selector.js +136 -5
  44. package/.agents/scripts/lib/checks/loop-health.js +340 -0
  45. package/.agents/scripts/lib/cli-args.js +8 -0
  46. package/.agents/scripts/lib/close-validation/gates.js +64 -24
  47. package/.agents/scripts/lib/config/ci.js +12 -1
  48. package/.agents/scripts/lib/config/runners.js +13 -5
  49. package/.agents/scripts/lib/config/temp-paths.js +24 -0
  50. package/.agents/scripts/lib/config-settings-schema-delivery.js +28 -8
  51. package/.agents/scripts/lib/doc-tiers.js +291 -0
  52. package/.agents/scripts/lib/epic-body-sections.js +5 -2
  53. package/.agents/scripts/lib/epic-merge-lock.js +83 -0
  54. package/.agents/scripts/lib/epic-plan-clarity.js +3 -1
  55. package/.agents/scripts/lib/feedback-loop/audit-results-graduator.js +47 -15
  56. package/.agents/scripts/lib/feedback-loop/graduator-core.js +395 -86
  57. package/.agents/scripts/lib/feedback-loop/memory-freshness.js +299 -72
  58. package/.agents/scripts/lib/feedback-loop/retro-proposals-graduator.js +438 -0
  59. package/.agents/scripts/lib/gates/friction.js +15 -5
  60. package/.agents/scripts/lib/npm-scripts.js +55 -0
  61. package/.agents/scripts/lib/observability/perf-aggregator.js +30 -104
  62. package/.agents/scripts/lib/observability/perf-report-readers.js +1 -1
  63. package/.agents/scripts/lib/observability/signal-validator.js +204 -0
  64. package/.agents/scripts/lib/observability/signals-writer.js +157 -54
  65. package/.agents/scripts/lib/observability/tool-trace-hook.js +42 -4
  66. package/.agents/scripts/lib/orchestration/acceptance-eval-decision.js +1 -1
  67. package/.agents/scripts/lib/orchestration/code-review.js +74 -4
  68. package/.agents/scripts/lib/orchestration/consolidation-precondition.js +213 -0
  69. package/.agents/scripts/lib/orchestration/doc-reader.js +4 -96
  70. package/.agents/scripts/lib/orchestration/docs-digest.js +34 -0
  71. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +56 -19
  72. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/run-spec-phase.js +22 -0
  73. package/.agents/scripts/lib/orchestration/lifecycle/emit-merge-unlanded.js +193 -0
  74. package/.agents/scripts/lib/orchestration/lifecycle/listeners/README.md +6 -0
  75. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-armer.js +248 -13
  76. package/.agents/scripts/lib/orchestration/lifecycle/listeners/automerge-predicate.js +109 -12
  77. package/.agents/scripts/lib/orchestration/lifecycle/listeners/finalizer.js +47 -61
  78. package/.agents/scripts/lib/orchestration/lifecycle/listeners/index.js +46 -4
  79. package/.agents/scripts/lib/orchestration/lifecycle/listeners/label-transitioner.js +144 -0
  80. package/.agents/scripts/lib/orchestration/lifecycle/listeners/merge-watcher.js +258 -14
  81. package/.agents/scripts/lib/orchestration/lifecycle/listeners/notify-dispatcher.js +6 -0
  82. package/.agents/scripts/lib/orchestration/merge-block-class.js +246 -0
  83. package/.agents/scripts/lib/orchestration/plan-review-routing.js +1 -1
  84. package/.agents/scripts/lib/orchestration/post-merge/phases/worktree-reap.js +3 -3
  85. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -34
  86. package/.agents/scripts/lib/orchestration/retro/phases/gather-signals.js +167 -52
  87. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +49 -2
  88. package/.agents/scripts/lib/orchestration/retro-proposals.js +12 -55
  89. package/.agents/scripts/lib/orchestration/retro-runner.js +9 -0
  90. package/.agents/scripts/lib/orchestration/single-story-close/phases/close-validation.js +5 -1
  91. package/.agents/scripts/lib/orchestration/single-story-close/phases/code-review.js +8 -0
  92. package/.agents/scripts/lib/orchestration/single-story-close/phases/confirm-merge.js +419 -0
  93. package/.agents/scripts/lib/orchestration/single-story-close/phases/options.js +35 -2
  94. package/.agents/scripts/lib/orchestration/single-story-close/phases/wrong-tree-guard.js +353 -69
  95. package/.agents/scripts/lib/orchestration/single-story-close/runner.js +66 -4
  96. package/.agents/scripts/lib/orchestration/spec-section-validator.js +60 -9
  97. package/.agents/scripts/lib/orchestration/story-close/auto-refresh-runner.js +7 -5
  98. package/.agents/scripts/lib/orchestration/story-close/merge-runner.js +24 -2
  99. package/.agents/scripts/lib/orchestration/story-close/phases/code-review.js +167 -8
  100. package/.agents/scripts/lib/orchestration/story-close/pre-merge-validation.js +8 -1
  101. package/.agents/scripts/lib/orchestration/story-close/shared-checkout-guard.js +163 -0
  102. package/.agents/scripts/lib/orchestration/ticketing/reads.js +20 -9
  103. package/.agents/scripts/lib/planning-corpus.js +306 -0
  104. package/.agents/scripts/lib/signals/detectors/common.js +10 -10
  105. package/.agents/scripts/lib/signals/detectors/index.js +4 -4
  106. package/.agents/scripts/lib/signals/detectors/retry.js +19 -18
  107. package/.agents/scripts/lib/signals/detectors/rework.js +1 -1
  108. package/.agents/scripts/lib/signals/schema.js +56 -81
  109. package/.agents/scripts/lib/signals/span-tree.js +6 -5
  110. package/.agents/scripts/lib/story-plan.js +3 -0
  111. package/.agents/scripts/lib/wave-runner/tick.js +10 -2
  112. package/.agents/scripts/lifecycle-emit.js +39 -8
  113. package/.agents/scripts/providers/github/issues.js +12 -1
  114. package/.agents/scripts/resolve-doc-tiers.js +83 -0
  115. package/.agents/scripts/retro-run.js +51 -0
  116. package/.agents/scripts/signals-view.js +1 -1
  117. package/.agents/scripts/single-story-close.js +20 -1
  118. package/.agents/scripts/standalone-feedback-rollup.js +188 -0
  119. package/.agents/scripts/story-close.js +48 -0
  120. package/.agents/scripts/story-plan.js +51 -12
  121. package/.agents/scripts/validate-docs-freshness.js +69 -15
  122. package/.agents/skills/core/documentation-and-adrs/SKILL.md +58 -0
  123. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +5 -3
  124. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +20 -7
  125. package/.agents/skills/core/scope-triage/SKILL.md +61 -0
  126. package/.agents/skills/skills.index.json +3 -3
  127. package/.agents/workflows/audit-documentation.md +82 -2
  128. package/.agents/workflows/helpers/code-review.md +116 -43
  129. package/.agents/workflows/helpers/deliver-epic.md +123 -54
  130. package/.agents/workflows/helpers/deliver-stories.md +26 -0
  131. package/.agents/workflows/helpers/epic-audit.md +116 -366
  132. package/.agents/workflows/helpers/epic-deliver-story.md +14 -0
  133. package/.agents/workflows/helpers/epic-plan-decompose.md +18 -200
  134. package/.agents/workflows/helpers/epic-plan-spec.md +18 -180
  135. package/.agents/workflows/helpers/plan-epic.md +141 -105
  136. package/.agents/workflows/helpers/plan-story.md +32 -0
  137. package/.agents/workflows/helpers/single-story-deliver.md +43 -0
  138. package/.agents/workflows/loops/nightly-audit.md +9 -7
  139. package/docs/CHANGELOG.md +29 -0
  140. package/lib/cli/doctor.js +44 -0
  141. package/package.json +4 -3
  142. package/.agents/scripts/epic-plan-spec-validate.js +0 -111
  143. package/.agents/scripts/lib/feedback-loop/code-review-graduator.js +0 -224
  144. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/prompts.js +0 -58
  145. package/.agents/scripts/lib/signals/detectors/hotspot.js +0 -292
@@ -42,6 +42,7 @@ import {
42
42
  storyTempDir,
43
43
  } from '../config/temp-paths.js';
44
44
  import { Logger } from '../Logger.js';
45
+ import { recordSignalReject, validateSignal } from './signal-validator.js';
45
46
  import { classifyPathSource } from './source-classifier.js';
46
47
 
47
48
  const TRACES_BASENAME = 'traces.ndjson';
@@ -60,20 +61,23 @@ function tracesFile(eid, sid, config) {
60
61
  * Best-effort decoration of a signal record with a `source` field
61
62
  * (`"framework"` or `"consumer"`) produced by `classifyPathSource`.
62
63
  *
63
- * Rules (Epic #2547 / Story #2553 / Tech Spec #2550):
64
+ * Post the Epic #4406 cutover `source` is reserved **exclusively** for
65
+ * this framework/consumer classification — a record's originating tool
66
+ * lives in `emitter`, never `source`. That freed the classifier to run
67
+ * for every friction record (pre-cutover a provenance object under the
68
+ * `source` key blocked it via `Object.hasOwn`).
69
+ *
70
+ * Rules:
64
71
  * - If the record is not a plain object (string, number, null,
65
- * undefined), return it unchanged — the writer's existing
66
- * serialisation guard will reject or pass it through as before.
67
- * - If the caller pre-set `signal.source`, preserve it verbatim. Some
68
- * detectors classify upstream (e.g. wave-lifecycle signals always
69
- * belong to the framework) and we MUST NOT overwrite their
70
- * intentional tag.
71
- * - Otherwise, invoke `classifyPathSource` against the record's
72
- * `failingPath` / `path` and `command` fields, and inject the result
73
- * as a new `source` key. The classifier itself never throws, but we
74
- * belt-and-braces a try/catch so an unexpected fault degrades to a
75
- * `Logger.warn` and a passthrough of the original signal — never a
76
- * dropped write.
72
+ * undefined), return it unchanged.
73
+ * - If the caller pre-set `source` to exactly `"framework"` or
74
+ * `"consumer"`, preserve it verbatim — some detectors classify
75
+ * upstream and we MUST NOT overwrite their intentional tag.
76
+ * - Otherwise (absent, or any other value — defense in depth against a
77
+ * stray non-canonical `source`), invoke `classifyPathSource` against
78
+ * the record's `failingPath` / `path` and `command` /
79
+ * `emitter.command` fields and inject/overwrite `source` with the
80
+ * result.
77
81
  *
78
82
  * @param {unknown} signal
79
83
  * @returns {unknown}
@@ -82,16 +86,17 @@ function tagSignalSource(signal) {
82
86
  if (signal === null || typeof signal !== 'object' || Array.isArray(signal)) {
83
87
  return signal;
84
88
  }
85
- // Caller-supplied source wins, even when undefined-typed but present as
86
- // an own property — only inject when the key is absent entirely so we
87
- // never overwrite an explicit decision.
88
- if (Object.hasOwn(signal, 'source')) {
89
- return signal;
89
+ const record = /** @type {Record<string, unknown>} */ (signal);
90
+ if (record.source === 'framework' || record.source === 'consumer') {
91
+ return record;
90
92
  }
91
93
  try {
92
- const record = /** @type {Record<string, unknown>} */ (signal);
93
94
  const failingPath = record.failingPath ?? record.path;
94
- const command = record.command;
95
+ const emitter =
96
+ record.emitter && typeof record.emitter === 'object'
97
+ ? /** @type {Record<string, unknown>} */ (record.emitter)
98
+ : null;
99
+ const command = record.command ?? emitter?.command;
95
100
  const source = classifyPathSource(failingPath, command);
96
101
  return { ...record, source };
97
102
  } catch (err) {
@@ -104,6 +109,27 @@ function tagSignalSource(signal) {
104
109
  }
105
110
  }
106
111
 
112
+ /**
113
+ * Validate a record against the canonical `signal-event.schema.json`
114
+ * before it is appended. On failure the record is **dropped** (never
115
+ * appended), a `Logger.warn` names the violating field, and the per-Epic
116
+ * reject tally is incremented under the Epic temp tree. Never throws —
117
+ * the writer's best-effort contract is preserved.
118
+ *
119
+ * @param {unknown} record
120
+ * @param {{ epicId?: number|null, config?: object, label: string }} ctx
121
+ * @returns {Promise<boolean>} true when the record is valid (safe to append).
122
+ */
123
+ async function validateOrDrop(record, { epicId, config, label }) {
124
+ const { valid, violatingField, message } = validateSignal(record);
125
+ if (valid) return true;
126
+ Logger.warn(
127
+ `signals-writer: dropping schema-invalid ${label} record — violating field '${violatingField}' (${message}).`,
128
+ );
129
+ await recordSignalReject({ epicId, config, field: violatingField });
130
+ return false;
131
+ }
132
+
107
133
  /**
108
134
  * Best-effort append of a single record as one newline-terminated JSON
109
135
  * line. Caller-supplied record must be JSON-serialisable; circular refs
@@ -163,14 +189,24 @@ export async function appendSignal(args) {
163
189
  );
164
190
  return false;
165
191
  }
166
- return appendOne(target, tagSignalSource(signal));
192
+ const tagged = tagSignalSource(signal);
193
+ const ok = await validateOrDrop(tagged, {
194
+ epicId: Number.isInteger(epicId) ? epicId : null,
195
+ config,
196
+ label: 'signal',
197
+ });
198
+ if (!ok) return false;
199
+ return appendOne(target, tagged);
167
200
  }
168
201
 
169
202
  /**
170
203
  * Append one signal record to the per-Epic stream at
171
- * `temp/epic-<eid>/signals.ndjson` — used for wave-lifecycle signals
172
- * (`wave-start`, `wave-tick`, `wave-complete`, `epic-complete`) that are
173
- * not scoped to an individual Story.
204
+ * `temp/epic-<eid>/signals.ndjson` — used for Epic-scoped signals not tied
205
+ * to an individual Story: the wave-window forensics kinds (`wave-start`,
206
+ * `wave-complete`), notification-derived anchors (`state-transition`,
207
+ * `notification.emitted`), and Epic-level `friction` (e.g.
208
+ * `lifecycle-emit.js`). The retired write-only kinds `wave-tick` /
209
+ * `epic-complete` are gone (Story #3909) and are NOT valid here.
174
210
  *
175
211
  * @param {{ epicId: number, signal: unknown, config?: object }} args
176
212
  * @returns {Promise<boolean>}
@@ -188,7 +224,14 @@ export async function appendEpicSignal(args) {
188
224
  );
189
225
  return false;
190
226
  }
191
- return appendOne(target, tagSignalSource(signal));
227
+ const tagged = tagSignalSource(signal);
228
+ const ok = await validateOrDrop(tagged, {
229
+ epicId: Number.isInteger(epicId) ? epicId : null,
230
+ config,
231
+ label: 'epic signal',
232
+ });
233
+ if (!ok) return false;
234
+ return appendOne(target, tagged);
192
235
  }
193
236
 
194
237
  /**
@@ -211,41 +254,32 @@ export async function appendTrace(args) {
211
254
  );
212
255
  return false;
213
256
  }
257
+ const ok = await validateOrDrop(trace, {
258
+ epicId: Number.isInteger(epicId) ? epicId : null,
259
+ config,
260
+ label: 'trace',
261
+ });
262
+ if (!ok) return false;
214
263
  return appendOne(target, trace);
215
264
  }
216
265
 
217
266
  /**
218
- * Stream `signals.ndjson` line by line, invoking `cb(parsed, lineNumber)`
219
- * for each successfully parsed JSON line. `lineNumber` is 1-based to
220
- * match operator log expectations. Malformed lines are skipped with a
221
- * `Logger.warn`. A missing file resolves with `missing: true` rather
222
- * than throwing — the analyzer treats absence as "no signals yet" and
223
- * keeps walking.
267
+ * Stream any NDJSON `target` file line by line, invoking
268
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line.
269
+ * `lineNumber` is 1-based to match operator log expectations. Malformed
270
+ * lines are skipped with a `Logger.warn`. A missing file resolves with
271
+ * `missing: true` rather than throwing.
224
272
  *
225
- * @param {number} epicId
226
- * @param {number} storyId
273
+ * Shared spine for `forEachLine` (per-Story stream) and `forEachEpicLine`
274
+ * (per-Epic stream) so the two readers cannot drift in their
275
+ * malformed-line / missing-file / cb-throw handling.
276
+ *
277
+ * @param {string} target
227
278
  * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
228
- * @param {object} [config]
279
+ * @param {string} label Reader name used in warn messages.
229
280
  * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
230
281
  */
231
- export async function forEachLine(epicId, storyId, cb, config) {
232
- if (typeof cb !== 'function') {
233
- Logger.warn('signals-writer: forEachLine called without a callback');
234
- return { linesRead: 0, linesParsed: 0, missing: false };
235
- }
236
-
237
- let target;
238
- try {
239
- target = signalsFile(epicId, storyId, config);
240
- } catch (err) {
241
- Logger.warn(
242
- `signals-writer: invalid epicId/storyId for forEachLine: ${
243
- err instanceof Error ? err.message : String(err)
244
- }`,
245
- );
246
- return { linesRead: 0, linesParsed: 0, missing: false };
247
- }
248
-
282
+ async function forEachLineIn(target, cb, label) {
249
283
  try {
250
284
  await fs.access(target);
251
285
  } catch {
@@ -278,7 +312,7 @@ export async function forEachLine(epicId, storyId, cb, config) {
278
312
  await cb(parsed, linesRead);
279
313
  } catch (err) {
280
314
  Logger.warn(
281
- `signals-writer: forEachLine cb threw at ${target}:${linesRead}: ${
315
+ `signals-writer: ${label} cb threw at ${target}:${linesRead}: ${
282
316
  err instanceof Error ? err.message : String(err)
283
317
  }`,
284
318
  );
@@ -286,7 +320,7 @@ export async function forEachLine(epicId, storyId, cb, config) {
286
320
  }
287
321
  } catch (err) {
288
322
  Logger.warn(
289
- `signals-writer: forEachLine read failed for ${target}: ${
323
+ `signals-writer: ${label} read failed for ${target}: ${
290
324
  err instanceof Error ? err.message : String(err)
291
325
  }`,
292
326
  );
@@ -294,3 +328,72 @@ export async function forEachLine(epicId, storyId, cb, config) {
294
328
 
295
329
  return { linesRead, linesParsed, missing: false };
296
330
  }
331
+
332
+ /**
333
+ * Stream a per-Story `signals.ndjson` line by line, invoking
334
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line. A
335
+ * missing file resolves with `missing: true` rather than throwing — the
336
+ * analyzer treats absence as "no signals yet" and keeps walking.
337
+ *
338
+ * @param {number} epicId
339
+ * @param {number} storyId
340
+ * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
341
+ * @param {object} [config]
342
+ * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
343
+ */
344
+ export async function forEachLine(epicId, storyId, cb, config) {
345
+ if (typeof cb !== 'function') {
346
+ Logger.warn('signals-writer: forEachLine called without a callback');
347
+ return { linesRead: 0, linesParsed: 0, missing: false };
348
+ }
349
+
350
+ let target;
351
+ try {
352
+ target = signalsFile(epicId, storyId, config);
353
+ } catch (err) {
354
+ Logger.warn(
355
+ `signals-writer: invalid epicId/storyId for forEachLine: ${
356
+ err instanceof Error ? err.message : String(err)
357
+ }`,
358
+ );
359
+ return { linesRead: 0, linesParsed: 0, missing: false };
360
+ }
361
+
362
+ return forEachLineIn(target, cb, 'forEachLine');
363
+ }
364
+
365
+ /**
366
+ * Stream the per-Epic `signals.ndjson` line by line, invoking
367
+ * `cb(parsed, lineNumber)` for each successfully parsed JSON line. This is
368
+ * the read side of `appendEpicSignal` — the wave-lifecycle stream at
369
+ * `temp/epic-<eid>/signals.ndjson` written by `appendEpicSignal` callers
370
+ * (e.g. `lifecycle-emit`). A missing file resolves with `missing: true`
371
+ * rather than throwing, mirroring the per-Story `forEachLine` contract so
372
+ * the retro's unified counts scan can fold the Epic stream in alongside the
373
+ * Story streams without a special-cased absence branch.
374
+ *
375
+ * @param {number} epicId
376
+ * @param {(parsed: unknown, lineNumber: number) => unknown | Promise<unknown>} cb
377
+ * @param {object} [config]
378
+ * @returns {Promise<{ linesRead: number, linesParsed: number, missing: boolean }>}
379
+ */
380
+ export async function forEachEpicLine(epicId, cb, config) {
381
+ if (typeof cb !== 'function') {
382
+ Logger.warn('signals-writer: forEachEpicLine called without a callback');
383
+ return { linesRead: 0, linesParsed: 0, missing: false };
384
+ }
385
+
386
+ let target;
387
+ try {
388
+ target = epicArtifactPath(epicId, EPIC_SIGNALS_BASENAME, config);
389
+ } catch (err) {
390
+ Logger.warn(
391
+ `signals-writer: invalid epicId for forEachEpicLine: ${
392
+ err instanceof Error ? err.message : String(err)
393
+ }`,
394
+ );
395
+ return { linesRead: 0, linesParsed: 0, missing: false };
396
+ }
397
+
398
+ return forEachLineIn(target, cb, 'forEachEpicLine');
399
+ }
@@ -220,10 +220,10 @@ function hashBashInput(toolInput) {
220
220
  * (`tool_input.file_path`, `tool_input.path`, `tool_input.pattern`)
221
221
  * before recording. The raw value never appears on disk.
222
222
  *
223
- * @param {{ tool: string, toolInput?: object, durationMs?: number|null }} args
223
+ * @param {{ tool: string, toolInput?: object, durationMs?: number|null, exitCode?: number|null }} args
224
224
  * @returns {object}
225
225
  */
226
- function buildDetails({ tool, toolInput, durationMs }) {
226
+ function buildDetails({ tool, toolInput, durationMs, exitCode }) {
227
227
  const details = {};
228
228
  if (typeof durationMs === 'number') {
229
229
  details.durationMs = durationMs;
@@ -231,6 +231,14 @@ function buildDetails({ tool, toolInput, durationMs }) {
231
231
  details.durationMs = null;
232
232
  }
233
233
 
234
+ // Bash exit code — the retry detector's failure predicate
235
+ // (`details.exitCode !== 0`) can only fire once this is recorded. Only
236
+ // Bash PostToolUse events carry a meaningful exit code; other tools omit
237
+ // the field entirely (Epic #4406 / Story #4413).
238
+ if (tool === 'Bash' && typeof exitCode === 'number') {
239
+ details.exitCode = exitCode;
240
+ }
241
+
234
242
  if (toolInput && typeof toolInput === 'object') {
235
243
  // Bash: hash `command` so a token-laden string never lands on disk.
236
244
  // Story #1768 also records `normalizedHash` (paraphrase-collapsed)
@@ -273,6 +281,35 @@ function buildDetails({ tool, toolInput, durationMs }) {
273
281
  return details;
274
282
  }
275
283
 
284
+ /**
285
+ * Extract the Bash exit code from a PostToolUse event. The harness reports
286
+ * the tool result under `tool_response` (occasionally at the event root);
287
+ * the exit-code field name is not contractually fixed across harness
288
+ * versions, so we probe the known aliases and return the first numeric
289
+ * hit. Returns `null` when no numeric exit code is present — the caller
290
+ * omits `details.exitCode` entirely rather than recording a guess.
291
+ *
292
+ * Exported for testing.
293
+ *
294
+ * @param {object} event
295
+ * @returns {number|null}
296
+ */
297
+ export function extractExitCode(event) {
298
+ const candidates = [
299
+ event?.tool_response?.exitCode,
300
+ event?.tool_response?.exit_code,
301
+ event?.tool_response?.returnCode,
302
+ event?.tool_response?.code,
303
+ event?.tool_response?.status,
304
+ event?.exit_code,
305
+ event?.exitCode,
306
+ ];
307
+ for (const c of candidates) {
308
+ if (typeof c === 'number' && Number.isFinite(c)) return c;
309
+ }
310
+ return null;
311
+ }
312
+
276
313
  /**
277
314
  * PreToolUse handler. Stashes `{ startedAt, tool }` into the in-flight
278
315
  * Map keyed by the harness `tool_use_id`. Does not append to
@@ -306,6 +343,7 @@ export async function handlePost(event, active) {
306
343
  const id = event?.tool_use_id ?? event?.id ?? null;
307
344
  const tool = event?.tool_name ?? event?.tool ?? 'unknown';
308
345
  const toolInput = event?.tool_input;
346
+ const exitCode = extractExitCode(event);
309
347
 
310
348
  let durationMs = null;
311
349
  if (id && inflight.has(id)) {
@@ -317,13 +355,13 @@ export async function handlePost(event, active) {
317
355
  const trace = {
318
356
  ts: new Date().toISOString(),
319
357
  kind: 'trace',
320
- source: { tool: clamp(tool) },
358
+ emitter: { tool: clamp(tool) },
321
359
  epicId: active.epicId,
322
360
  storyId: active.storyId,
323
361
  taskId: null,
324
362
  phase:
325
363
  typeof process.env.CC_PHASE === 'string' ? process.env.CC_PHASE : null,
326
- details: buildDetails({ tool, toolInput, durationMs }),
364
+ details: buildDetails({ tool, toolInput, durationMs, exitCode }),
327
365
  };
328
366
 
329
367
  await appendTrace({
@@ -174,7 +174,7 @@ export function buildAcceptanceEvalSignal({
174
174
  epicId: epicId ?? null,
175
175
  storyId,
176
176
  phase,
177
- source: { tool: 'acceptance-eval.js' },
177
+ emitter: { tool: 'acceptance-eval.js' },
178
178
  details: {
179
179
  decision: outcome.decision,
180
180
  round: outcome.round,
@@ -22,8 +22,11 @@
22
22
  * Behaviour:
23
23
  * - Loads the configured review adapter via the factory; defaults to
24
24
  * `native` when `delivery.codeReview.provider` is unset.
25
- * - Always posts the structured `code-review` comment on the Epic
26
- * issue (the adapter never posts; the orchestrator owns persistence).
25
+ * - Always posts the unified `verification-results` structured comment on
26
+ * the Epic issue (the adapter never posts; the orchestrator owns
27
+ * persistence). Story #4411 (Epic #4405) unified the former
28
+ * `code-review` and `audit-results` findings contracts into this one
29
+ * `verification-results` marker.
27
30
  * - Treats severity.critical > 0 as a halting blocker — the merged
28
31
  * `/deliver` runner consults `halted` and refuses to advance
29
32
  * to Phase E (retro) when set.
@@ -32,6 +35,8 @@
32
35
  * helper's "operator must remediate before /deliver" gate.
33
36
  */
34
37
 
38
+ import { hasSurvivingCritical } from '../audit-suite/findings.js';
39
+ import { resolveLensTier } from '../audit-suite/selector.js';
35
40
  import { resolveConfig } from '../config-resolver.js';
36
41
  import { selectAuditStrategy } from '../dynamic-workflow/capability.js';
37
42
  import { gitSpawn } from '../git-utils.js';
@@ -228,6 +233,71 @@ export function resolveAuditLenses(envelope = {}) {
228
233
  return LENS_ORDER.filter((lens) => matched.has(lens));
229
234
  }
230
235
 
236
+ /**
237
+ * Compose the Epic-close (gate3) lens roster from the change-set selection
238
+ * plus every risk-routed lens.
239
+ *
240
+ * STOPGAP (post-#4405 review finding): Story #4412 excluded every
241
+ * `local`-tier change-set lens here on the premise that the Story-scope
242
+ * local-lens pass (Story #4409) verifies those concerns shift-left. That
243
+ * premise does not hold yet — `runLocalLensReview`
244
+ * (story-close/phases/code-review.js) materializes lens bodies with no
245
+ * `artifactPrefix` (so no content persists) and no consumer acts on its
246
+ * result, which left change-set lenses (security, privacy, performance,
247
+ * quality, …) executed by NO reviewer at ANY tier. Local-tier lenses are
248
+ * therefore KEPT at Epic close until the story-scope pass has a real
249
+ * consumer; re-enable the exclusion then (tracked as a follow-up issue).
250
+ * The write-time maker checklists (#4410) are advisory self-checks, not
251
+ * verification. The `resolveLensTierFn` seam is retained so the
252
+ * re-exclusion is a one-line revert.
253
+ *
254
+ * The two inputs mirror the `epic-audit-prepare.js` envelope:
255
+ * - `changeSetAudits` — the raw change-set gate3 selection, kept in full
256
+ * (see the stopgap note above).
257
+ * - `riskRoutedAudits` — the verdict-routed high-risk lenses plus the
258
+ * route-glob navigability lens. Kept in **full**, regardless of tier: a
259
+ * high-risk axis (or a route-adding change set) that demands a local-tier
260
+ * lens still runs it at Epic close, because that demand is the whole point
261
+ * of risk routing.
262
+ *
263
+ * De-duplicated and order-preserving (kept change-set lenses first, then the
264
+ * risk-routed extras). Pure over the injected `resolveLensTierFn` seam; the
265
+ * default resolver reads `audit-rules.json` from disk.
266
+ *
267
+ * @param {{
268
+ * changeSetAudits?: string[],
269
+ * riskRoutedAudits?: string[],
270
+ * resolveLensTierFn?: typeof resolveLensTier,
271
+ * }} [params]
272
+ * @returns {string[]} The Epic-close roster (all change-set tiers +
273
+ * risk-routed; local-tier exclusion suspended per the stopgap above).
274
+ */
275
+ export function selectEpicCloseLenses({
276
+ changeSetAudits = [],
277
+ riskRoutedAudits = [],
278
+ resolveLensTierFn = resolveLensTier,
279
+ } = {}) {
280
+ const kept = [];
281
+ const seen = new Set();
282
+ const add = (lens) => {
283
+ if (typeof lens !== 'string' || lens.length === 0 || seen.has(lens)) return;
284
+ seen.add(lens);
285
+ kept.push(lens);
286
+ };
287
+ for (const lens of Array.isArray(changeSetAudits) ? changeSetAudits : []) {
288
+ if (typeof lens !== 'string' || lens.length === 0) continue;
289
+ // Stopgap: keep local-tier lenses (see docstring). The tier seam is
290
+ // deliberately untouched so restoring `=== 'local' → continue` is a
291
+ // one-line revert once the story-scope tier has a real consumer.
292
+ void resolveLensTierFn;
293
+ add(lens);
294
+ }
295
+ for (const lens of Array.isArray(riskRoutedAudits) ? riskRoutedAudits : []) {
296
+ add(lens);
297
+ }
298
+ return kept;
299
+ }
300
+
231
301
  /**
232
302
  * Build the post-delivery audit-lens execution plan for a judged risk
233
303
  * envelope. Each routed lens (see {@link resolveAuditLenses}) is paired with a
@@ -551,7 +621,7 @@ async function postReviewComment({
551
621
  const postResult = await upsertCommentFn(
552
622
  provider,
553
623
  commentTargetId,
554
- 'code-review',
624
+ 'verification-results',
555
625
  report,
556
626
  );
557
627
  const postedCommentId =
@@ -621,7 +691,7 @@ async function executeReviewPipeline({ opts, config, envelope }) {
621
691
  );
622
692
 
623
693
  const severity = countBySeverity(findings);
624
- const halted = severity.critical > 0;
694
+ const halted = hasSurvivingCritical(severity);
625
695
  const report = renderFindingsFn({
626
696
  scope,
627
697
  ticketId,