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
@@ -0,0 +1,340 @@
1
+ /**
2
+ * loop-health.js — read-only retro-scope check for the feedback-loop
3
+ * substrate (Epic #4406 / Story #4419, the terminal slice).
4
+ *
5
+ * The original feedback-loop drift shipped silently because an empty
6
+ * feedback report is indistinguishable from a healthy one. This standing
7
+ * self-check closes that gap: at retro time it samples the on-disk signal
8
+ * substrate the sibling Stories established and surfaces the three ways the
9
+ * repaired loop can regress without anyone noticing:
10
+ *
11
+ * 1. **Schema-invalid signal lines.** It tails the most recent
12
+ * {@link MAX_SAMPLE_LINES} lines of every `signals.ndjson` stream under
13
+ * the Epic temp tree and validates each against the canonical
14
+ * `signal-event.schema.json` (via `validateSignal`, the same validator
15
+ * the writer uses — no hand-rolled drift).
16
+ * 2. **Persisted write-time rejects.** It reads the per-Epic reject tally
17
+ * (`temp/epic-<id>/signal-rejects.json`, written by Story #4413's
18
+ * signals-writer) so records that were dropped at write time — and thus
19
+ * never appear in the stream — are still counted.
20
+ * 3. **Un-actioned retro proposals.** It reads the retro mirror
21
+ * (`temp/epic-<id>/retro.md`, Story #4418) and flags any actionable
22
+ * "Proposed issues" item that carries neither a filed-issue reference
23
+ * (`Filed: [#N](url)`) nor lives under the explicit "One-off /
24
+ * discarded" record.
25
+ *
26
+ * Contract:
27
+ * - Scope `retro`, `autoCorrect: 'refuse-and-print'` — read-only by
28
+ * construction; the runner refuses `autoFix` under the retro scope.
29
+ * - A clean substrate (valid lines, zero rejects, every proposal filed or
30
+ * discarded) yields **zero findings**, preserving the compact retro
31
+ * shape. Only when a concern is non-zero does `detect` return a single
32
+ * combined finding naming every non-clean dimension.
33
+ * - No new provider plumbing: `detect(state)` reads `state.cwd` from the
34
+ * existing cwd-scoped checks-registry state, anchors it to the main
35
+ * checkout root, and reads the temp tree directly.
36
+ */
37
+
38
+ import { readdirSync, readFileSync, statSync } from 'node:fs';
39
+ import path from 'node:path';
40
+
41
+ import { mainCheckoutRoot, tempRootFrom } from '../config/temp-paths.js';
42
+ import { validateSignal } from '../observability/signal-validator.js';
43
+
44
+ /**
45
+ * Lines sampled from the tail of each `signals.ndjson` stream. The check is
46
+ * a health probe, not an exhaustive audit — the most recent window is a
47
+ * representative sample that keeps the read bounded on long streams.
48
+ */
49
+ export const MAX_SAMPLE_LINES = 200;
50
+
51
+ /**
52
+ * Locate the most-recently-touched `epic-<id>` temp tree under
53
+ * `<baseDir>/<tempRoot>`. Returns `{ epicId, epicDir }` or `null` when no
54
+ * Epic temp tree exists (a fresh checkout, or a non-Epic context).
55
+ *
56
+ * @param {string} baseDir The main checkout root.
57
+ * @param {{ tempRoot?: string, fsImpl?: { readdirSync: typeof readdirSync, statSync: typeof statSync } }} [opts]
58
+ * @returns {{ epicId: number, epicDir: string } | null}
59
+ */
60
+ export function resolveEpicTempTree(
61
+ baseDir,
62
+ { tempRoot = 'temp', fsImpl } = {},
63
+ ) {
64
+ const readdir = fsImpl?.readdirSync ?? readdirSync;
65
+ const stat = fsImpl?.statSync ?? statSync;
66
+ const tempDir = path.join(baseDir, tempRoot);
67
+ let entries;
68
+ try {
69
+ entries = readdir(tempDir);
70
+ } catch {
71
+ return null;
72
+ }
73
+ let best = null;
74
+ for (const entry of entries) {
75
+ const match = /^epic-(\d+)$/.exec(entry);
76
+ if (!match) continue;
77
+ const full = path.join(tempDir, entry);
78
+ let st;
79
+ try {
80
+ st = stat(full);
81
+ } catch {
82
+ continue;
83
+ }
84
+ if (!st.isDirectory()) continue;
85
+ if (!best || st.mtimeMs > best.mtimeMs) {
86
+ best = { epicId: Number(match[1]), epicDir: full, mtimeMs: st.mtimeMs };
87
+ }
88
+ }
89
+ return best ? { epicId: best.epicId, epicDir: best.epicDir } : null;
90
+ }
91
+
92
+ /**
93
+ * Enumerate every `signals.ndjson` stream under an Epic temp tree: the
94
+ * Epic-level wave-lifecycle stream plus each per-Story stream.
95
+ *
96
+ * @param {string} epicDir
97
+ * @param {{ fsImpl?: { readdirSync: typeof readdirSync, statSync: typeof statSync } }} [opts]
98
+ * @returns {string[]}
99
+ */
100
+ export function findSignalStreams(epicDir, { fsImpl } = {}) {
101
+ const readdir = fsImpl?.readdirSync ?? readdirSync;
102
+ const stat = fsImpl?.statSync ?? statSync;
103
+ const streams = [];
104
+ const isFile = (p) => {
105
+ try {
106
+ return stat(p).isFile();
107
+ } catch {
108
+ return false;
109
+ }
110
+ };
111
+ const epicSignals = path.join(epicDir, 'signals.ndjson');
112
+ if (isFile(epicSignals)) streams.push(epicSignals);
113
+ const storiesDir = path.join(epicDir, 'stories');
114
+ let storyEntries;
115
+ try {
116
+ storyEntries = readdir(storiesDir);
117
+ } catch {
118
+ storyEntries = [];
119
+ }
120
+ for (const entry of storyEntries) {
121
+ if (!/^story-\d+$/.test(entry)) continue;
122
+ const storySignals = path.join(storiesDir, entry, 'signals.ndjson');
123
+ if (isFile(storySignals)) streams.push(storySignals);
124
+ }
125
+ return streams;
126
+ }
127
+
128
+ /**
129
+ * Tail `maxLines` of a single stream and count how many sampled lines fail
130
+ * the canonical schema (a JSON parse failure counts as invalid). A missing
131
+ * or unreadable stream contributes zero — absence is not invalidity.
132
+ *
133
+ * @param {string} streamPath
134
+ * @param {{ validate?: typeof validateSignal, maxLines?: number, readImpl?: typeof readFileSync }} [opts]
135
+ * @returns {{ sampled: number, invalid: number }}
136
+ */
137
+ export function sampleStreamInvalidCount(
138
+ streamPath,
139
+ {
140
+ validate = validateSignal,
141
+ maxLines = MAX_SAMPLE_LINES,
142
+ readImpl = readFileSync,
143
+ } = {},
144
+ ) {
145
+ let raw;
146
+ try {
147
+ raw = readImpl(streamPath, 'utf8');
148
+ } catch {
149
+ return { sampled: 0, invalid: 0 };
150
+ }
151
+ const lines = raw
152
+ .split('\n')
153
+ .map((l) => l.trim())
154
+ .filter(Boolean);
155
+ const tail = lines.slice(-maxLines);
156
+ let invalid = 0;
157
+ for (const line of tail) {
158
+ let parsed;
159
+ try {
160
+ parsed = JSON.parse(line);
161
+ } catch {
162
+ invalid += 1;
163
+ continue;
164
+ }
165
+ if (!validate(parsed).valid) invalid += 1;
166
+ }
167
+ return { sampled: tail.length, invalid };
168
+ }
169
+
170
+ /**
171
+ * Read the per-Epic persisted reject count from `signal-rejects.json`.
172
+ * Returns 0 when the tally is absent or unreadable.
173
+ *
174
+ * @param {string} epicDir
175
+ * @param {{ readImpl?: typeof readFileSync }} [opts]
176
+ * @returns {number}
177
+ */
178
+ export function readRejectTally(epicDir, { readImpl = readFileSync } = {}) {
179
+ try {
180
+ const parsed = JSON.parse(
181
+ readImpl(path.join(epicDir, 'signal-rejects.json'), 'utf8'),
182
+ );
183
+ return parsed && Number.isFinite(parsed.count) ? parsed.count : 0;
184
+ } catch {
185
+ return 0;
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Scan a retro mirror body for actionable proposals that were neither filed
191
+ * nor discarded. An actionable proposal is a `- **Title**` item under a
192
+ * `### Proposed issues` heading whose body carries a paste-ready
193
+ * `gh issue create` stanza (the un-filed fallback) instead of a
194
+ * `Filed: [#N](url)` reference. Discarded proposals live under the separate
195
+ * "### One-off / discarded" heading and are never scanned here.
196
+ *
197
+ * @param {string} retroText
198
+ * @returns {string[]} Titles of unfiled actionable proposals.
199
+ */
200
+ export function scanRetroMirror(retroText) {
201
+ if (typeof retroText !== 'string' || retroText.length === 0) return [];
202
+ const ACTION_HEADING = /^###\s+Proposed issues\b/;
203
+ const ANY_HEADING = /^#{1,6}\s+/;
204
+ const ITEM = /^-\s+\*\*(.+?)\*\*\s*$/;
205
+ const unfiled = [];
206
+ let inSection = false;
207
+ let current = null;
208
+ const flush = () => {
209
+ if (current && current.actionable && !current.filed) {
210
+ unfiled.push(current.title);
211
+ }
212
+ current = null;
213
+ };
214
+ for (const line of retroText.split('\n')) {
215
+ if (ANY_HEADING.test(line)) {
216
+ flush();
217
+ inSection = ACTION_HEADING.test(line);
218
+ continue;
219
+ }
220
+ if (!inSection) continue;
221
+ const itemMatch = ITEM.exec(line);
222
+ if (itemMatch) {
223
+ flush();
224
+ current = { title: itemMatch[1].trim(), filed: false, actionable: false };
225
+ continue;
226
+ }
227
+ if (!current) continue;
228
+ if (/^\s*Filed:/.test(line)) current.filed = true;
229
+ if (/gh issue create/.test(line)) current.actionable = true;
230
+ }
231
+ flush();
232
+ return unfiled;
233
+ }
234
+
235
+ /**
236
+ * Core detection: locate the Epic temp tree under `baseDir`, sample its
237
+ * signal streams, read its reject tally, and scan its retro mirror. Returns
238
+ * a single combined finding when any dimension is non-clean, else `null`.
239
+ *
240
+ * @param {string} baseDir
241
+ * @param {{
242
+ * tempRoot?: string,
243
+ * validate?: typeof validateSignal,
244
+ * maxLines?: number,
245
+ * scope?: string,
246
+ * fsImpl?: object,
247
+ * readImpl?: typeof readFileSync,
248
+ * }} [opts]
249
+ * @returns {import('./index.js').Finding | null}
250
+ */
251
+ export function detectLoopHealth(
252
+ baseDir,
253
+ {
254
+ tempRoot = 'temp',
255
+ validate = validateSignal,
256
+ maxLines = MAX_SAMPLE_LINES,
257
+ scope = 'retro',
258
+ fsImpl,
259
+ readImpl = readFileSync,
260
+ } = {},
261
+ ) {
262
+ const tree = resolveEpicTempTree(baseDir, { tempRoot, fsImpl });
263
+ if (!tree) return null;
264
+ const { epicId, epicDir } = tree;
265
+
266
+ const streams = findSignalStreams(epicDir, { fsImpl });
267
+ let invalidCount = 0;
268
+ let sampled = 0;
269
+ for (const stream of streams) {
270
+ const r = sampleStreamInvalidCount(stream, {
271
+ validate,
272
+ maxLines,
273
+ readImpl,
274
+ });
275
+ invalidCount += r.invalid;
276
+ sampled += r.sampled;
277
+ }
278
+
279
+ const rejectCount = readRejectTally(epicDir, { readImpl });
280
+
281
+ let retroText = '';
282
+ try {
283
+ retroText = readImpl(path.join(epicDir, 'retro.md'), 'utf8');
284
+ } catch {
285
+ retroText = '';
286
+ }
287
+ const unfiledProposals = scanRetroMirror(retroText);
288
+
289
+ const signalConcern = invalidCount > 0 || rejectCount > 0;
290
+ const proposalConcern = unfiledProposals.length > 0;
291
+ if (!signalConcern && !proposalConcern) return null;
292
+
293
+ const summaryParts = [];
294
+ const detailLines = [];
295
+ if (signalConcern) {
296
+ summaryParts.push(
297
+ `${invalidCount} schema-invalid signal sample(s), ${rejectCount} persisted reject(s)`,
298
+ );
299
+ detailLines.push(
300
+ `Sampled ${sampled} line(s) across ${streams.length} signals.ndjson stream(s) (last ${maxLines} per stream):`,
301
+ ` schema-invalid samples: ${invalidCount}`,
302
+ ` persisted reject tally (signal-rejects.json): ${rejectCount}`,
303
+ );
304
+ }
305
+ if (proposalConcern) {
306
+ summaryParts.push(
307
+ `${unfiledProposals.length} unfiled actionable proposal(s)`,
308
+ );
309
+ detailLines.push(
310
+ 'Retro proposals with neither a filed-issue reference nor a discard record:',
311
+ ...unfiledProposals.map((title) => ` - ${title}`),
312
+ );
313
+ }
314
+
315
+ return {
316
+ id: 'loop-health',
317
+ severity: 'warning',
318
+ scope,
319
+ summary: `Loop-health (epic-${epicId}): ${summaryParts.join('; ')}.`,
320
+ detail: detailLines.join('\n'),
321
+ fixCommand:
322
+ 'Inspect temp/epic-<id>/{signals.ndjson,signal-rejects.json,retro.md}; fix the signal producer or file/discard the surfaced proposals.',
323
+ autoCorrectable: false,
324
+ };
325
+ }
326
+
327
+ export default {
328
+ id: 'loop-health',
329
+ severity: 'warning',
330
+ scope: ['retro'],
331
+ autoCorrect: 'refuse-and-print',
332
+ detect(state) {
333
+ const cwd = state?.cwd ?? process.cwd();
334
+ const baseDir = mainCheckoutRoot(cwd) ?? cwd;
335
+ return detectLoopHealth(baseDir, {
336
+ tempRoot: tempRootFrom(state?.config),
337
+ scope: state?.scope ?? 'retro',
338
+ });
339
+ },
340
+ };
@@ -58,6 +58,8 @@ export function parseSprintArgs(args = process.argv) {
58
58
  'skip-sync': { type: 'boolean', default: false },
59
59
  'no-auto-merge': { type: 'boolean', default: false },
60
60
  'no-full-scope-crap': { type: 'boolean', default: false },
61
+ 'wait-merge': { type: 'boolean', default: false },
62
+ 'no-wait-merge': { type: 'boolean', default: false },
61
63
  executor: { type: 'string' },
62
64
  cwd: { type: 'string' },
63
65
  'recut-of': { type: 'string' },
@@ -79,6 +81,12 @@ export function parseSprintArgs(args = process.argv) {
79
81
  skipSync: coerceBooleanFlag(values['skip-sync']),
80
82
  noAutoMerge: coerceBooleanFlag(values['no-auto-merge']),
81
83
  noFullScopeCrap: coerceBooleanFlag(values['no-full-scope-crap']),
84
+ // Story #4428 — headless must-land signal for `single-story-close.js`:
85
+ // `--wait-merge` opts a headless run into polling to merge confirmation
86
+ // instead of resting at `agent::closing`; `--no-wait-merge` is the
87
+ // explicit opt-out (see `single-story-close/phases/options.js`).
88
+ waitForMerge: coerceBooleanFlag(values['wait-merge']),
89
+ noWaitForMerge: coerceBooleanFlag(values['no-wait-merge']),
82
90
  executor: values.executor ?? null,
83
91
  // Resolve worktree cwd from flag or env. Empty string/whitespace → null.
84
92
  cwd:
@@ -6,6 +6,7 @@
6
6
  * runner (`INDEPENDENT_GATE_NAMES` / `partitionGates`).
7
7
  */
8
8
 
9
+ import { hasNpmScript, readPackageScripts } from '../npm-scripts.js';
9
10
  import {
10
11
  buildFormatHint,
11
12
  FORMAT_CHECK_FALLBACK,
@@ -87,17 +88,30 @@ function isCrapGateEnabled(config) {
87
88
  }
88
89
 
89
90
  /**
90
- * Conditionally produce the standalone `test` gate entry. Returns an empty
91
- * array when the CRAP gate is enabled (Story #1798: coverage-capture is the
92
- * canonical test runner in that mode); returns the legacy single-entry
93
- * gate otherwise. Splitting this out keeps `buildDefaultGates` flat for
94
- * the CRAP-cyclomatic gate.
91
+ * The gates run in the Story worktree, whose `package.json` is the committed
92
+ * one the consumer ships the presence of a `test:coverage` script is a
93
+ * committed fact, so probing at the gate cwd is authoritative. See
94
+ * `lib/npm-scripts.js` for the shared reader.
95
+ */
96
+
97
+ /**
98
+ * Conditionally produce the standalone `test` gate entry.
95
99
  *
96
- * @param {object|undefined|null} config - Canonical resolved config.
100
+ * The plain `test` gate is the canonical test runner UNLESS the
101
+ * coverage-capture gate is taking that role — which happens only when the
102
+ * CRAP gate is enabled (Story #1798) AND the consumer actually ships a
103
+ * `test:coverage` script for coverage-capture to run (#4473). When CRAP is
104
+ * enabled but `test:coverage` is absent, coverage-capture is dropped from
105
+ * the gate list, so the `test` gate MUST come back — otherwise the consumer
106
+ * has NO working test gate at all. Splitting this out keeps
107
+ * `buildDefaultGates` flat for the CRAP-cyclomatic gate.
108
+ *
109
+ * @param {boolean} coverageCaptureActive - Whether the coverage-capture gate
110
+ * is registered as the test runner for this build.
97
111
  * @returns {Gate[]}
98
112
  */
99
- function buildTestGateEntry(config) {
100
- if (isCrapGateEnabled(config)) return [];
113
+ function buildTestGateEntry(coverageCaptureActive) {
114
+ if (coverageCaptureActive) return [];
101
115
  return [{ name: 'test', cmd: 'npm', args: ['test'] }];
102
116
  }
103
117
 
@@ -105,10 +119,13 @@ function buildTestGateEntry(config) {
105
119
  * Build the canonical close-validation gate list.
106
120
  *
107
121
  * Ordering (cheapest fast-fail first): typecheck → lint → [test] →
108
- * format → coverage-capture → check-baselines. The standalone `test`
109
- * gate is dropped when `crap.enabled === true` (Story #1798) because
110
- * coverage-capture carries test-failure signalling under c8 in that
111
- * mode.
122
+ * format → [coverage-capture] → check-baselines. The standalone `test`
123
+ * gate is dropped when coverage-capture is the active test runner — i.e.
124
+ * `crap.enabled === true` (Story #1798) AND a `test:coverage` script
125
+ * exists (Story #4473) — because coverage-capture then carries
126
+ * test-failure signalling under c8. When CRAP is on but `test:coverage` is
127
+ * absent, coverage-capture is dropped and the `test` gate is restored so
128
+ * there is always a working test gate.
112
129
  *
113
130
  * `typecheck` is mandatory; consumers may customise the command via
114
131
  * `project.commands.typecheck` (default `npm run typecheck`).
@@ -127,15 +144,34 @@ function buildTestGateEntry(config) {
127
144
  * re-discovered inherited main-vs-epic drift in untouched files as phantom
128
145
  * regressions and worked around it by hand-setting `BASELINE_REF`.
129
146
  *
130
- * @param {{ config?: object, epicBranch?: string }} [opts] - `config` is the
131
- * canonical resolved config (`{ project, delivery, ... }`); gate commands
132
- * resolve from `project.commands` and the CRAP toggle from
147
+ * Story #4473 the coverage-capture gate spawns `npm run test:coverage`,
148
+ * so it is registered ONLY when the consumer actually ships that script.
149
+ * When CRAP is enabled but `test:coverage` is absent, coverage-capture is
150
+ * dropped and the plain `test` gate is restored (see `buildTestGateEntry`),
151
+ * so a consumer without a coverage script gets a working degraded test gate
152
+ * instead of a deterministic close failure with no test gate at all. The
153
+ * probe reads `package.json` at `cwd` (the gate execution directory).
154
+ *
155
+ * @param {{ config?: object, epicBranch?: string, cwd?: string, packageScripts?: Record<string, string> }} [opts]
156
+ * `config` is the canonical resolved config (`{ project, delivery, ... }`);
157
+ * gate commands resolve from `project.commands` and the CRAP toggle from
133
158
  * `delivery.quality.gates.crap.enabled`. `epicBranch` is the close run's
134
159
  * integration branch (`epic/<id>` for Epic-attached Stories, the base
135
- * branch for standalone Stories).
160
+ * branch for standalone Stories). `cwd` is where the `package.json`
161
+ * coverage-script probe reads from (defaults to `process.cwd()`);
162
+ * `packageScripts` injects the scripts map directly (tests) and short-
163
+ * circuits the disk read.
136
164
  * @returns {Gate[]}
137
165
  */
138
- export function buildDefaultGates({ config, epicBranch } = {}) {
166
+ export function buildDefaultGates({
167
+ config,
168
+ epicBranch,
169
+ cwd,
170
+ packageScripts,
171
+ } = {}) {
172
+ const scripts = packageScripts ?? readPackageScripts(cwd);
173
+ const coverageCaptureActive =
174
+ isCrapGateEnabled(config) && hasNpmScript(scripts, 'test:coverage');
139
175
  const typecheckCmdString = resolveTypecheckCommand(config);
140
176
  const [typecheckCmd, ...typecheckArgs] = typecheckCmdString
141
177
  .split(/\s+/)
@@ -158,7 +194,7 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
158
194
  hint: TYPECHECK_HINT,
159
195
  },
160
196
  { name: 'lint', cmd: 'npm', args: ['run', 'lint'] },
161
- ...buildTestGateEntry(config),
197
+ ...buildTestGateEntry(coverageCaptureActive),
162
198
  {
163
199
  // Gate name kept generic ("format") so the close-orchestrator log line
164
200
  // and the per-gate phase-timer key don't shift when a repo swaps biome
@@ -172,12 +208,16 @@ export function buildDefaultGates({ config, epicBranch } = {}) {
172
208
  ? { changedFileScope: formatChangedFileScope }
173
209
  : {}),
174
210
  },
175
- {
176
- name: 'coverage-capture',
177
- cmd: 'node',
178
- args: ['.agents/scripts/coverage-capture.js'],
179
- hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
180
- },
211
+ ...(coverageCaptureActive
212
+ ? [
213
+ {
214
+ name: 'coverage-capture',
215
+ cmd: 'node',
216
+ args: ['.agents/scripts/coverage-capture.js'],
217
+ hint: 'Coverage capture failed — `npm run test:coverage` exited non-zero. Fix failing tests or coverage-threshold breaches, then re-run close.',
218
+ },
219
+ ]
220
+ : []),
181
221
  {
182
222
  // Story #2210 — unified `check-baselines` gate is the only path for
183
223
  // per-kind regression enforcement. The legacy per-kind in-process
@@ -12,12 +12,19 @@
12
12
  * waves run; `watch` tunes the merge/CI watch poll loop; and `autoMerge`
13
13
  * (default `"trust-ci"`) selects the merge posture — `"trust-ci"` merges once
14
14
  * required checks pass, `"strict"` additionally requires a clean review gate.
15
+ *
16
+ * Story #4472 adds `requireChecks` (default `false`): when `true` the
17
+ * AutomergePredicate treats a checks-less repo ("no checks reported") as a
18
+ * hard block rather than green, so a consumer that wants fail-closed-without-
19
+ * checks as policy opts into it explicitly instead of the framework blocking
20
+ * implicitly.
15
21
  */
16
22
 
17
23
  export const CI_DELIVERY_DEFAULTS = Object.freeze({
18
24
  skipForStoryPushes: true,
19
25
  earlyPr: true,
20
26
  autoMerge: 'trust-ci',
27
+ requireChecks: false,
21
28
  });
22
29
 
23
30
  /**
@@ -28,7 +35,7 @@ export const CI_DELIVERY_DEFAULTS = Object.freeze({
28
35
  * defaults; only the scalar knobs carry framework defaults here.
29
36
  *
30
37
  * @param {object | null | undefined} config
31
- * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', watch: object | undefined }}
38
+ * @returns {{ skipForStoryPushes: boolean, earlyPr: boolean, autoMerge: 'trust-ci' | 'strict', requireChecks: boolean, watch: object | undefined }}
32
39
  */
33
40
  export function getCiDelivery(config) {
34
41
  const ci = config?.delivery?.ci ?? config?.ci ?? config ?? {};
@@ -45,6 +52,10 @@ export function getCiDelivery(config) {
45
52
  ci.autoMerge === 'trust-ci' || ci.autoMerge === 'strict'
46
53
  ? ci.autoMerge
47
54
  : CI_DELIVERY_DEFAULTS.autoMerge,
55
+ requireChecks:
56
+ typeof ci.requireChecks === 'boolean'
57
+ ? ci.requireChecks
58
+ : CI_DELIVERY_DEFAULTS.requireChecks,
48
59
  watch:
49
60
  ci.watch && typeof ci.watch === 'object' ? { ...ci.watch } : undefined,
50
61
  };
@@ -49,15 +49,23 @@ const DEFAULT_DELIVER_RUNNER = Object.freeze({
49
49
  * `delivery.epicAudit.*` and `delivery.codeReview.*` in `.agentrc.json`
50
50
  * (Story #2611, Epic #2586).
51
51
  *
52
- * `autoFixSeverity` (Story #4399) defaults to `'medium'` — the phase
53
- * remediates 🔴/🟠/🟡 findings on-branch while 🟢 suggestions graduate to
54
- * follow-up issues. `'high'` reproduces the pre-4399 Critical/High-only
55
- * routing. Hard cutover per `rules/git-conventions.md` no back-compat flag.
52
+ * `autoFixSeverity` is **tier-aware** (Story #4412, Epic #4405). The
53
+ * Epic-close audit tier (`DEFAULT_EPIC_AUDIT`) defaults to `'high'`
54
+ * remediating only 🔴 Critical + 🟠 High findings on-branch while 🟡 Medium
55
+ * and 🟢 Suggestion findings graduate to follow-up issues. This is the
56
+ * three-tier model's slim outermost tier: 🟡 Medium code-quality concerns are
57
+ * already routed into on-branch remediation shift-left (the write-time
58
+ * checklist threading of Story #4410 and the Story-scope local-lens pass of
59
+ * Story #4409), so the Epic-close tier stops paying to re-remediate them where
60
+ * a fix is most expensive. The code-review tier (`DEFAULT_CODE_REVIEW`) keeps
61
+ * the `'medium'` default introduced by Story #4399. Both are hard cutovers per
62
+ * `rules/git-conventions.md` — no back-compat flag; an operator opts back into
63
+ * the wider routing by setting `delivery.epicAudit.autoFixSeverity: 'medium'`.
56
64
  */
57
65
  export const DEFAULT_EPIC_AUDIT = Object.freeze({
58
66
  maxFixAttempts: 3,
59
67
  maxFixScopeFiles: 5,
60
- autoFixSeverity: 'medium',
68
+ autoFixSeverity: 'high',
61
69
  });
62
70
 
63
71
  export const DEFAULT_CODE_REVIEW = Object.freeze({
@@ -258,6 +258,30 @@ export function signalsFile(eid, sid, config) {
258
258
  return path.join(storyTempDir(eid, sid, config), 'signals.ndjson');
259
259
  }
260
260
 
261
+ /**
262
+ * `temp/epic-<eid>/stories/story-<sid>/lifecycle.ndjson` — the story-scope
263
+ * ledger destination for lifecycle events emitted directly by a Story
264
+ * (rather than routed through the Epic-scoped bus ledger). Story #4426
265
+ * (Epic #4425) introduces the first consumer: a standalone
266
+ * `single-story-close` run (no parent Epic) emitting `merge.unlanded`
267
+ * needs an on-disk home even though there is no `epic-<id>/` directory to
268
+ * anchor the event to.
269
+ *
270
+ * Mirrors `epicLedgerPath` exactly, one level down: `eid === null` routes
271
+ * through `storyTempDir`'s standalone branch to
272
+ * `<tempRoot>/standalone/stories/story-<sid>/lifecycle.ndjson`; a real
273
+ * `eid` routes to `<tempRoot>/epic-<eid>/stories/story-<sid>/lifecycle.ndjson`,
274
+ * so an Epic-attached Story's story-scope ledger sits alongside its
275
+ * `signals.ndjson` sibling.
276
+ *
277
+ * @param {number|null} eid
278
+ * @param {number} sid
279
+ * @param {object} [config]
280
+ * @returns {string}
281
+ */
282
+ export const storyLedgerPath = (eid, sid, config) =>
283
+ storyArtifactPath(eid, sid, 'lifecycle.ndjson', config);
284
+
261
285
  /**
262
286
  * Escape hatch for an Epic-level artifact whose name isn't part of the
263
287
  * canonical layout (one of the per-Epic perf surfaces, retro mirror, etc.).
@@ -211,11 +211,17 @@ const MERGE_WATCH_SCHEMA = {
211
211
  * unattended auto-fixes, independent of the Story-sizing thresholds in
212
212
  * `ticket-validator-sizing.js`.
213
213
  *
214
- * `autoFixSeverity` (Story #4399) is the threshold that governs which
215
- * findings the Phase 4 host-LLM remediation loop fixes on-branch: `medium`
216
- * (the default) routes 🔴/🟠/🟡 into remediation while 🟢 still graduates;
217
- * `high` reproduces the pre-4399 Critical/High-only routing. It is a hard
218
- * cutover per `rules/git-conventions.md` there is no back-compat flag.
214
+ * `autoFixSeverity` (Story #4399) is the threshold that governs which findings
215
+ * the Epic-close host-LLM remediation loop fixes on-branch. It is **tier-aware**
216
+ * (Story #4412, Epic #4405): the Epic-close audit tier defaults to `high`
217
+ * (route only 🔴 Critical + 🟠 High into remediation; 🟡 Medium + 🟢 Suggestion
218
+ * graduate to follow-up issues) because 🟡 Medium code-quality concerns are
219
+ * already remediated shift-left at the write-time and Story-scope tiers — the
220
+ * slim outermost tier stops re-paying to fix them where a fix is most
221
+ * expensive. Setting `medium` opts back into routing 🔴/🟠/🟡 on-branch. The
222
+ * enum is the single validation seam: a configured value round-trips through
223
+ * the resolver, and any value outside `['high', 'medium']` is rejected at load.
224
+ * Hard cutover per `rules/git-conventions.md` — there is no back-compat flag.
219
225
  */
220
226
  const EPIC_AUDIT_SCHEMA = {
221
227
  type: 'object',
@@ -258,6 +264,13 @@ const CI_DELIVERY_SCHEMA = {
258
264
  earlyPr: { type: 'boolean' },
259
265
  watch: CI_WATCH_SCHEMA,
260
266
  autoMerge: { type: 'string', enum: ['trust-ci', 'strict'] },
267
+ // Story #4472 — fail-closed-without-checks policy. When `true`, the
268
+ // AutomergePredicate refuses to arm merge in a repo that reports zero
269
+ // required checks ("no checks reported"), treating the absence of a CI
270
+ // gate as a hard block instead of green. Defaults to `false` so a
271
+ // checks-less repo with green close-validation gates lands headlessly
272
+ // rather than parking on the operator-merges path.
273
+ requireChecks: { type: 'boolean' },
261
274
  },
262
275
  additionalProperties: false,
263
276
  };
@@ -331,15 +344,22 @@ const ACCEPTANCE_EVAL_SCHEMA = {
331
344
  /**
332
345
  * `delivery.feedbackLoop` — opt-out toggles consumed by the Epic finalize
333
346
  * listener's auto-file graduators (`lib/feedback-loop/*-graduator.js`, read
334
- * via `graduator-core.js#makeIsAutoFileEnabled`). Both default to `true`
335
- * (auto-file on); set either to `false` to suppress auto-filing the
347
+ * via `graduator-core.js#makeIsAutoFileEnabled`). All default to `true`
348
+ * (auto-file on); set any to `false` to suppress auto-filing the
336
349
  * corresponding non-blocking findings as follow-up issues.
350
+ *
351
+ * `retroProposals` (Story #4418) governs the retro auto-filer: when true
352
+ * (default) the retro's actionable routed proposals are filed as
353
+ * `meta::<framework-gap|consumer-improvement>` + `friction::<category>`
354
+ * issues via the graduator pre-parsed-findings seam, and the rendered retro
355
+ * sections list the filed issue numbers instead of paste-ready `gh` command
356
+ * stanzas; set it to `false` to fall back to the command stanzas.
337
357
  */
338
358
  const FEEDBACK_LOOP_SCHEMA = {
339
359
  type: 'object',
340
360
  properties: {
341
- codeReviewAutoFile: { type: 'boolean' },
342
361
  auditResultsAutoFile: { type: 'boolean' },
362
+ retroProposals: { type: 'boolean' },
343
363
  },
344
364
  additionalProperties: false,
345
365
  };