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,320 @@
1
+ /**
2
+ * CLI: ratchet-down gate for the always-loaded documentation context budget
3
+ * (Story #4438, Epic #4430 — Context Economy).
4
+ *
5
+ * Follows the standalone `check-arch-cycles.js` / `check-dead-exports.js`
6
+ * precedent — a pure-Node, baseline-aware, sub-second checker wired into the
7
+ * CI `baselines` job — rather than a `baselines/kinds/` metric. It measures the
8
+ * live byte total of two documentation read-tiers and compares each against a
9
+ * single committed budget in `baselines/context-budget.json`:
10
+ *
11
+ * - `alwaysLoaded` — the `CLAUDE.md` `@`-import closure re-paid on every
12
+ * session and every subagent spawn (instructions.md § 4).
13
+ * - `mandatoryRead` — the resolved `project.docsContextFiles` set.
14
+ *
15
+ * A tier that resolves **empty** is skipped silently (the `docsContextFiles`
16
+ * half skips when unconfigured / its files are absent), so a repo with no
17
+ * `CLAUDE.md` and no context docs is a clean no-op.
18
+ *
19
+ * Ratchet semantics (mirroring the sibling ratchets):
20
+ * - A gated tier grows beyond `baseline.tiers.<tier>.totalBytes +
21
+ * baseline.toleranceBytes` → exit 1, naming the tier and its delta.
22
+ * - A gated tier shrinks below its baseline total → printed as a `-`
23
+ * (removal) note, warning the baseline can be refreshed downward.
24
+ * Shrink-only exits 0.
25
+ * - Within tolerance / clean → exit 0.
26
+ * - Baseline file absent → warn + exit 0 (no-op; nothing to ratchet against).
27
+ *
28
+ * Tolerance lives in the baseline JSON (`toleranceBytes`) — there is **no**
29
+ * `.agentrc.json` config key; this is a framework-internal dogfooding ratchet,
30
+ * like arch-cycles.
31
+ *
32
+ * Flags:
33
+ * --baseline <path> override the budget path (default
34
+ * `baselines/context-budget.json`, resolved from cwd).
35
+ * --root <path> resolve tiers against an explicit repo root (default cwd).
36
+ * --update reseed the baseline from the current measurement (keeps
37
+ * the existing `toleranceBytes`, or defaults it) and exit 0.
38
+ * --json write the structured envelope to stdout.
39
+ */
40
+
41
+ import fs from 'node:fs';
42
+ import path from 'node:path';
43
+ import process from 'node:process';
44
+ import { runAsCli } from './lib/cli-utils.js';
45
+ import { resolveConfig } from './lib/config-resolver.js';
46
+ import { resolveDocTiers, tierTotalBytes } from './lib/doc-tiers.js';
47
+
48
+ /**
49
+ * The tiers this ratchet gates (in report order). `digestVisible` and
50
+ * `onDemand` are resolved by the tier map for the lens, but the byte budget
51
+ * intentionally gates only the two tiers the Epic AC names.
52
+ * @type {Array<'alwaysLoaded' | 'mandatoryRead'>}
53
+ */
54
+ export const GATED_TIERS = ['alwaysLoaded', 'mandatoryRead'];
55
+
56
+ /**
57
+ * Default tolerance (bytes) seeded into a fresh baseline by `--update` when the
58
+ * existing baseline carries none.
59
+ * @type {number}
60
+ */
61
+ export const DEFAULT_TOLERANCE_BYTES = 2048;
62
+
63
+ /**
64
+ * Parse argv for `--baseline <path>`, `--root <path>`, `--update`, `--json`.
65
+ * Exported so unit tests can pin the parser.
66
+ *
67
+ * @param {string[]} argv
68
+ * @returns {{ baselinePath: string|null, rootPath: string|null, update: boolean, json: boolean }}
69
+ */
70
+ export function parseArgv(argv = []) {
71
+ let baselinePath = null;
72
+ let rootPath = null;
73
+ let update = false;
74
+ let json = false;
75
+ for (let i = 0; i < argv.length; i += 1) {
76
+ const a = argv[i];
77
+ if (a === '--baseline') {
78
+ const next = argv[i + 1];
79
+ if (next && !next.startsWith('--')) {
80
+ baselinePath = next;
81
+ i += 1;
82
+ }
83
+ } else if (a === '--root') {
84
+ const next = argv[i + 1];
85
+ if (next && !next.startsWith('--')) {
86
+ rootPath = next;
87
+ i += 1;
88
+ }
89
+ } else if (a === '--update') {
90
+ update = true;
91
+ } else if (a === '--json') {
92
+ json = true;
93
+ }
94
+ }
95
+ return { baselinePath, rootPath, update, json };
96
+ }
97
+
98
+ /**
99
+ * Read the committed budget envelope from disk. Returns the parsed object or
100
+ * `null` when the file is missing or unparseable.
101
+ *
102
+ * @param {string} baselinePath
103
+ * @returns {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number, files?: Array<{ path: string, bytes: number }> }> } | null}
104
+ */
105
+ export function loadBaseline(baselinePath) {
106
+ try {
107
+ if (!fs.existsSync(baselinePath)) return null;
108
+ const parsed = JSON.parse(fs.readFileSync(baselinePath, 'utf-8'));
109
+ if (!parsed || typeof parsed !== 'object') return null;
110
+ return parsed;
111
+ } catch {
112
+ return null;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Build the committed-baseline envelope from a resolved tier map. Only the
118
+ * gated tiers are recorded (each as `{ totalBytes, files }`).
119
+ *
120
+ * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
121
+ * @param {number} toleranceBytes
122
+ * @returns {object}
123
+ */
124
+ export function buildBaseline(tierMap, toleranceBytes) {
125
+ const tiers = {};
126
+ for (const name of GATED_TIERS) {
127
+ const files = tierMap.tiers[name] ?? [];
128
+ tiers[name] = { totalBytes: tierTotalBytes(files), files };
129
+ }
130
+ return {
131
+ $schema: 'https://mandrel.dev/baselines/context-budget.schema.json',
132
+ generatedAt: new Date().toISOString(),
133
+ toleranceBytes,
134
+ tiers,
135
+ };
136
+ }
137
+
138
+ /**
139
+ * Pure diff: compare the current tier map against the committed baseline. A
140
+ * gated tier with no current files is skipped; a tier absent from the baseline
141
+ * is skipped. `grown` entries fail the gate; `shrunk` entries are informational.
142
+ *
143
+ * @param {{ tiers: Record<string, Array<{ path: string, bytes: number }>> }} tierMap
144
+ * @param {{ toleranceBytes?: number, tiers?: Record<string, { totalBytes: number }> }} baseline
145
+ * @returns {{
146
+ * grown: Array<{ tier: string, current: number, baseline: number, tolerance: number, delta: number }>,
147
+ * shrunk: Array<{ tier: string, current: number, baseline: number }>,
148
+ * skipped: string[],
149
+ * }}
150
+ */
151
+ export function diffBudget(tierMap, baseline) {
152
+ const tolerance = Number.isFinite(baseline?.toleranceBytes)
153
+ ? baseline.toleranceBytes
154
+ : 0;
155
+ const grown = [];
156
+ const shrunk = [];
157
+ const skipped = [];
158
+ for (const tier of GATED_TIERS) {
159
+ const files = tierMap.tiers[tier] ?? [];
160
+ const current = tierTotalBytes(files);
161
+ const baseTier = baseline?.tiers?.[tier];
162
+ if (
163
+ files.length === 0 ||
164
+ !baseTier ||
165
+ !Number.isFinite(baseTier.totalBytes)
166
+ ) {
167
+ skipped.push(tier);
168
+ continue;
169
+ }
170
+ const baselineBytes = baseTier.totalBytes;
171
+ if (current > baselineBytes + tolerance) {
172
+ grown.push({
173
+ tier,
174
+ current,
175
+ baseline: baselineBytes,
176
+ tolerance,
177
+ delta: current - baselineBytes,
178
+ });
179
+ } else if (current < baselineBytes) {
180
+ shrunk.push({ tier, current, baseline: baselineBytes });
181
+ }
182
+ }
183
+ return { grown, shrunk, skipped };
184
+ }
185
+
186
+ /**
187
+ * Render the human-readable diff. `+` lines are tiers that grew beyond
188
+ * tolerance (gate fail); `-` lines are tiers that shrank (refreshable
189
+ * baseline). A one-line summary always follows.
190
+ *
191
+ * @param {ReturnType<typeof diffBudget>} diff
192
+ * @returns {string}
193
+ */
194
+ export function renderDiff(diff) {
195
+ const lines = [];
196
+ for (const g of diff.grown) {
197
+ lines.push(
198
+ `+ ${g.tier}: ${g.current} bytes exceeds budget ${g.baseline} + tolerance ${g.tolerance} (delta +${g.delta})`,
199
+ );
200
+ }
201
+ for (const s of diff.shrunk) {
202
+ lines.push(
203
+ `- ${s.tier}: ${s.current} bytes below baseline ${s.baseline} — refresh baselines/context-budget.json`,
204
+ );
205
+ }
206
+ const tag = diff.grown.length > 0 ? '(gate fail)' : '(ok)';
207
+ lines.push(
208
+ `[context-budget] grown=${diff.grown.length} shrunk=${diff.shrunk.length} skipped=${diff.skipped.length} ${tag}`,
209
+ );
210
+ return lines.join('\n');
211
+ }
212
+
213
+ /**
214
+ * Top-level CLI entry. Exported so tests can drive the full pipeline against a
215
+ * tmpdir fixture with an injected config and sinks.
216
+ *
217
+ * @param {{
218
+ * argv?: string[],
219
+ * cwd?: string,
220
+ * config?: object,
221
+ * stdout?: { write: (s: string) => void },
222
+ * stderr?: { write: (s: string) => void },
223
+ * }} [opts]
224
+ * @returns {Promise<number>} 0 = clean / within tolerance / shrink-only / no-op;
225
+ * 1 = a gated tier grew beyond tolerance
226
+ */
227
+ export async function runCli({
228
+ argv = process.argv.slice(2),
229
+ cwd = process.cwd(),
230
+ config,
231
+ stdout = process.stdout,
232
+ stderr = process.stderr,
233
+ } = {}) {
234
+ const { baselinePath, rootPath, update, json } = parseArgv(argv);
235
+ const root = rootPath ? path.resolve(cwd, rootPath) : path.resolve(cwd);
236
+ const resolvedBaselinePath = path.resolve(
237
+ cwd,
238
+ baselinePath ?? path.join('baselines', 'context-budget.json'),
239
+ );
240
+ const resolvedConfig = config ?? resolveConfig();
241
+ const tierMap = resolveDocTiers(resolvedConfig, { root });
242
+
243
+ if (update) {
244
+ const existing = loadBaseline(resolvedBaselinePath);
245
+ const tolerance = Number.isFinite(existing?.toleranceBytes)
246
+ ? existing.toleranceBytes
247
+ : DEFAULT_TOLERANCE_BYTES;
248
+ const envelope = buildBaseline(tierMap, tolerance);
249
+ fs.mkdirSync(path.dirname(resolvedBaselinePath), { recursive: true });
250
+ fs.writeFileSync(
251
+ resolvedBaselinePath,
252
+ `${JSON.stringify(envelope, null, 2)}\n`,
253
+ );
254
+ if (!json) {
255
+ stdout.write(
256
+ `[context-budget] wrote baseline ${resolvedBaselinePath} (tolerance ${tolerance} bytes)\n`,
257
+ );
258
+ } else {
259
+ stdout.write(
260
+ `${JSON.stringify({ kind: 'context-budget-update', baselinePath: resolvedBaselinePath, envelope }, null, 2)}\n`,
261
+ );
262
+ }
263
+ return 0;
264
+ }
265
+
266
+ const baseline = loadBaseline(resolvedBaselinePath);
267
+ if (!baseline) {
268
+ if (json) {
269
+ stdout.write(
270
+ `${JSON.stringify({ kind: 'context-budget-report', baselinePath: resolvedBaselinePath, tiers: tierMap.tiers, grown: [], shrunk: [], skipped: GATED_TIERS, exitCode: 0, noBaseline: true }, null, 2)}\n`,
271
+ );
272
+ } else {
273
+ stderr.write(
274
+ `[context-budget] ⚠ budget not found at ${resolvedBaselinePath} — skipping (no-op)\n`,
275
+ );
276
+ }
277
+ return 0;
278
+ }
279
+
280
+ const diff = diffBudget(tierMap, baseline);
281
+ const exitCode = diff.grown.length > 0 ? 1 : 0;
282
+
283
+ if (json) {
284
+ const envelope = {
285
+ kind: 'context-budget-report',
286
+ baselinePath: resolvedBaselinePath,
287
+ toleranceBytes: Number.isFinite(baseline.toleranceBytes)
288
+ ? baseline.toleranceBytes
289
+ : 0,
290
+ current: Object.fromEntries(
291
+ GATED_TIERS.map((t) => [t, tierTotalBytes(tierMap.tiers[t] ?? [])]),
292
+ ),
293
+ grown: diff.grown,
294
+ shrunk: diff.shrunk,
295
+ skipped: diff.skipped,
296
+ exitCode,
297
+ };
298
+ stdout.write(`${JSON.stringify(envelope, null, 2)}\n`);
299
+ } else {
300
+ stdout.write(`\n--- context-budget preview ---\n`);
301
+ stdout.write(`${renderDiff(diff)}\n`);
302
+ if (exitCode === 1) {
303
+ stderr.write(
304
+ `[context-budget] ❌ a documentation tier grew beyond tolerance — refresh the budget consciously with \`node .agents/scripts/check-context-budget.js --update\` once the growth is intentional\n`,
305
+ );
306
+ }
307
+ }
308
+
309
+ return exitCode;
310
+ }
311
+
312
+ async function main() {
313
+ return runCli();
314
+ }
315
+
316
+ runAsCli(import.meta.url, main, {
317
+ source: 'context-budget',
318
+ propagateExitCode: true,
319
+ errorPrefix: '[context-budget] ❌ Fatal error',
320
+ });
@@ -131,7 +131,7 @@ function buildFrictionSignal({
131
131
  return {
132
132
  kind: 'friction',
133
133
  eventId: crypto.randomUUID(),
134
- timestamp: new Date().toISOString(),
134
+ ts: new Date().toISOString(),
135
135
  epicId: epicId ?? null,
136
136
  storyId: storyId ?? null,
137
137
  // 2-tier hierarchy (Epic #3163): no Task tier, so friction signals
@@ -139,11 +139,11 @@ function buildFrictionSignal({
139
139
  // and always null.
140
140
  taskId: null,
141
141
  category,
142
- source: {
142
+ emitter: {
143
143
  tool: 'diagnose-friction.js',
144
144
  command: commandStr,
145
145
  },
146
- details: errorPreview,
146
+ details: { errorPreview },
147
147
  };
148
148
  }
149
149
 
@@ -209,7 +209,7 @@ export async function main(args = process.argv.slice(2)) {
209
209
 
210
210
  // Story #2874 — accept story-only context (no parent Epic). When
211
211
  // only the story is resolved, write to the standalone signals
212
- // stream at `<tempRoot>/standalone/story-<sid>/signals.ndjson`
212
+ // stream at `<tempRoot>/standalone/stories/story-<sid>/signals.ndjson`
213
213
  // by passing `epicId: null` through to the writer. The only case
214
214
  // we still skip is fully-no-context (story unresolved).
215
215
  if (resolvedStoryId != null) {
@@ -23,7 +23,7 @@
23
23
  *
24
24
  * Story #3939 — depth-aware lenses. The `depth` field tells the audit
25
25
  * executor (`helpers/epic-audit.md`) how thorough each selected lens should
26
- * be on this Epic without ever skipping a selected (or alwaysRun) lens:
26
+ * be on this Epic without ever skipping a selected lens:
27
27
  * `light` shrinks a lens's sweep to the changed surface + Critical/High
28
28
  * findings, `standard` is today's behavior, and `deep` widens the sweep to
29
29
  * the directly-touched modules. Depth never changes which lenses fire, the
@@ -53,6 +53,7 @@
53
53
  * "epicBranch": "epic/2586",
54
54
  * "depth": "deep",
55
55
  * "selectedAudits": ["audit-security", "audit-privacy"],
56
+ * "epicCloseLenses": ["audit-security"],
56
57
  * "changeSetAudits": ["audit-privacy"],
57
58
  * "riskRoutedAudits": ["audit-security"],
58
59
  * "globalLenses": [],
@@ -97,7 +98,10 @@ import {
97
98
  import { defineFlags } from './lib/cli-args.js';
98
99
  import { runAsCli } from './lib/cli-utils.js';
99
100
  import { resolveConfig } from './lib/config-resolver.js';
100
- import { resolveAuditLenses } from './lib/orchestration/code-review.js';
101
+ import {
102
+ resolveAuditLenses,
103
+ selectEpicCloseLenses,
104
+ } from './lib/orchestration/code-review.js';
101
105
  import { read as readPlanState } from './lib/orchestration/epic-plan-state-store.js';
102
106
  import { resolveDepth } from './lib/orchestration/review-depth.js';
103
107
  import { createProvider } from './lib/provider-factory.js';
@@ -121,6 +125,14 @@ Output (JSON envelope on stdout):
121
125
  each SELECTED lens runs; it never changes which lenses
122
126
  fire, the severity taxonomy, or the Phase 4 halting rule.
123
127
  selectedAudits De-duplicated union of changeSetAudits + riskRoutedAudits.
128
+ epicCloseLenses The SLIM Epic-close roster (Story #4412): selectedAudits
129
+ with every local-tier change-set lens excluded, keeping
130
+ only cumulative + global change-set lenses plus every
131
+ risk-routed lens. This is the roster the Phase 5
132
+ code-review pass walks over the cumulative Epic diff —
133
+ local-tier concerns are already verified shift-left
134
+ (write-time checklists + the Story-scope local-lens pass),
135
+ so they are not re-run at Epic close.
124
136
  changeSetAudits Lenses the change-set selector chose.
125
137
  riskRoutedAudits Lenses routed from the model-judged high-risk axes plus the
126
138
  navigability lens when a changed file matches a configured
@@ -409,6 +421,21 @@ export async function runEpicAuditPrepare(values, deps = {}) {
409
421
  );
410
422
  const selectedAudits = unionAudits(changeSetAudits, riskRoutedAudits);
411
423
 
424
+ // Story #4412 (Epic #4405) — the SLIM Epic-close roster. Restrict the gate3
425
+ // selection to the tiers the Epic-close pass owns: cumulative + global
426
+ // change-set lenses plus every risk-routed lens, dropping every local-tier
427
+ // change-set lens (routed off `resolveLensTier` in `selectEpicCloseLenses`).
428
+ // Local-tier concerns are already verified shift-left — the write-time
429
+ // checklist threading (#4410) and the maker-blind Story-scope local-lens
430
+ // pass (#4409) — so re-running them over the cumulative diff at close would
431
+ // verify the same concern at a second tier. This is the roster the Phase 5
432
+ // code-review pass walks; `selectedAudits` stays the pre-slim union for
433
+ // observability.
434
+ const epicCloseLenses = selectEpicCloseLenses({
435
+ changeSetAudits,
436
+ riskRoutedAudits,
437
+ });
438
+
412
439
  // Epic #4131 (F2) — surface which selected lenses are on the global-lens
413
440
  // allowlist so the helper runs them against the WHOLE route tree, exempt from
414
441
  // the cross-epic-leak guard's change-set narrowing (`#3362`). The exemption
@@ -439,6 +466,7 @@ export async function runEpicAuditPrepare(values, deps = {}) {
439
466
  epicBranch,
440
467
  depth,
441
468
  selectedAudits,
469
+ epicCloseLenses,
442
470
  changeSetAudits,
443
471
  riskRoutedAudits,
444
472
  globalLenses,
@@ -10,10 +10,18 @@
10
10
  * subset of audit lenses whose `filePatterns` overlap that list.
11
11
  *
12
12
  * The CLI is intentionally narrower than `select-audits.js`: it does NOT
13
- * run a git diff, does NOT consult the Epic ticket body for keyword
14
- * triggers, and does NOT honor `alwaysRun`. The auto-fix tail already knows
15
- * exactly which paths it touched — only file-pattern overlap is relevant
16
- * for deciding which lenses are stale and need re-invocation.
13
+ * run a git diff and does NOT consult the Epic ticket body for keyword
14
+ * triggers. The auto-fix tail already knows exactly which paths it touched —
15
+ * only file-pattern overlap is relevant for deciding which lenses are stale
16
+ * and need re-invocation.
17
+ *
18
+ * Tier gate (Epic #4405): this is the **Epic-close** re-check, so it emits
19
+ * only non-`local` lenses. A `local`-tier lens is verified shift-left at
20
+ * write-time and Story-scope review; re-running it here would verify the same
21
+ * concern at a second tier. The overlap set is therefore filtered through
22
+ * `resolveLensTier`, dropping every `local` lens (including the universal
23
+ * `audit-clean-code`) so a focused-fix commit cannot drag one back into the
24
+ * Epic-close re-check.
17
25
  *
18
26
  * Usage:
19
27
  * node .agents/scripts/epic-audit-recheck.js \
@@ -40,7 +48,10 @@
40
48
 
41
49
  import fs from 'node:fs';
42
50
  import path from 'node:path';
43
- import { matchesAnyFilePattern } from './lib/audit-suite/index.js';
51
+ import {
52
+ matchesAnyFilePattern,
53
+ resolveLensTier,
54
+ } from './lib/audit-suite/index.js';
44
55
  import { defineFlags } from './lib/cli-args.js';
45
56
  import { runAsCli } from './lib/cli-utils.js';
46
57
  import {
@@ -148,23 +159,45 @@ export function loadAuditRules(deps = {}) {
148
159
 
149
160
  /**
150
161
  * Pure overlap detector. Walks the audit-rules document and returns the
151
- * names of every audit whose `triggers.filePatterns` matches at least one
152
- * entry in `files`. Lenses without `filePatterns` (or with an empty
153
- * array) NEVER appear in the output — `alwaysRun` and keyword triggers
154
- * are intentionally ignored: this CLI answers "what was invalidated by
155
- * the touched paths?", not "what should I run from scratch?".
162
+ * names of every **non-`local`** audit whose `triggers.filePatterns` matches
163
+ * at least one entry in `files`. Lenses without `filePatterns` (or with an
164
+ * empty array) NEVER appear in the output — keyword triggers are
165
+ * intentionally ignored: this CLI answers "what was invalidated by the
166
+ * touched paths?", not "what should I run from scratch?".
167
+ *
168
+ * The output is additionally gated by tier (Epic #4405): a `local`-tier lens
169
+ * is verified shift-left (write-time + Story-scope) and is never re-run at
170
+ * Epic close, so it is dropped here even when its patterns overlap. This
171
+ * matters now that `audit-clean-code` carries the universal `**` glob — its
172
+ * overlap is universal, so without the tier gate every focused-fix commit
173
+ * would drag it (and any other local lens) back into the Epic-close re-check.
156
174
  *
157
175
  * @param {{ audits: Record<string, { triggers?: { filePatterns?: string[] } }> }} rules
158
176
  * @param {string[]} files
177
+ * @param {(lens: string) => string} [resolveLensTierFn] tier resolver
178
+ * (injectable seam; defaults to {@link resolveLensTier}).
159
179
  */
160
- export function selectOverlappingAudits(rules, files) {
180
+ export function selectOverlappingAudits(
181
+ rules,
182
+ files,
183
+ resolveLensTierFn = resolveLensTier,
184
+ ) {
161
185
  const selected = [];
162
186
  for (const [auditName, ruleOpts] of Object.entries(rules?.audits ?? {})) {
163
187
  const patterns = ruleOpts?.triggers?.filePatterns;
164
188
  if (!Array.isArray(patterns) || patterns.length === 0) continue;
165
- if (matchesAnyFilePattern(patterns, files)) {
166
- selected.push(auditName);
189
+ if (!matchesAnyFilePattern(patterns, files)) continue;
190
+ // Epic-close tier gate: skip `local` lenses (verified shift-left). A
191
+ // malformed/unknown tier is drift the schema guards against — skip it
192
+ // rather than throw the whole selection.
193
+ let tier;
194
+ try {
195
+ tier = resolveLensTierFn(auditName);
196
+ } catch {
197
+ continue;
167
198
  }
199
+ if (tier === 'local') continue;
200
+ selected.push(auditName);
168
201
  }
169
202
  return selected;
170
203
  }
@@ -37,12 +37,13 @@ import path from 'node:path';
37
37
  import { parseArgs } from 'node:util';
38
38
 
39
39
  import { runBootSweep } from './boot-sweep.js';
40
+ import { buildChecklistPayload } from './lib/audit-suite/index.js';
40
41
  import { runAsCli } from './lib/cli-utils.js';
41
42
  import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
42
43
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
43
44
  import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
44
45
  import { Logger } from './lib/Logger.js';
45
- import { buildDocsDigest } from './lib/orchestration/docs-digest.js';
46
+ import { ensureDocsDigest } from './lib/orchestration/docs-digest.js';
46
47
  import {
47
48
  resolveOperator,
48
49
  runPrepareGuards,
@@ -60,6 +61,7 @@ import {
60
61
  import { runBuildWaveDagPhase } from './lib/orchestration/epic-runner/phases/build-wave-dag.js';
61
62
  import { runSnapshotPhase } from './lib/orchestration/epic-runner/phases/snapshot.js';
62
63
  import { StoryLauncher } from './lib/orchestration/epic-runner/story-launcher.js';
64
+ import { collectStoryAssumptionEntries } from './lib/orchestration/file-assumptions.js';
63
65
  import {
64
66
  computeBaseSha,
65
67
  readPreflightCache,
@@ -357,14 +359,69 @@ async function writeDocsDigest({ epicId, cwd, config }) {
357
359
  const paths = getPaths(config);
358
360
  const root = path.resolve(cwd ?? process.cwd());
359
361
  const docsRoot = path.resolve(root, paths.docsRoot);
360
- const digest = await buildDocsDigest({ docsContextFiles, docsRoot });
361
- if (digest == null) return null;
362
-
363
362
  const relPath = path.join(paths.tempRoot, `epic-${epicId}`, 'docs-digest.md');
364
363
  const absPath = path.resolve(root, relPath);
365
- await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
366
- await fs.promises.writeFile(absPath, digest, 'utf-8');
367
- return relPath;
364
+ const result = await ensureDocsDigest({
365
+ docsContextFiles,
366
+ docsRoot,
367
+ outputPath: absPath,
368
+ });
369
+ return result ? relPath : null;
370
+ }
371
+
372
+ /**
373
+ * Thread footprint-matched **local**-lens authoring checklists into the
374
+ * per-Story dispatch entries (Epic #4405, Story #4410). For each planned Story,
375
+ * derive its predicted footprint from the full ticket body's `changes[]` /
376
+ * `references[]` entries, build the budget-capped checklist payload (matched by
377
+ * `resolveLensTier(lens) === 'local'` + `matchesAnyFilePattern` — NOT
378
+ * `selectAudits`, so no provider or git diff runs here), and write it to
379
+ * `<tempRoot>/epic-<id>/checklists/story-<sid>.md`. The parent threads the
380
+ * returned repo-relative `checklistPath` into that child's maker prompt, next
381
+ * to `docsDigestPath`; a Story that matches no local lens gets a `null` path
382
+ * and no file.
383
+ *
384
+ * @param {{
385
+ * epicId: number,
386
+ * cwd?: string,
387
+ * config: object,
388
+ * stories: Array<{ storyId: number, worktree?: string, title?: string }>,
389
+ * storyById: Map<number, object>,
390
+ * }} args
391
+ * @returns {Promise<Array<{ storyId: number, worktree?: string, title?: string, checklistPath: string|null }>>}
392
+ */
393
+ export async function writeStoryChecklists({
394
+ epicId,
395
+ cwd,
396
+ config,
397
+ stories,
398
+ storyById,
399
+ buildPayload = buildChecklistPayload,
400
+ }) {
401
+ const paths = getPaths(config);
402
+ const root = path.resolve(cwd ?? process.cwd());
403
+
404
+ return Promise.all(
405
+ stories.map(async (entry) => {
406
+ const ticket = storyById.get(Number(entry.storyId));
407
+ const footprint = ticket
408
+ ? collectStoryAssumptionEntries(ticket).map((e) => e.path)
409
+ : [];
410
+ const { payload } = buildPayload({ footprint, logger: Logger });
411
+ if (!payload) return { ...entry, checklistPath: null };
412
+
413
+ const relPath = path.join(
414
+ paths.tempRoot,
415
+ `epic-${epicId}`,
416
+ 'checklists',
417
+ `story-${entry.storyId}.md`,
418
+ );
419
+ const absPath = path.resolve(root, relPath);
420
+ await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
421
+ await fs.promises.writeFile(absPath, payload, 'utf-8');
422
+ return { ...entry, checklistPath: relPath };
423
+ }),
424
+ );
368
425
  }
369
426
 
370
427
  export async function runEpicDeliverPrepare({
@@ -450,11 +507,26 @@ export async function runEpicDeliverPrepare({
450
507
  // dispatch from. This is a dispatch *hint* — the ready-set tick decides
451
508
  // which Stories to dispatch on each beat; the prepare only enumerates them.
452
509
  const launcher = new StoryLauncher({ concurrencyCap });
453
- const stories = launcher.planWave(openStories).map((entry, i) => ({
510
+ const plannedStories = launcher.planWave(openStories).map((entry, i) => ({
454
511
  ...entry,
455
512
  title: openStories[i]?.title ?? '',
456
513
  }));
457
514
 
515
+ // Thread footprint-matched local-lens authoring checklists into each Story's
516
+ // dispatch entry (Story #4410). Keyed off the full discovered tickets (which
517
+ // carry the `changes[]`/`references[]` bodies the footprint is derived from);
518
+ // a Story that matches no local lens gets a `null` checklistPath.
519
+ const storyById = new Map(
520
+ (state.stories ?? []).map((t) => [Number(t.id ?? t.number), t]),
521
+ );
522
+ const stories = await writeStoryChecklists({
523
+ epicId,
524
+ cwd,
525
+ config,
526
+ stories: plannedStories,
527
+ storyById,
528
+ });
529
+
458
530
  // Persist the `--ignore-concurrency-hazards` flag on the checkpoint so
459
531
  // retro tooling can flag a run that shipped despite an outstanding hazard
460
532
  // (the warning above is one-shot; the checkpoint is durable).
@@ -70,10 +70,6 @@ import {
70
70
  planEpic,
71
71
  resolveAcceptancePersistence,
72
72
  } from './lib/orchestration/epic-plan-spec/phases/plan-epic.js';
73
- import {
74
- ACCEPTANCE_SPEC_SYSTEM_PROMPT,
75
- TECH_SPEC_SYSTEM_PROMPT,
76
- } from './lib/orchestration/epic-plan-spec/phases/prompts.js';
77
73
  import {
78
74
  loadRiskVerdict,
79
75
  validateRiskVerdict,
@@ -86,7 +82,6 @@ import { createProvider } from './lib/provider-factory.js';
86
82
  // Re-exports for stable public API: tests and external callers import these
87
83
  // from `epic-plan-spec.js`. The implementations live in `phases/`.
88
84
  export {
89
- ACCEPTANCE_SPEC_SYSTEM_PROMPT,
90
85
  buildAuthoringContext,
91
86
  drainPendingCleanupAtBoot,
92
87
  loadRiskVerdict,
@@ -96,7 +91,6 @@ export {
96
91
  resolveReviewRouting,
97
92
  runSpecFreshnessCheck,
98
93
  runSpecPhase,
99
- TECH_SPEC_SYSTEM_PROMPT,
100
94
  validateRiskVerdict,
101
95
  };
102
96
 
@@ -126,8 +120,10 @@ async function main() {
126
120
  // Story #2278 — in --emit-context mode stdout is reserved for the JSON
127
121
  // envelope. Flip every Logger sink that could land on stdout to stderr
128
122
  // *before* any pipeline code runs (drainPendingCleanupAtBoot,
129
- // buildAuthoringContext → buildDocsContext scrapeProjectDocs), so a
130
- // captured file is unconditionally parseable by `JSON.parse`.
123
+ // buildAuthoringContext → ensureDocsDigest Story #4433 cut the digest
124
+ // build over from the old buildDocsContext/scrapeProjectDocs full-content
125
+ // read path), so a captured file is unconditionally parseable by
126
+ // `JSON.parse`.
131
127
  if (emitContext) routeAllOutputToStderr();
132
128
 
133
129
  try {