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,306 @@
1
+ /**
2
+ * planning-corpus.js — corpus-aware context for the standalone-Story
3
+ * planning path (Story #4432).
4
+ *
5
+ * `/plan --idea` previously drafted a standalone Story from a blank
6
+ * slate: the seed, the body template, and a title-only duplicate scan.
7
+ * For a change request that is really a small delta against an
8
+ * already-delivered surface, that blank slate throws away context the
9
+ * project already has — the docs digest and the relevant Tech Spec
10
+ * sections of existing Epics that cover the touched area.
11
+ *
12
+ * This module assembles that inherited context (`corpusContext`) for
13
+ * `story-plan.js`'s `--emit-context` envelope:
14
+ *
15
+ * 1. `docsDigest` — the same per-project docs digest
16
+ * `orchestration/docs-digest.js` builds for `/deliver` Story
17
+ * children, reused here so the standalone path gets the same
18
+ * compact outline instead of re-reading the whole docs set.
19
+ * `null` when `project.docsContextFiles` is not configured.
20
+ * 2. `relevantSections` — a ranked list of existing Epic Tech Spec
21
+ * (or lede, when no Tech Spec region exists) excerpts that overlap
22
+ * with the seed, so the draft can build on prior art instead of
23
+ * re-deriving it.
24
+ *
25
+ * The Epic list surface (`provider.getEpics`) maps every issue through
26
+ * `issueToEpicListItem`, which deliberately omits `body` (a list-scale
27
+ * payload trim). Body content therefore requires an **explicit**,
28
+ * bounded per-candidate fetch via `provider.getEpic(id)` — never a
29
+ * silent assumption that the list response carries prose to score
30
+ * against.
31
+ *
32
+ * Relevance scoring reuses the same `tokenize` / `overlapScore` Jaccard
33
+ * primitives `duplicate-search.js` exports for Epic-dedupe and
34
+ * `story-plan.js` reuses for Story-dedupe — one matcher, three
35
+ * consumers, no forked scoring logic.
36
+ */
37
+
38
+ import { overlapScore, tokenize } from './duplicate-search.js';
39
+ import { extractEpicSection, hasEpicSection } from './epic-body-sections.js';
40
+ import { Logger } from './Logger.js';
41
+ import { buildDocsDigest } from './orchestration/docs-digest.js';
42
+
43
+ /** Top-K Epics kept after the cheap title-only ranking pass. */
44
+ const DEFAULT_CORPUS_MAX_CANDIDATES = 5;
45
+
46
+ /**
47
+ * Bound on the explicit per-candidate body fetch. Keeps corpus-context
48
+ * assembly at a fixed, small number of GitHub reads regardless of how
49
+ * many open Epics the repo carries.
50
+ */
51
+ export const DEFAULT_CORPUS_BODY_FETCH_TOP_K = 3;
52
+
53
+ /** Minimum Jaccard overlap for a section excerpt to be worth surfacing. */
54
+ const DEFAULT_CORPUS_MIN_SCORE = 0.1;
55
+
56
+ /** Max relevant-section excerpts returned in the envelope. */
57
+ const DEFAULT_CORPUS_MAX_SECTIONS = 5;
58
+
59
+ /** Excerpt length cap (chars) so one oversized Tech Spec doesn't blow the envelope. */
60
+ const EXCERPT_MAX_CHARS = 600;
61
+
62
+ /**
63
+ * Page-scan cap passed to `provider.getEpics({ state: 'open', pageCap })`.
64
+ * The corpus lookup only ever ranks the list down to a top-5 shortlist
65
+ * (`DEFAULT_CORPUS_MAX_CANDIDATES`), so there is no need to inherit
66
+ * `paginateRest`'s full default ceiling (50 pages / 5000 items) to build
67
+ * it — a bounded scan keeps this call a fixed, small number of GitHub
68
+ * reads regardless of how many open Epics the repo carries.
69
+ */
70
+ const CORPUS_EPICS_PAGE_CAP = 5;
71
+
72
+ /**
73
+ * Rank open Epics by title-overlap with the seed. This is the cheap
74
+ * first pass over the list surface (title only — `issueToEpicListItem`
75
+ * has no `body`), used solely to pick the bounded top-K candidates
76
+ * worth an explicit body fetch. It is not the final relevance signal;
77
+ * `extractRelevantSections` re-scores against actual section content.
78
+ *
79
+ * @param {{ seed: string, epics: Array<{ id:number, title:string }>, maxResults?: number }} opts
80
+ * @returns {Array<{ id:number, title:string, score:number }>}
81
+ */
82
+ export function rankCandidateEpics({
83
+ seed,
84
+ epics,
85
+ maxResults = DEFAULT_CORPUS_MAX_CANDIDATES,
86
+ }) {
87
+ if (!seed || typeof seed !== 'string') {
88
+ throw new Error('rankCandidateEpics: seed must be a non-empty string');
89
+ }
90
+ if (!Array.isArray(epics)) {
91
+ throw new Error('rankCandidateEpics: epics must be an array');
92
+ }
93
+ const seedTokens = tokenize(seed);
94
+ if (seedTokens.size === 0) return [];
95
+
96
+ const ranked = [];
97
+ for (const epic of epics) {
98
+ if (!epic || typeof epic.title !== 'string') continue;
99
+ const score = overlapScore(seedTokens, tokenize(epic.title));
100
+ ranked.push({
101
+ id: epic.id,
102
+ title: epic.title,
103
+ score: Number(score.toFixed(4)),
104
+ });
105
+ }
106
+ ranked.sort((a, b) => b.score - a.score);
107
+ return ranked.slice(0, maxResults);
108
+ }
109
+
110
+ /**
111
+ * Fetch full bodies for the top-K ranked candidates via the single-issue
112
+ * read (`provider.getEpic`), which — unlike the `getEpics` list mapper —
113
+ * does carry `body`. This is the explicit, bounded fetch the corpus
114
+ * lookup performs instead of assuming the list surface already has
115
+ * prose to score: a candidate never contributes a relevant section
116
+ * without this round-trip resolving its body.
117
+ *
118
+ * A single candidate's fetch failing (deleted issue, transient error) is
119
+ * non-fatal — it is dropped from the result rather than aborting corpus
120
+ * assembly for every other candidate. Failures are logged via
121
+ * `Logger.debug` (stderr) so they are visible under
122
+ * `AGENT_LOG_LEVEL=verbose` triage without violating the friction-
123
+ * telemetry posture in `.agents/instructions.md` §1.H of never silently
124
+ * swallowing an error.
125
+ *
126
+ * The bounded candidate slice is fetched concurrently
127
+ * (`Promise.allSettled`) rather than sequentially — `topK` is a fixed
128
+ * small ceiling (default 3), so this is a bounded fan-out, not an
129
+ * unbounded one, and it removes the serial network-latency stacking a
130
+ * plain `for`-await loop would otherwise incur.
131
+ *
132
+ * @param {{ provider: object, candidates: Array<{ id:number, title:string }>, topK?: number }} opts
133
+ * @returns {Promise<Array<{ id:number, title:string, body:string }>>}
134
+ */
135
+ export async function fetchCandidateBodies({
136
+ provider,
137
+ candidates,
138
+ topK = DEFAULT_CORPUS_BODY_FETCH_TOP_K,
139
+ }) {
140
+ if (!provider || typeof provider.getEpic !== 'function') return [];
141
+ if (!Array.isArray(candidates) || candidates.length === 0) return [];
142
+
143
+ const bounded = candidates.slice(0, topK);
144
+ const settled = await Promise.allSettled(
145
+ bounded.map(async (candidate) => {
146
+ const epic = await provider.getEpic(candidate.id);
147
+ return {
148
+ id: candidate.id,
149
+ title: candidate.title ?? epic?.title ?? '',
150
+ body: epic?.body ?? '',
151
+ };
152
+ }),
153
+ );
154
+
155
+ const results = [];
156
+ for (let i = 0; i < settled.length; i += 1) {
157
+ const outcome = settled[i];
158
+ if (outcome.status === 'fulfilled') {
159
+ results.push(outcome.value);
160
+ continue;
161
+ }
162
+ // Best-effort: one candidate failing to resolve must not abort
163
+ // corpus-context assembly for the rest — but the failure is still
164
+ // surfaced for triage rather than silently swallowed.
165
+ Logger.debug(
166
+ `[planning-corpus] fetchCandidateBodies: candidate #${bounded[i].id} failed to resolve: ${outcome.reason?.message ?? outcome.reason}`,
167
+ );
168
+ }
169
+ return results;
170
+ }
171
+
172
+ /**
173
+ * Extract a scoreable excerpt from an Epic body: the managed Tech Spec
174
+ * region when present (the folded `## Delivery Slicing` section, #4324),
175
+ * otherwise the ideation lede — the prose before the first `##` heading
176
+ * — so a plain-body Epic still contributes something to score.
177
+ *
178
+ * @param {string} body
179
+ * @returns {{ kind:'techSpec'|'lede', content:string }}
180
+ */
181
+ function extractScoreableExcerpt(body) {
182
+ if (hasEpicSection(body, 'techSpec')) {
183
+ return { kind: 'techSpec', content: extractEpicSection(body, 'techSpec') };
184
+ }
185
+ const lede = (body ?? '').split(/^##\s+/m)[0].trim();
186
+ return { kind: 'lede', content: lede };
187
+ }
188
+
189
+ /**
190
+ * Rank existing-Epic body excerpts (Tech Spec section, or lede) by
191
+ * overlap with the seed. Reuses the same `tokenize` / `overlapScore`
192
+ * primitives as the title-ranking pass above and as
193
+ * `duplicate-search.js` — one matcher shared across every corpus /
194
+ * dedupe surface.
195
+ *
196
+ * @param {{ seed:string, epicBodies: Array<{ id:number, title:string, body:string }>, maxResults?: number, minScore?: number }} opts
197
+ * @returns {Array<{ epicId:number, epicTitle:string, section:'techSpec'|'lede', score:number, excerpt:string }>}
198
+ */
199
+ export function extractRelevantSections({
200
+ seed,
201
+ epicBodies,
202
+ maxResults = DEFAULT_CORPUS_MAX_SECTIONS,
203
+ minScore = DEFAULT_CORPUS_MIN_SCORE,
204
+ }) {
205
+ if (!seed || typeof seed !== 'string') {
206
+ throw new Error('extractRelevantSections: seed must be a non-empty string');
207
+ }
208
+ if (!Array.isArray(epicBodies)) {
209
+ throw new Error('extractRelevantSections: epicBodies must be an array');
210
+ }
211
+ const seedTokens = tokenize(seed);
212
+ if (seedTokens.size === 0) return [];
213
+
214
+ const ranked = [];
215
+ for (const epic of epicBodies) {
216
+ if (!epic) continue;
217
+ const { kind, content } = extractScoreableExcerpt(epic.body ?? '');
218
+ if (!content) continue;
219
+ const score = overlapScore(seedTokens, tokenize(content));
220
+ if (score < minScore) continue;
221
+ ranked.push({
222
+ epicId: epic.id,
223
+ epicTitle: epic.title ?? null,
224
+ section: kind,
225
+ score: Number(score.toFixed(4)),
226
+ excerpt: content.slice(0, EXCERPT_MAX_CHARS),
227
+ });
228
+ }
229
+ ranked.sort((a, b) => b.score - a.score);
230
+ return ranked.slice(0, maxResults);
231
+ }
232
+
233
+ /**
234
+ * Assemble the `corpusContext` field of the story-plan context envelope.
235
+ * Pure orchestration over the three helpers above plus `buildDocsDigest`
236
+ * — no I/O beyond what `provider` and the docs-digest reader perform.
237
+ *
238
+ * `relevantSections` is `[]` (not an error) when the provider has no
239
+ * `getEpics` surface, the seed tokenizes to nothing, or no candidate
240
+ * clears `minScore` — the standalone-Story draft path degrades to
241
+ * exactly today's blank-slate behavior in that case.
242
+ *
243
+ * @param {{
244
+ * seed: string,
245
+ * provider?: { getEpics?: Function, getEpic?: Function },
246
+ * docsContextFiles?: string[],
247
+ * docsRoot?: string,
248
+ * maxCandidates?: number,
249
+ * bodyFetchTopK?: number,
250
+ * maxSections?: number,
251
+ * minScore?: number,
252
+ * }} opts
253
+ * @returns {Promise<{ docsDigest: string|null, relevantSections: Array<object> }>}
254
+ */
255
+ export async function buildCorpusContext({
256
+ seed,
257
+ provider,
258
+ docsContextFiles,
259
+ docsRoot,
260
+ maxCandidates = DEFAULT_CORPUS_MAX_CANDIDATES,
261
+ bodyFetchTopK = DEFAULT_CORPUS_BODY_FETCH_TOP_K,
262
+ maxSections = DEFAULT_CORPUS_MAX_SECTIONS,
263
+ minScore = DEFAULT_CORPUS_MIN_SCORE,
264
+ }) {
265
+ const docsDigest = await buildDocsDigest({ docsContextFiles, docsRoot });
266
+
267
+ let relevantSections = [];
268
+ if (provider && typeof provider.getEpics === 'function') {
269
+ // A single candidate-listing call failing (rate limit, transient
270
+ // network error, provider outage) must not abort the whole
271
+ // `--emit-context` envelope build — degrade to an empty candidate
272
+ // list instead of letting the rejection propagate out of the
273
+ // caller's `Promise.all` and take down the rest of the envelope
274
+ // (body template, tech-stack summary, docs digest) with it.
275
+ let epics = [];
276
+ try {
277
+ epics = await provider.getEpics({
278
+ state: 'open',
279
+ pageCap: CORPUS_EPICS_PAGE_CAP,
280
+ });
281
+ } catch (err) {
282
+ Logger.debug(
283
+ `[planning-corpus] buildCorpusContext: provider.getEpics failed, degrading to an empty candidate list: ${err?.message ?? err}`,
284
+ );
285
+ epics = [];
286
+ }
287
+ const ranked = rankCandidateEpics({
288
+ seed,
289
+ epics: Array.isArray(epics) ? epics : [],
290
+ maxResults: maxCandidates,
291
+ });
292
+ const bodies = await fetchCandidateBodies({
293
+ provider,
294
+ candidates: ranked,
295
+ topK: bodyFetchTopK,
296
+ });
297
+ relevantSections = extractRelevantSections({
298
+ seed,
299
+ epicBodies: bodies,
300
+ maxResults: maxSections,
301
+ minScore,
302
+ });
303
+ }
304
+
305
+ return { docsDigest, relevantSections };
306
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * detectors/common.js — shared helpers for the signals layer.
3
3
  *
4
- * Hoisted out of three detector modules (hotspot, retry, rework) plus
4
+ * Hoisted out of the detector modules (retry, rework) plus
5
5
  * `signals/read.js` and `signals/schema.js`, all of which shipped
6
6
  * byte-equivalent copies of these predicates. See Story #2464.
7
7
  */
@@ -19,15 +19,15 @@ export function isPositiveInt(v) {
19
19
 
20
20
  /**
21
21
  * Pull the tool name from a trace record. The hook writes the tool name
22
- * into `source.tool` and (defensively) into `details.tool` — we accept
23
- * either so older traces still classify correctly.
22
+ * into `emitter.tool` (canonical provenance) and, defensively, into
23
+ * `details.tool` we accept either.
24
24
  *
25
25
  * @param {object} rec
26
26
  * @returns {string|null}
27
27
  */
28
28
  export function extractTool(rec) {
29
- if (typeof rec?.source?.tool === 'string' && rec.source.tool.length > 0) {
30
- return rec.source.tool;
29
+ if (typeof rec?.emitter?.tool === 'string' && rec.emitter.tool.length > 0) {
30
+ return rec.emitter.tool;
31
31
  }
32
32
  if (typeof rec?.details?.tool === 'string' && rec.details.tool.length > 0) {
33
33
  return rec.details.tool;
@@ -38,12 +38,12 @@ export function extractTool(rec) {
38
38
  /**
39
39
  * Validate and normalize the shared detector argument preamble.
40
40
  *
41
- * `detectRework`, `detectRetry`, and `detectHotspot` previously shipped a
42
- * near-identical guard block: the `args` object-shape `TypeError`, the
43
- * `nowFn` function-type `TypeError`, the positive-integer `RangeError`s for
44
- * the id fields, the non-empty-string `tracesPath` check, and the
41
+ * `detectRework` and `detectRetry` previously shipped a near-identical
42
+ * guard block: the `args` object-shape `TypeError`, the `nowFn`
43
+ * function-type `TypeError`, the positive-integer `RangeError`s for the id
44
+ * fields, the non-empty-string `tracesPath` check, and the
45
45
  * non-negative-integer `threshold` check. Story #4077 hoists that preamble
46
- * here so the three detectors share one error-message contract.
46
+ * here so the detectors share one error-message contract.
47
47
  *
48
48
  * Error wording stays per-detector-accurate by prefixing every message with
49
49
  * `fnName` (e.g. `detectRework: …`). Error *types* are preserved exactly:
@@ -2,13 +2,13 @@
2
2
  * Detectors barrel (Epic #1721 / Story #1771 / Task #1774).
3
3
  *
4
4
  * Single import surface for every signal detector. Detector Stories
5
- * (rework in #1771, retry in #1768, hotspot in #1769) re-export from
6
- * here so callers (`lib/observability/perf-aggregator.js`, future
7
- * emission orchestrators) only ever import from one place.
5
+ * (rework in #1771, retry in #1768) re-export from here so callers
6
+ * (`lib/orchestration/detectors-phase.js`) only ever import from one
7
+ * place. The Epic #1769 hotspot detector was retired in the Epic #4406
8
+ * signal-contract cutover (no live emitter, no consumer).
8
9
  *
9
10
  * @module lib/signals/detectors
10
11
  */
11
12
 
12
- export { detectHotspot, nearestRankP95 } from './hotspot.js';
13
13
  export { detectRetry } from './retry.js';
14
14
  export { detectRework } from './rework.js';
@@ -27,11 +27,12 @@
27
27
  * ## Failure rule
28
28
  *
29
29
  * A trace record is treated as **failed** when its `details.exitCode`
30
- * is a number and not `0`. The hook does not itself capture the exit
31
- * code today (the field is set by callers / future hook extensions);
32
- * records without an `exitCode` field are ignored, which matches the
33
- * decision in the parent Epic body that retry only counts non-zero-exit
34
- * commands.
30
+ * is a number and not `0`. As of Epic #4406 / Story #4413 the tool-trace
31
+ * hook captures `details.exitCode` for Bash `PostToolUse` events, so this
32
+ * detector fires on real deliveries; records without an `exitCode` field
33
+ * (non-Bash tools, or tools that report no exit code) are ignored, which
34
+ * matches the decision in the parent Epic body that retry only counts
35
+ * non-zero-exit commands.
35
36
  *
36
37
  * Successful runs after failures **do not** cancel the count — failure-
37
38
  * count is monotonic per identity. This matches the Epic's intent: once
@@ -41,12 +42,11 @@
41
42
  *
42
43
  * ## Tool filter
43
44
  *
44
- * Only trace records whose `source.tool === 'Bash'` participate. Edit /
45
+ * Only trace records whose `emitter.tool === 'Bash'` participate. Edit /
45
46
  * Write / Read / Grep / Glob events are not retries — those belong to
46
- * other detectors (rework for file-edit churn, hotspot at Epic scope).
47
- * The tool name is read from `source.tool` first and falls back to
48
- * `details.tool` to mirror the rework detector's tolerance for legacy
49
- * trace shapes.
47
+ * other detectors (rework for file-edit churn). The tool name is read
48
+ * from `emitter.tool` first and falls back to `details.tool` (see
49
+ * `common.extractTool`).
50
50
  *
51
51
  * ## Privacy contract
52
52
  *
@@ -124,13 +124,14 @@ function resolveIdentity(rec) {
124
124
  }
125
125
 
126
126
  /**
127
- * Decide whether a trace record represents a failed invocation. The
128
- * hook (today) does not capture exit codes; the field is populated by
129
- * future hook extensions and by tests that need to assert behaviour.
130
- * A record counts as failed when `details.exitCode` is a number and
131
- * not zero. Anything else (missing field, null, non-number, zero) is
132
- * NOT a failure and is ignored entirely — the detector only counts
133
- * non-zero-exit commands per the parent Epic.
127
+ * Decide whether a trace record represents a failed invocation. As of
128
+ * Epic #4406 / Story #4413 the tool-trace hook records `details.exitCode`
129
+ * for Bash `PostToolUse` events, so the field is present on real Bash
130
+ * traces (and still set directly by tests). A record counts as failed
131
+ * when `details.exitCode` is a number and not zero. Anything else
132
+ * (missing field, null, non-number, zero) is NOT a failure and is ignored
133
+ * entirely — the detector only counts non-zero-exit commands per the
134
+ * parent Epic.
134
135
  *
135
136
  * @param {object} rec
136
137
  * @returns {boolean}
@@ -238,7 +239,7 @@ export async function detectRetry(args) {
238
239
  return offenders.map(([commandHash, failureCount]) => ({
239
240
  ts,
240
241
  kind: 'retry',
241
- source: { tool: 'retry-detector' },
242
+ emitter: { tool: 'retry-detector' },
242
243
  epicId,
243
244
  storyId,
244
245
  taskId,
@@ -158,7 +158,7 @@ export async function detectRework(args) {
158
158
  return offenders.map(([targetHash, editCount]) => ({
159
159
  ts,
160
160
  kind: 'rework',
161
- source: { tool: 'rework-detector' },
161
+ emitter: { tool: 'rework-detector' },
162
162
  epicId,
163
163
  storyId,
164
164
  taskId,
@@ -18,48 +18,50 @@
18
18
  * the codebase as of Epic #1181 (audit-snapshot 2026-05-11):
19
19
  *
20
20
  * Signals-writer `appendSignal` call sites:
21
- * - `friction` — quality-gate / runtime friction (check-crap.js,
22
- * check-maintainability.js, diagnose-friction.js,
23
- * post-merge-pipeline.js, progress-reporter.js,
24
- * auto-refresh-runner.js)
25
- * - `dispatched` — (no live emitter; retained pending Story #3908 sweep)
21
+ * - `friction` — quality-gate / runtime friction (diagnose-friction.js,
22
+ * lib/gates/friction.js, auto-refresh-runner.js,
23
+ * worktree-reap.js, lifecycle-emit.js)
24
+ * - `acceptance-eval` — acceptance-eval.js per-criterion terminus signal.
26
25
  * - `wave-start` / `wave-complete` — `lib/wave-runner/tick.js` (read by
27
26
  * perf-aggregator's `waveParallelism` report;
28
27
  * `wave-start` also anchors span-tree Story spans)
29
- * - `wave-end` — span-tree pairing anchor (no live emitter after the
30
- * Epic #2646 listener deletion; retained for span-tree)
31
- * - `state-transition` — orchestration/ticketing.js
28
+ * - `wave-end` — span-tree pairing anchor (retained for span-tree)
29
+ * - `state-transition` notification-derived window anchor read by the
30
+ * waveParallelism bucketer.
32
31
  *
33
- * Story #3909 retired the write-only wave kinds with no consumer (`wave-tick`,
34
- * `epic-complete`)they duplicated the checkpoint + `epic-run-progress`
35
- * rollup and nothing read them back.
32
+ * The `dispatched` kind was deleted in the Epic #4406 signal-contract
33
+ * cutoverit had no live emitter and no consumer.
36
34
  *
37
35
  * Signals-writer `appendTrace` call sites (traces.ndjson sibling, but
38
36
  * sharing the same envelope shape — `tool-trace-hook.js`):
39
37
  * - `trace` — per-tool-call timing record
40
38
  *
41
- * Aggregator-consumed kinds (perf-aggregator.js scans these — emitters
42
- * for `hotspot`, `rework`, `churn`, `idle`, `retry` are future Epic #1030
43
- * detector Stories that the aggregator was built to receive; the schema
44
- * pins the names so emitters land on a known shape):
39
+ * Detector-emitted / aggregator-consumed kinds (`rework`, `retry` are
40
+ * emitted by `lib/signals/detectors/*`; `hotspot`, `churn`, `idle` remain
41
+ * pinned in the enum for the aggregator's kind-count rollup even though no
42
+ * live detector emits them):
45
43
  * - `hotspot`, `rework`, `churn`, `idle`, `retry`
46
44
  *
47
- * ## Common envelope
45
+ * ## Common envelope (canonical — Epic #4406 / Story #4413)
48
46
  *
49
47
  * Every signal MUST carry at minimum:
50
- * - `ts` — ISO-8601 timestamp string (writers historically used
51
- * `timestamp:` instead; both keys are accepted by the
52
- * guards below for backward-compat the migration to
53
- * `ts:` lands in a follow-on Story).
54
- * - `epic` — integer Epic ID (writers historically used `epicId:`;
55
- * same backward-compat note).
48
+ * - `ts` — ISO-8601 timestamp string. The single canonical
49
+ * timestamp key; the legacy `timestamp:` alias was deleted
50
+ * from every writer and this guard in the same PR.
56
51
  * - `kind` — one of `EVENT_KINDS`.
57
52
  *
53
+ * Scoped signals additionally carry:
54
+ * - `epicId` — integer Epic ID (or `null` for standalone-Story
55
+ * friction). The single canonical epic-id key; the legacy
56
+ * `epic:` alias was deleted.
57
+ *
58
58
  * Optional but commonly carried:
59
- * - `story` / `storyId` — integer Story ID
60
- * - `task` / `taskId` — integer Task ID (nullable)
61
- * - `source` — `{ tool: string }`
62
- * - `details` kind-specific payload (object or string)
59
+ * - `storyId` — integer Story ID
60
+ * - `taskId` — integer Task ID (nullable)
61
+ * - `emitter` — `{ tool: string, command?: string }` provenance
62
+ * - `source` `"framework" | "consumer"` classifier tag
63
+ * - `category` — top-level friction category string
64
+ * - `details` — kind-specific payload (always an object)
63
65
  *
64
66
  * @module lib/signals/schema
65
67
  */
@@ -77,7 +79,6 @@ import { isPositiveInt } from './detectors/common.js';
77
79
  export const EVENT_KINDS = Object.freeze({
78
80
  FRICTION: 'friction',
79
81
  TRACE: 'trace',
80
- DISPATCHED: 'dispatched',
81
82
  // Wave-window forensics signals: `wave-start` / `wave-end` anchor the
82
83
  // span-tree's Story spans, and the perf-aggregator brackets each wave's
83
84
  // wall-clock from `wave-start` → `wave-complete` (the `waveParallelism`
@@ -101,6 +102,13 @@ export const EVENT_KINDS = Object.freeze({
101
102
  // the retro and /plan Phase 0 feedback fetch can see acceptance
102
103
  // churn alongside friction/hotspot data.
103
104
  ACCEPTANCE_EVAL: 'acceptance-eval',
105
+ // Forensic breadcrumb appended to the per-Epic stream by the notify
106
+ // dispatcher (lib/orchestration/lifecycle/listeners/notify-dispatcher.js)
107
+ // when a lifecycle event maps to a webhook notification — the resume
108
+ // suite reads it back to prove a dispatch survived a crash window without
109
+ // duplicating. Enumerated so the write-time validator (Story #4413) does
110
+ // not drop it; it is a deliberate write, not malformed data.
111
+ NOTIFICATION_EMITTED: 'notification.emitted',
104
112
  });
105
113
 
106
114
  /**
@@ -117,20 +125,17 @@ export const EVENT_KIND_VALUES = Object.freeze(
117
125
  * spread string literals across the module graph.
118
126
  */
119
127
  export const FIELDS = Object.freeze({
120
- // Envelope (canonical namesmigration to these is in flight)
128
+ // Canonical envelope keysthere is exactly one key per concept. The
129
+ // legacy `timestamp` / `epic` / `story` / `task` aliases were deleted in
130
+ // the Epic #4406 signal-contract cutover; no reader tolerates them.
121
131
  TS: 'ts',
122
- EPIC: 'epic',
123
- STORY: 'story',
124
- TASK: 'task',
125
- KIND: 'kind',
126
-
127
- // Legacy envelope aliases still emitted by some writers
128
- TIMESTAMP: 'timestamp',
129
132
  EPIC_ID: 'epicId',
130
133
  STORY_ID: 'storyId',
131
134
  TASK_ID: 'taskId',
135
+ KIND: 'kind',
132
136
 
133
137
  // Common payload fields
138
+ EMITTER: 'emitter',
134
139
  SOURCE: 'source',
135
140
  DETAILS: 'details',
136
141
  CATEGORY: 'category',
@@ -150,8 +155,9 @@ function isTimestamp(v) {
150
155
  }
151
156
 
152
157
  /**
153
- * Common envelope guard: every signal MUST carry `ts` (or legacy
154
- * `timestamp`), `epic` (or legacy `epicId`), and a recognised `kind`.
158
+ * Common envelope guard: every signal MUST carry `ts`, `epicId`, and a
159
+ * recognised `kind`. Canonical keys only — the legacy `timestamp` / `epic`
160
+ * aliases were deleted in the Epic #4406 cutover.
155
161
  *
156
162
  * Returns true when the envelope is well-formed. Used by `lib/signals/read`
157
163
  * to discard malformed lines before yielding them to the consumer.
@@ -164,16 +170,24 @@ export function hasCommonEnvelope(evt) {
164
170
  if (typeof evt.kind !== 'string' || !EVENT_KIND_VALUES.has(evt.kind)) {
165
171
  return false;
166
172
  }
167
- const ts = evt.ts ?? evt.timestamp;
168
- if (!isTimestamp(ts)) return false;
169
- const epic = evt.epic ?? evt.epicId;
170
- if (!isPositiveInt(epic)) return false;
173
+ if (!isTimestamp(evt.ts)) return false;
174
+ // The canonical `epicId` key MUST be present — a record carrying only the
175
+ // legacy `epic` alias is rejected (the cutover deleted that alias). Its
176
+ // value is nullable by contract: standalone-Story friction carries
177
+ // `epicId: null` (see signal-event.schema.json, where epicId is
178
+ // `["integer","null"]`). So accept an explicit null, reject a missing key
179
+ // or a present-but-non-positive-int value.
180
+ if (!Object.hasOwn(evt, 'epicId')) return false;
181
+ if (evt.epicId !== null && !isPositiveInt(evt.epicId)) return false;
171
182
  return true;
172
183
  }
173
184
 
174
185
  /**
175
186
  * Generic per-kind guard. Returns true when the envelope is well-formed
176
- * AND (when `kind` is supplied) the event's `kind` matches.
187
+ * AND (when `kind` is supplied) the event's `kind` matches. The full
188
+ * canonical-shape check lives in the AJV validator compiled from
189
+ * `signal-event.schema.json` (see `lib/observability/signal-validator.js`);
190
+ * this predicate is the cheap envelope gate the streaming reader uses.
177
191
  *
178
192
  * @param {unknown} evt
179
193
  * @param {string} [kind] — optional kind to match (one of `EVENT_KINDS`).
@@ -184,42 +198,3 @@ export function isValidSignal(evt, kind) {
184
198
  if (kind != null && evt.kind !== kind) return false;
185
199
  return true;
186
200
  }
187
-
188
- /**
189
- * Per-kind shape guards. Each entry asserts the envelope plus any
190
- * required per-kind fields. Unknown kinds fall back to the envelope
191
- * check.
192
- *
193
- * The guards are intentionally lax — they reject records that are
194
- * obviously malformed (missing `kind`, missing `ts`, missing `epic`),
195
- * not records with extra fields or future schema extensions. The
196
- * aggregator (perf-aggregator.js) carries its own per-kind narrowing.
197
- *
198
- * @type {Readonly<Record<string, (evt: unknown) => boolean>>}
199
- */
200
- export const GUARDS = Object.freeze({
201
- [EVENT_KINDS.FRICTION]: (evt) => {
202
- if (!isValidSignal(evt, EVENT_KINDS.FRICTION)) return false;
203
- // friction signals commonly carry a `category` field, but some
204
- // writers (early in the migration) omit it. We accept both.
205
- return true;
206
- },
207
- [EVENT_KINDS.TRACE]: (evt) => isValidSignal(evt, EVENT_KINDS.TRACE),
208
- [EVENT_KINDS.DISPATCHED]: (evt) => isValidSignal(evt, EVENT_KINDS.DISPATCHED),
209
- [EVENT_KINDS.WAVE_START]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_START),
210
- [EVENT_KINDS.WAVE_END]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_END),
211
- [EVENT_KINDS.WAVE_TICK]: (evt) => isValidSignal(evt, EVENT_KINDS.WAVE_TICK),
212
- [EVENT_KINDS.WAVE_COMPLETE]: (evt) =>
213
- isValidSignal(evt, EVENT_KINDS.WAVE_COMPLETE),
214
- [EVENT_KINDS.EPIC_COMPLETE]: (evt) =>
215
- isValidSignal(evt, EVENT_KINDS.EPIC_COMPLETE),
216
- [EVENT_KINDS.STATE_TRANSITION]: (evt) =>
217
- isValidSignal(evt, EVENT_KINDS.STATE_TRANSITION),
218
- [EVENT_KINDS.HOTSPOT]: (evt) => isValidSignal(evt, EVENT_KINDS.HOTSPOT),
219
- [EVENT_KINDS.REWORK]: (evt) => isValidSignal(evt, EVENT_KINDS.REWORK),
220
- [EVENT_KINDS.CHURN]: (evt) => isValidSignal(evt, EVENT_KINDS.CHURN),
221
- [EVENT_KINDS.IDLE]: (evt) => isValidSignal(evt, EVENT_KINDS.IDLE),
222
- [EVENT_KINDS.RETRY]: (evt) => isValidSignal(evt, EVENT_KINDS.RETRY),
223
- [EVENT_KINDS.ACCEPTANCE_EVAL]: (evt) =>
224
- isValidSignal(evt, EVENT_KINDS.ACCEPTANCE_EVAL),
225
- });