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
@@ -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:
@@ -196,6 +196,10 @@ const KEY_MEANINGS = Object.freeze({
196
196
  'Maximum auto-fix attempts the code-review phase makes.',
197
197
  'delivery.codeReview.maxFixScopeFiles':
198
198
  'Maximum files an auto-fix may touch in one attempt.',
199
+ 'delivery.codeReview.autoFixSeverity':
200
+ 'Severity threshold for on-branch code-review remediation (medium fixes 🔴/🟠/🟡, high fixes 🔴/🟠 only; default medium).',
201
+ 'delivery.epicAudit.autoFixSeverity':
202
+ 'Severity threshold for on-branch epic-audit remediation (medium fixes 🔴/🟠/🟡, high fixes 🔴/🟠 only; default medium).',
199
203
  'delivery.refactorStage.enabled':
200
204
  'Whether a dedicated refactor stage runs during delivery.',
201
205
  'delivery.acceptanceEval.maxRounds':
@@ -48,15 +48,30 @@ const DEFAULT_DELIVER_RUNNER = Object.freeze({
48
48
  * and Phase 5 (code-review). Operators override via
49
49
  * `delivery.epicAudit.*` and `delivery.codeReview.*` in `.agentrc.json`
50
50
  * (Story #2611, Epic #2586).
51
+ *
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'`.
51
64
  */
52
65
  export const DEFAULT_EPIC_AUDIT = Object.freeze({
53
66
  maxFixAttempts: 3,
54
67
  maxFixScopeFiles: 5,
68
+ autoFixSeverity: 'high',
55
69
  });
56
70
 
57
71
  export const DEFAULT_CODE_REVIEW = Object.freeze({
58
72
  maxFixAttempts: 3,
59
73
  maxFixScopeFiles: 5,
74
+ autoFixSeverity: 'medium',
60
75
  });
61
76
 
62
77
  /**
@@ -65,8 +80,8 @@ export const DEFAULT_CODE_REVIEW = Object.freeze({
65
80
  * @param {object | null | undefined} config
66
81
  * @returns {{
67
82
  * deliverRunner: { concurrencyCap: number, progressReportIntervalSec: number, verifyConcurrencyCap: number },
68
- * epicAudit: { maxFixAttempts: number, maxFixScopeFiles: number },
69
- * codeReview: { maxFixAttempts: number, maxFixScopeFiles: number },
83
+ * epicAudit: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
84
+ * codeReview: { maxFixAttempts: number, maxFixScopeFiles: number, autoFixSeverity: 'high'|'medium' },
70
85
  * storyMergeRetry: { maxAttempts: number, backoffMs: readonly number[] },
71
86
  * decomposer: { concurrencyCap: number },
72
87
  * }}
@@ -92,12 +107,16 @@ export function getRunners(config) {
92
107
  epicAuditUser.maxFixAttempts ?? DEFAULT_EPIC_AUDIT.maxFixAttempts,
93
108
  maxFixScopeFiles:
94
109
  epicAuditUser.maxFixScopeFiles ?? DEFAULT_EPIC_AUDIT.maxFixScopeFiles,
110
+ autoFixSeverity:
111
+ epicAuditUser.autoFixSeverity ?? DEFAULT_EPIC_AUDIT.autoFixSeverity,
95
112
  },
96
113
  codeReview: {
97
114
  maxFixAttempts:
98
115
  codeReviewUser.maxFixAttempts ?? DEFAULT_CODE_REVIEW.maxFixAttempts,
99
116
  maxFixScopeFiles:
100
117
  codeReviewUser.maxFixScopeFiles ?? DEFAULT_CODE_REVIEW.maxFixScopeFiles,
118
+ autoFixSeverity:
119
+ codeReviewUser.autoFixSeverity ?? DEFAULT_CODE_REVIEW.autoFixSeverity,
101
120
  },
102
121
  storyMergeRetry: DEFAULT_STORY_MERGE_RETRY,
103
122
  decomposer: DEFAULT_DECOMPOSER,
@@ -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.).
@@ -210,12 +210,25 @@ const MERGE_WATCH_SCHEMA = {
210
210
  * `agent::blocked` (default 5) — a deliberately narrow bound for
211
211
  * unattended auto-fixes, independent of the Story-sizing thresholds in
212
212
  * `ticket-validator-sizing.js`.
213
+ *
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.
213
225
  */
214
226
  const EPIC_AUDIT_SCHEMA = {
215
227
  type: 'object',
216
228
  properties: {
217
229
  maxFixAttempts: { type: 'integer', minimum: 0 },
218
230
  maxFixScopeFiles: { type: 'integer', minimum: 1 },
231
+ autoFixSeverity: { type: 'string', enum: ['high', 'medium'] },
219
232
  },
220
233
  additionalProperties: false,
221
234
  };
@@ -324,15 +337,22 @@ const ACCEPTANCE_EVAL_SCHEMA = {
324
337
  /**
325
338
  * `delivery.feedbackLoop` — opt-out toggles consumed by the Epic finalize
326
339
  * listener's auto-file graduators (`lib/feedback-loop/*-graduator.js`, read
327
- * via `graduator-core.js#makeIsAutoFileEnabled`). Both default to `true`
328
- * (auto-file on); set either to `false` to suppress auto-filing the
340
+ * via `graduator-core.js#makeIsAutoFileEnabled`). All default to `true`
341
+ * (auto-file on); set any to `false` to suppress auto-filing the
329
342
  * corresponding non-blocking findings as follow-up issues.
343
+ *
344
+ * `retroProposals` (Story #4418) governs the retro auto-filer: when true
345
+ * (default) the retro's actionable routed proposals are filed as
346
+ * `meta::<framework-gap|consumer-improvement>` + `friction::<category>`
347
+ * issues via the graduator pre-parsed-findings seam, and the rendered retro
348
+ * sections list the filed issue numbers instead of paste-ready `gh` command
349
+ * stanzas; set it to `false` to fall back to the command stanzas.
330
350
  */
331
351
  const FEEDBACK_LOOP_SCHEMA = {
332
352
  type: 'object',
333
353
  properties: {
334
- codeReviewAutoFile: { type: 'boolean' },
335
354
  auditResultsAutoFile: { type: 'boolean' },
355
+ retroProposals: { type: 'boolean' },
336
356
  },
337
357
  additionalProperties: false,
338
358
  };
@@ -116,6 +116,12 @@ export const QUALITY_SCHEMA = {
116
116
  /**
117
117
  * `delivery.codeReview` — sibling to `delivery.epicAudit`. Same bounded
118
118
  * retry + scope cap, applied to /deliver Phase 5 (code-review).
119
+ *
120
+ * `autoFixSeverity` (Story #4399) is the sibling of
121
+ * `delivery.epicAudit.autoFixSeverity`: the threshold that governs which
122
+ * Phase 5 findings the host-LLM focused-fix routing remediates on-branch —
123
+ * `medium` (default) routes 🔴/🟠/🟡 while 🟢 still graduates, `high`
124
+ * reproduces the pre-4399 Critical/High-only routing.
119
125
  */
120
126
  export const CODE_REVIEW_SCHEMA = {
121
127
  type: 'object',
@@ -173,6 +179,7 @@ export const CODE_REVIEW_SCHEMA = {
173
179
  providerConfig: { type: 'object', additionalProperties: true },
174
180
  maxFixAttempts: { type: 'integer', minimum: 0 },
175
181
  maxFixScopeFiles: { type: 'integer', minimum: 1 },
182
+ autoFixSeverity: { type: 'string', enum: ['high', 'medium'] },
176
183
  },
177
184
  additionalProperties: false,
178
185
  };