mandrel 1.65.0 → 1.67.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 (28) hide show
  1. package/.agents/docs/configuration.md +7 -0
  2. package/.agents/docs/workflows.md +3 -2
  3. package/.agents/personas/engineer.md +27 -0
  4. package/.agents/schemas/agentrc.schema.json +38 -0
  5. package/.agents/schemas/audit-rules.json +12 -0
  6. package/.agents/scripts/epic-audit-prepare.js +53 -3
  7. package/.agents/scripts/epic-plan-healthcheck.js +191 -2
  8. package/.agents/scripts/lib/audit-suite/index.js +5 -0
  9. package/.agents/scripts/lib/audit-suite/selector.js +73 -0
  10. package/.agents/scripts/lib/config-settings-schema-quality.js +13 -0
  11. package/.agents/scripts/lib/config-settings-schema.js +11 -0
  12. package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +82 -0
  13. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +43 -18
  14. package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +147 -0
  15. package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -0
  16. package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +11 -0
  17. package/.agents/scripts/lib/orchestration/retro-runner.js +19 -2
  18. package/.agents/scripts/lib/orchestration/spec-freshness.js +2 -2
  19. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +17 -0
  20. package/.agents/skills/core/epic-plan-premortem/SKILL.md +138 -0
  21. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +15 -0
  22. package/.agents/skills/skills.index.json +12 -2
  23. package/.agents/workflows/audit-navigability.md +129 -0
  24. package/.agents/workflows/helpers/deliver-epic.md +116 -1
  25. package/.agents/workflows/helpers/plan-epic.md +60 -5
  26. package/.agents/workflows/qa-assist.md +214 -141
  27. package/docs/CHANGELOG.md +14 -0
  28. package/package.json +1 -1
@@ -12,6 +12,16 @@
12
12
  * Tests inject a `spawnImpl` (or shape-compatible `execImpl`) to exercise
13
13
  * the gh-exec surface deterministically; production code defaults to
14
14
  * `child_process.spawn`.
15
+ *
16
+ * Story #4135 (Epic #4131, F11) — the envelope additionally carries a
17
+ * `recurringDefectClasses[]` array derived from the `friction::<class>`
18
+ * labels the retro routed-proposals composer stamps onto the meta issues it
19
+ * proposes. That closes the retro→planner loop: a recurring defect class
20
+ * caught by review/deliver is filed as a `meta::*` + `friction::<class>`
21
+ * issue, and the next `/plan` Phase 0 surfaces the class (with a recurrence
22
+ * count across the open feedback issues) to the decompose-author guidance so
23
+ * the planning floor ratchets up. The derivation is no-op-safe: issues with
24
+ * no `friction::*` label contribute nothing and the array is empty.
15
25
  */
16
26
 
17
27
  import { META_LABELS } from '../label-constants.js';
@@ -19,6 +29,66 @@ import { runChild } from './graduator-core.js';
19
29
 
20
30
  const DEFAULT_LIMIT = 50;
21
31
 
32
+ /** Prefix stamped on routed-proposal issue labels by the retro composer. */
33
+ const FRICTION_LABEL_PREFIX = 'friction::';
34
+
35
+ /**
36
+ * Pure: derive recurring-defect-class signals from a list of normalized
37
+ * issues by counting `friction::<class>` labels across them (Story #4135).
38
+ *
39
+ * Each fetched meta issue may carry one `friction::<class>` label (stamped by
40
+ * the retro routed-proposals composer when it proposed the issue). A class
41
+ * that appears across **multiple** open feedback issues is recurring across
42
+ * Epics — exactly the signal F11 surfaces to the planner. The count is the
43
+ * number of distinct open issues carrying the class; the `issues[]` array
44
+ * lists their numbers so the planner can cross-reference.
45
+ *
46
+ * Determinism: classes are sorted by descending recurrence count, ties
47
+ * broken by class name ASC, so a given input always yields a stable order.
48
+ *
49
+ * No-op-safe: issues without a `friction::*` label contribute nothing; an
50
+ * empty / non-array input yields `[]`.
51
+ *
52
+ * @param {Array<{ number: number, labels?: string[] }>} issues
53
+ * @returns {Array<{ class: string, count: number, issues: number[] }>}
54
+ */
55
+ export function extractRecurringDefectClasses(issues) {
56
+ if (!Array.isArray(issues)) return [];
57
+ /** @type {Map<string, Set<number>>} */
58
+ const byClass = new Map();
59
+ for (const issue of issues) {
60
+ if (!issue || typeof issue !== 'object') continue;
61
+ const number = typeof issue.number === 'number' ? issue.number : null;
62
+ const labels = Array.isArray(issue.labels) ? issue.labels : [];
63
+ for (const label of labels) {
64
+ if (
65
+ typeof label !== 'string' ||
66
+ !label.startsWith(FRICTION_LABEL_PREFIX)
67
+ ) {
68
+ continue;
69
+ }
70
+ const cls = label.slice(FRICTION_LABEL_PREFIX.length).trim();
71
+ if (cls.length === 0) continue;
72
+ let set = byClass.get(cls);
73
+ if (!set) {
74
+ set = new Set();
75
+ byClass.set(cls, set);
76
+ }
77
+ if (number !== null) set.add(number);
78
+ }
79
+ }
80
+ const out = [];
81
+ for (const [cls, set] of byClass) {
82
+ out.push({
83
+ class: cls,
84
+ count: set.size,
85
+ issues: [...set].sort((a, b) => a - b),
86
+ });
87
+ }
88
+ out.sort((a, b) => b.count - a.count || a.class.localeCompare(b.class));
89
+ return out;
90
+ }
91
+
22
92
  /**
23
93
  * Spawn the given gh CLI with the supplied args and resolve to
24
94
  * `{ code, stdout, stderr, spawnError }`. Delegates to the shared
@@ -157,6 +227,7 @@ async function fetchByLabel({ owner, repo, label, ghPath, limit, spawnImpl }) {
157
227
  * @returns {Promise<{
158
228
  * frameworkGaps: object[],
159
229
  * consumerImprovements: object[],
230
+ * recurringDefectClasses: Array<{ class: string, count: number, issues: number[] }>,
160
231
  * fetchedAt: string,
161
232
  * errors: string[],
162
233
  * }>}
@@ -180,6 +251,7 @@ export async function fetchPriorFeedback({
180
251
  const envelope = {
181
252
  frameworkGaps: [],
182
253
  consumerImprovements: [],
254
+ recurringDefectClasses: [],
183
255
  fetchedAt: new Date().toISOString(),
184
256
  errors,
185
257
  };
@@ -223,6 +295,16 @@ export async function fetchPriorFeedback({
223
295
  envelope.consumerImprovements.push(issue);
224
296
  }
225
297
 
298
+ // Story #4135 (Epic #4131, F11) — close the retro→planner loop: derive the
299
+ // recurring defect classes from the `friction::<class>` labels carried by
300
+ // the deduped feedback issues, so the next /plan Phase 0 surfaces them to
301
+ // the decompose-author guidance. No-op-safe when no issue carries a
302
+ // `friction::*` label (empty array, no behavioural change).
303
+ envelope.recurringDefectClasses = extractRecurringDefectClasses([
304
+ ...envelope.frameworkGaps,
305
+ ...envelope.consumerImprovements,
306
+ ]);
307
+
226
308
  return envelope;
227
309
  }
228
310
 
@@ -21,11 +21,13 @@ import { fetchPriorFeedback } from '../../../feedback-loop/prior-feedback-fetche
21
21
  import { Logger } from '../../../Logger.js';
22
22
  import { buildDocsContext } from '../../doc-reader.js';
23
23
  import { applyBudget } from '../../planning-context-budget.js';
24
+ import { collectReferences, hasNewFileCue } from '../../spec-freshness.js';
24
25
  import {
25
26
  ACCEPTANCE_SPEC_SYSTEM_PROMPT,
26
27
  PRD_SYSTEM_PROMPT,
27
28
  TECH_SPEC_SYSTEM_PROMPT,
28
29
  } from './prompts.js';
30
+ import { buildAuthoringGrounding } from './spec-authoring-grounding.js';
29
31
 
30
32
  /**
31
33
  * Resolve the per-project memory directory used by the memory-freshness
@@ -146,24 +148,47 @@ export async function buildAuthoringContext(
146
148
  recentCommitWindow:
147
149
  settings?.planning?.codebaseSnapshot?.recentCommitWindow,
148
150
  });
149
- // Story #3959when the skinny-tier cap drops files, the degradation
150
- // used to be silent (only `truncated: true` on the envelope). Surface
151
- // an operator-visible warning naming the dropped count and the two
152
- // remedies so the spec author knows the snapshot is partial and can
153
- // either opt into the richer `medium` tier or narrow `include`.
154
- if (codebaseSnapshot?.truncated) {
155
- const dropped = Math.max(
156
- 0,
157
- (codebaseSnapshot.fileCount ?? 0) -
158
- (codebaseSnapshot.files?.length ?? 0),
159
- );
160
- Logger.warn(
161
- `[epic-plan-spec] codebase snapshot truncated: ${dropped} of ` +
162
- `${codebaseSnapshot.fileCount} matched file(s) dropped from the ` +
163
- `skinny-tier view. The spec-author context is partial. To restore ` +
164
- `full grounding, set planning.codebaseSnapshot.tier: "medium" ` +
165
- `and/or narrow planning.codebaseSnapshot.include in .agentrc.json.`,
166
- );
151
+ // Story #4139 (F10) ground the spec author in the files it will cite.
152
+ // Two signals are attached to the snapshot envelope so the author (which
153
+ // consumes the JSON, not stderr) cannot miss them:
154
+ // 1. `grounding.truncation` the structured, in-envelope form of the
155
+ // Story #3959 dropped-file warning. The skinny-tier cap used to drop
156
+ // the majority of matched files with only a stderr `Logger.warn` and
157
+ // a bare `truncated: true` flag; the author never learned the
158
+ // snapshot was partial (a real run dropped "377 of 627 files").
159
+ // 2. `grounding.citedButAbsent` path-shaped references in the Epic
160
+ // body (the prose the author grounds *from*) that are absent from
161
+ // the snapshot's file set and not phrased as net-new, so cited-but-
162
+ // absent surfaces are visible *during* authoring rather than only
163
+ // after the post-author freshness gate (Story #2635).
164
+ // The grounding consults only the snapshot's file set and the Epic body —
165
+ // no new filesystem or git probes so the context stays bounded for cost.
166
+ if (codebaseSnapshot) {
167
+ const grounding = buildAuthoringGrounding({
168
+ snapshot: codebaseSnapshot,
169
+ prose: epic.body ?? '',
170
+ collectReferences,
171
+ hasNewFileCue,
172
+ });
173
+ codebaseSnapshot.grounding = grounding;
174
+ if (grounding.truncation) {
175
+ const { dropped, matched, tier } = grounding.truncation;
176
+ Logger.warn(
177
+ `[epic-plan-spec] codebase snapshot truncated: ${dropped} of ` +
178
+ `${matched} matched file(s) dropped from the ${tier}-tier view. ` +
179
+ `The spec-author context is partial. To restore full grounding, ` +
180
+ `set planning.codebaseSnapshot.tier: "medium" and/or narrow ` +
181
+ `planning.codebaseSnapshot.include in .agentrc.json.`,
182
+ );
183
+ }
184
+ if (grounding.citedButAbsent.length > 0) {
185
+ Logger.warn(
186
+ `[epic-plan-spec] ${grounding.citedButAbsent.length} path(s) cited ` +
187
+ `in the Epic body are absent from the codebase snapshot: ` +
188
+ `${grounding.citedButAbsent.join(', ')}. The spec author will ` +
189
+ `flag these as drift unless they are net-new.`,
190
+ );
191
+ }
167
192
  }
168
193
  } catch (err) {
169
194
  Logger.warn(`[epic-plan-spec] codebase snapshot skipped: ${err.message}`);
@@ -0,0 +1,147 @@
1
+ /**
2
+ * phases/spec-authoring-grounding.js — F10 spec-authoring code-grounding.
3
+ *
4
+ * Story #4139 (Epic #4131). `/plan` Phase 7 authors the PRD + Tech Spec
5
+ * from the Epic body, the scraped project docs, and the
6
+ * `codebaseSnapshot` structural view of the consumer repo. Two failure
7
+ * modes made that grounding silently partial:
8
+ *
9
+ * 1. The skinny-tier snapshot caps its file list at `MAX_FILES_SKINNY`
10
+ * and sets `truncated: true` — but the only operator-visible signal
11
+ * was a stderr `Logger.warn` (Story #3959). The spec author consumes
12
+ * the JSON envelope, not stderr, so it never learned the snapshot
13
+ * was partial. A run that dropped "377 of 627 files" looked complete
14
+ * to the author.
15
+ *
16
+ * 2. When the Epic body cites a code path that is **absent** from the
17
+ * snapshot's file set, nothing surfaced the gap *during* authoring.
18
+ * The post-author spec-freshness gate (Story #2635) catches stale
19
+ * citations only after the Tech Spec is written — one phase too late
20
+ * to ground the author's choices.
21
+ *
22
+ * `buildAuthoringGrounding` derives a small, bounded `grounding` block that
23
+ * is attached to the `codebaseSnapshot` envelope so the author (and the
24
+ * operator inspecting the run) see both signals before the spec is written:
25
+ *
26
+ * - `truncation` — non-null when the snapshot dropped files. Carries the
27
+ * dropped count, the matched/shown totals, and the two remedies. This
28
+ * is the structured, in-envelope form of the Story #3959 stderr warning.
29
+ * - `citedButAbsent` — path-shaped references pulled from the authoring
30
+ * prose (the Epic body) that are **not** present in the snapshot's file
31
+ * set and are not phrased as net-new. Bounded to `MAX_CITED_ABSENT`
32
+ * entries so a pathological Epic body cannot blow the envelope budget.
33
+ *
34
+ * The grounding is targeted (the prose the author is grounding *from* and
35
+ * the files the snapshot already carries), not a whole-repo dump — the
36
+ * snapshot file set is the only source consulted, so this adds no new
37
+ * filesystem or git probes.
38
+ */
39
+
40
+ /**
41
+ * Hard cap on the `citedButAbsent` list so an Epic body that mentions a
42
+ * very large number of paths cannot inflate the authoring envelope. The
43
+ * cap is generous relative to a realistic Epic citation count; when it is
44
+ * hit, `citedButAbsentTruncated: true` flags the elision so the signal is
45
+ * not silently dropped (the very failure mode this Story fixes).
46
+ */
47
+ export const MAX_CITED_ABSENT = 40;
48
+
49
+ /**
50
+ * Build the operator-visible truncation signal from a snapshot envelope.
51
+ * Returns `null` when the snapshot is absent or was not truncated.
52
+ *
53
+ * @param {object|null} snapshot - The `codebaseSnapshot` envelope.
54
+ * @returns {{ dropped: number, matched: number, shown: number, tier: string, remedies: string[] } | null}
55
+ */
56
+ export function buildTruncationSignal(snapshot) {
57
+ if (!snapshot || snapshot.truncated !== true) return null;
58
+ const matched = Number.isInteger(snapshot.fileCount) ? snapshot.fileCount : 0;
59
+ const shown = Array.isArray(snapshot.files) ? snapshot.files.length : 0;
60
+ const dropped = Math.max(0, matched - shown);
61
+ return {
62
+ dropped,
63
+ matched,
64
+ shown,
65
+ tier: typeof snapshot.tier === 'string' ? snapshot.tier : 'skinny',
66
+ remedies: [
67
+ 'Set planning.codebaseSnapshot.tier: "medium" in .agentrc.json to restore full grounding.',
68
+ 'Narrow planning.codebaseSnapshot.include in .agentrc.json so the cited surfaces survive the cap.',
69
+ ],
70
+ };
71
+ }
72
+
73
+ /**
74
+ * Surface path-shaped references from the authoring prose that are absent
75
+ * from the snapshot's file set. Reuses the spec-freshness path extractor so
76
+ * the citation shapes recognised here match the post-author freshness gate
77
+ * exactly — a path the author cites in prose is detected the same way before
78
+ * and after authoring.
79
+ *
80
+ * A reference is reported only when it is **not** present in `snapshotFiles`
81
+ * **and** the surrounding prose does not phrase it as net-new (the same
82
+ * cue heuristic the freshness gate uses to demote intentional new-file
83
+ * mentions). Results are deduped by path, sorted, and bounded to
84
+ * `MAX_CITED_ABSENT`.
85
+ *
86
+ * @param {string} prose - The authoring prose (typically the Epic body).
87
+ * @param {string[]} snapshotFiles - The snapshot's `files` array.
88
+ * @param {object} deps
89
+ * @param {Function} deps.collectReferences - (body) => Array<{ path, index, matchLength }>.
90
+ * @param {Function} deps.hasNewFileCue - (body, index, matchLength) => boolean.
91
+ * @returns {{ paths: string[], truncated: boolean }}
92
+ */
93
+ export function findCitedButAbsent(prose, snapshotFiles, deps) {
94
+ const { collectReferences, hasNewFileCue } = deps;
95
+ if (typeof prose !== 'string' || prose.length === 0) {
96
+ return { paths: [], truncated: false };
97
+ }
98
+ const present = new Set(
99
+ (Array.isArray(snapshotFiles) ? snapshotFiles : []).map((f) =>
100
+ String(f).replace(/\\/g, '/'),
101
+ ),
102
+ );
103
+ const absent = new Set();
104
+ for (const { path, index, matchLength } of collectReferences(prose)) {
105
+ const normalised = path.replace(/\\/g, '/');
106
+ if (present.has(normalised)) continue;
107
+ if (hasNewFileCue(prose, index, matchLength)) continue;
108
+ absent.add(normalised);
109
+ }
110
+ const sorted = [...absent].sort();
111
+ return {
112
+ paths: sorted.slice(0, MAX_CITED_ABSENT),
113
+ truncated: sorted.length > MAX_CITED_ABSENT,
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Build the full `grounding` block attached to the `codebaseSnapshot`
119
+ * envelope. Pure with respect to its inputs (no filesystem or git probes):
120
+ * the snapshot file set is the sole grounding source, keeping the context
121
+ * bounded for cost.
122
+ *
123
+ * @param {object} opts
124
+ * @param {object|null} opts.snapshot - The `codebaseSnapshot` envelope.
125
+ * @param {string} opts.prose - The authoring prose (Epic body) to scan.
126
+ * @param {Function} opts.collectReferences - spec-freshness path extractor.
127
+ * @param {Function} opts.hasNewFileCue - spec-freshness net-new cue check.
128
+ * @returns {{ truncation: object|null, citedButAbsent: string[], citedButAbsentTruncated: boolean }}
129
+ */
130
+ export function buildAuthoringGrounding({
131
+ snapshot,
132
+ prose,
133
+ collectReferences,
134
+ hasNewFileCue,
135
+ }) {
136
+ const truncation = buildTruncationSignal(snapshot);
137
+ const { paths, truncated } = findCitedButAbsent(
138
+ prose,
139
+ snapshot?.files ?? [],
140
+ { collectReferences, hasNewFileCue },
141
+ );
142
+ return {
143
+ truncation,
144
+ citedButAbsent: paths,
145
+ citedButAbsentTruncated: truncated,
146
+ };
147
+ }
@@ -26,6 +26,69 @@ export function normalizeInterventionCount(value) {
26
26
  return Math.trunc(value);
27
27
  }
28
28
 
29
+ /**
30
+ * Pure: derive the recurring-defect-class signal from the routed-proposal
31
+ * sections (Story #4135 / Epic #4131, F11).
32
+ *
33
+ * The routed-proposals composer (`retro-proposals.js`) already split the
34
+ * Epic's source-tagged friction into `framework` / `consumer` actionable
35
+ * items — a category lands there only when it recurred ≥2 times across the
36
+ * Epic OR was force-flagged by an unresolved `agent::blocked` event. Those
37
+ * are exactly the **recurring classes** of review/deliver-caught issues F11
38
+ * tracks, so we lift them verbatim rather than re-deriving a parallel
39
+ * threshold. Each entry carries the `friction::<category>` label the
40
+ * composer stamps onto its `gh issue create` command — that label is the
41
+ * join key the `/plan` Phase 0 prior-feedback fetcher reads back to surface
42
+ * recurring classes to the planner.
43
+ *
44
+ * Determinism: the two actionable arrays are already sorted by `category`
45
+ * ASC by the composer; we concatenate framework-then-consumer and re-sort by
46
+ * `category` so the merged signal is stable regardless of which source
47
+ * contributed a class.
48
+ *
49
+ * No-op-safe: a null / non-object / shapeless `routedProposals` (or one with
50
+ * empty actionable arrays) yields `[]` — the common clean-sprint case.
51
+ *
52
+ * @param {{ framework?: object[], consumer?: object[] } | null | undefined} routedProposals
53
+ * @returns {Array<{ category: string, occurrences: number, source: 'framework'|'consumer', label: string }>}
54
+ */
55
+ export function deriveDefectClasses(routedProposals) {
56
+ if (
57
+ !routedProposals ||
58
+ typeof routedProposals !== 'object' ||
59
+ Array.isArray(routedProposals)
60
+ ) {
61
+ return [];
62
+ }
63
+ const framework = Array.isArray(routedProposals.framework)
64
+ ? routedProposals.framework
65
+ : [];
66
+ const consumer = Array.isArray(routedProposals.consumer)
67
+ ? routedProposals.consumer
68
+ : [];
69
+
70
+ const out = [];
71
+ for (const item of [...framework, ...consumer]) {
72
+ if (!item || typeof item !== 'object') continue;
73
+ const category =
74
+ typeof item.category === 'string' ? item.category.trim() : '';
75
+ if (category.length === 0) continue;
76
+ const occurrences =
77
+ typeof item.occurrences === 'number' && Number.isFinite(item.occurrences)
78
+ ? item.occurrences
79
+ : 0;
80
+ const source = item.source === 'framework' ? 'framework' : 'consumer';
81
+ out.push({
82
+ category,
83
+ occurrences,
84
+ source,
85
+ label: `friction::${category}`,
86
+ });
87
+ }
88
+ out.sort((a, b) => a.category.localeCompare(b.category));
89
+ return out;
90
+ }
91
+
29
92
  /**
30
93
  * Pure: compose the retro markdown body. Exported for tests so they can
31
94
  * verify the body shape without round-tripping through a stub provider.
@@ -18,6 +18,7 @@ import { upsertStructuredComment } from '../../ticketing.js';
18
18
  import { appendChecksSection, collectRetroFindings } from './checks.js';
19
19
  import {
20
20
  composeRetroBody as defaultComposeRetroBody,
21
+ deriveDefectClasses,
21
22
  normalizeInterventionCount,
22
23
  } from './compose-body.js';
23
24
  import { gatherRetroSignals as defaultGatherRetroSignals } from './gather-signals.js';
@@ -74,6 +75,15 @@ export async function composeAndPostRetro({
74
75
  perfThresholds,
75
76
  });
76
77
 
78
+ // Story #4135 (Epic #4131, F11) — derive the recurring-defect-class signal
79
+ // from the same routed proposals the body composer consumed. The signal is
80
+ // surfaced on the runRetro envelope (`defectClasses`) so callers/tests can
81
+ // observe the recurring classes the routed-proposal `gh issue create`
82
+ // commands stamp with `friction::<class>` labels — the join key the
83
+ // `/plan` Phase 0 fetcher reads back. No-op-safe: a clean sprint (no
84
+ // routed proposals) yields `[]`.
85
+ const defectClasses = deriveDefectClasses(signals.routedProposals);
86
+
77
87
  const findings = await collectRetroFindings({
78
88
  runChecksFn,
79
89
  assembleStateFn,
@@ -128,6 +138,7 @@ export async function composeAndPostRetro({
128
138
  scorecard,
129
139
  body: bodyWithChecks,
130
140
  findings,
141
+ defectClasses,
131
142
  commentId: result?.commentId,
132
143
  };
133
144
  }
@@ -18,10 +18,23 @@
18
18
  * existing import sites stay unchanged.
19
19
  *
20
20
  * Public API:
21
- * - `runRetro({ epicId, provider, logger })` → `{ posted, compact, scorecard, body }`.
21
+ * - `runRetro({ epicId, provider, logger })` → `{ posted, compact, scorecard, body, defectClasses }`.
22
22
  * - `composeRetroBody(input)` (pure, exported for tests).
23
23
  * - `gatherRetroSignals({ epicId, provider })` (exported for tests).
24
24
  * - `appendChecksSection(body, findings)` (pure, exported for tests).
25
+ * - `deriveDefectClasses(routedProposals)` (pure, exported for tests).
26
+ *
27
+ * Story #4135 (Epic #4131, F11) — the runner now derives a
28
+ * **recurring-defect-class signal** from the routed-proposal actionable
29
+ * items (categories that recurred ≥2 times across review/deliver-caught
30
+ * friction, or a force-flagged `agent::blocked` category). The derived
31
+ * classes ride on the `runRetro` envelope as `defectClasses[]` and are
32
+ * stamped onto the proposed `gh issue create` commands as `friction::<class>`
33
+ * labels (the routed-proposals composer already emits that label), which is
34
+ * the durable substrate the `/plan` Phase 0 prior-feedback fetcher reads
35
+ * back to surface recurring classes to the planner. The derivation is
36
+ * **no-op-safe**: absent or empty routed proposals yield an empty array and
37
+ * no behavioural change to the existing retro/post path.
25
38
  *
26
39
  * Behaviour:
27
40
  * - Reads child Stories' `story-perf-summary` comments to aggregate
@@ -50,7 +63,10 @@ import { upsertStructuredComment } from './ticketing.js';
50
63
 
51
64
  // Re-export phase-level helpers so existing import sites stay unchanged.
52
65
  export { appendChecksSection } from './retro/phases/checks.js';
53
- export { composeRetroBody } from './retro/phases/compose-body.js';
66
+ export {
67
+ composeRetroBody,
68
+ deriveDefectClasses,
69
+ } from './retro/phases/compose-body.js';
54
70
  export { gatherRetroSignals } from './retro/phases/gather-signals.js';
55
71
 
56
72
  /**
@@ -92,6 +108,7 @@ export { gatherRetroSignals } from './retro/phases/gather-signals.js';
92
108
  * scorecard: object,
93
109
  * body: string,
94
110
  * findings: object[],
111
+ * defectClasses: Array<{ category: string, occurrences: number, source: 'framework'|'consumer', label: string }>,
95
112
  * commentId?: number,
96
113
  * }>}
97
114
  */
@@ -141,7 +141,7 @@ function lineNumberFor(body, index) {
141
141
  * so future cue variants don't silently slip the gate. False positives
142
142
  * downgrade staleness to ambiguity, which is the safe direction.
143
143
  */
144
- function hasNewFileCue(body, index, matchLength) {
144
+ export function hasNewFileCue(body, index, matchLength) {
145
145
  const start = Math.max(0, index - AMBIGUITY_WINDOW);
146
146
  const end = Math.min(body.length, index + matchLength + AMBIGUITY_WINDOW);
147
147
  const window = body.slice(start, end).toLowerCase();
@@ -160,7 +160,7 @@ function hasNewFileCue(body, index, matchLength) {
160
160
  * @param {string} body
161
161
  * @returns {Array<{ path: string, index: number, matchLength: number }>}
162
162
  */
163
- function collectReferences(body) {
163
+ export function collectReferences(body) {
164
164
  const refs = [];
165
165
  for (const re of [BACKTICK_PATH_RE, COMMENT_HEADER_PATH_RE, BARE_PATH_RE]) {
166
166
  re.lastIndex = 0;
@@ -27,6 +27,8 @@ allowed_tools:
27
27
  - Acceptance MUST NOT prescribe a commit subject starting with a non-Conventional-Commits prefix; the literal `baseline-refresh:` leading token is forbidden (use a body trailer instead — see Epic #2501).
28
28
  - A legitimately broad Story (files > `hardFiles`) MUST declare `wide` with a one-line reason (encoded in the serialized body string via the `<!-- meta -->` comment) to lift the `hardFiles` rejection; lead the sizing decision with cohesion, not count. UI-touching Stories MUST end `changes` with a `data-testid invariance:` or `data-testid changes: <old> -> <new>` declaration.
29
29
  - A Story's `depends_on` references only **sibling Stories within the same Epic**. Apply the cross-cutting-config-file rule (sequential `depends_on` or a late-wave wiring Story) whenever multiple Stories edit a shared root config file.
30
+ - **Authoring-contract altitude (Epic #4131 F8).** `acceptance[]` and `verify[]` are the Story's **binding contract** — the executor MUST satisfy them exactly, and they are the only definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: a best-effort prediction of which files the work touches that the executor MAY revise when the codebase tells a different story. Author `acceptance[]`/`verify[]` so they capture the outcome independently of any particular file layout — never bake an incidental implementation detail into them that the advisory sketch is free to change. This does **not** weaken the file-assumption gate: `changes[]` paths are still validated structurally against the base branch (a `creates` against an existing path still fails), the New-File Contract still holds, and the executor's revised approach stays bounded by the inviolable `acceptance[]`/`verify[]` contract and `rules/security-baseline.md`.
31
+ - **Navigate-don't-deep-link acceptance standard (Epic #4131 F5).** For any Story whose acceptance describes a **signed-in** (authenticated) scenario reaching a feature surface, author the acceptance so the persona starts from their authenticated home and **reaches the feature through navigation** (clicking a nav door, menu item, or link the UI exposes) — never by asserting against a hardcoded deep-link URL. A scenario that drops the user straight onto `/some/feature/path` proves the page renders but not that the feature is reachable, masking orphaned surfaces with no nav owner. Phrase signed-in acceptance as "from the signed-in home, the persona navigates to … and sees …", not "loading `/feature/path` shows the feature."
30
32
 
31
33
  ## Role
32
34
 
@@ -253,6 +255,21 @@ Declaring `wide` with a non-empty reason **lifts the `hardFiles` rejection** —
253
255
 
254
256
  - Stories that touch user-visible copy, brand assets, or visual style MUST cite the relevant section of `docs/style-guide.md` in `acceptance` (e.g. `"acceptance": ["Hero copy matches docs/style-guide.md §3 (voice & tone)"]`). If `docs/style-guide.md` does not exist or has no relevant section, state that explicitly: `"acceptance": ["docs/style-guide.md absent — copy reviewed against the inline brand brief in PRD §2"]`. Silence on style sourcing is a smell.
255
257
 
258
+ #### BINDING ACCEPTANCE vs ADVISORY CHANGES (authoring altitude):
259
+
260
+ `acceptance[]` and `verify[]` are the **binding contract** the executor MUST satisfy — they are the sole definition of "done." `changes[]` and `references[]` are an **advisory implementation sketch**: your best prediction of the file footprint, which the executor is permitted to revise when the real codebase diverges from the sketch. Author at that altitude:
261
+
262
+ - Write `acceptance[]`/`verify[]` to capture the **outcome**, independent of any one file layout. Do NOT pin an incidental implementation detail (an internal helper name, a private file path) into an acceptance item that the advisory `changes[]` is free to reshape — assert the observable behaviour instead.
263
+ - Keep `changes[]`/`references[]` as the honest predicted footprint. They still pass through the structural file-assumption gate (the `creates`/`refactors-existing`/`deletes` probes against the base branch) and the New-File Contract unchanged — advisory does NOT mean unvalidated. The executor's latitude to revise the approach never licenses skipping `acceptance[]`/`verify[]` or any `rules/security-baseline.md` MUST.
264
+
265
+ #### NAVIGATE-DON'T-DEEP-LINK (signed-in acceptance scenarios):
266
+
267
+ When a Story's acceptance describes a **signed-in / authenticated** persona reaching a feature surface, author it so the persona starts from their authenticated home and **reaches the feature through navigation** — clicking a nav door, menu entry, or link the UI actually exposes — **never** via a hardcoded deep-link URL.
268
+
269
+ - A deep-link scenario (`load /reports/export and assert the export button`) proves the page renders but NOT that it is reachable; it masks an orphaned surface that no navigation door points to.
270
+ - Phrase it as: `"From the signed-in home, the persona navigates to Reports → Export and sees the export button"` — not `"GET /reports/export returns the export view"`.
271
+ - This applies to signed-in journeys only; an unauthenticated landing page or a deliberately deep-linkable share URL is exempt — say so in the acceptance item when you take that exemption.
272
+
256
273
  ### WAVE-0 BDD SCAFFOLD STORY (features-first; emit when the Acceptance Spec has `new`-disposition rows):
257
274
 
258
275
  The Acceptance Spec's AC table (columns `AC ID | Outcome | Feature File | Scenario | Disposition`) tags each row's `Disposition` with one of `new | updated | unchanged`. A `new` row names a `.feature` file + scenario that does NOT yet exist on `main`. The framework is features-first: implementation Stories reference those `.feature` paths in their `verify[]` lines, so the files MUST already exist when those Stories run — otherwise verification fails mid-delivery on a missing file (observed gap: Epic #18 in `dsj1984/athportal` had 9 `new` rows and no Story tasked with creating the feature files Stories #1457 / #1466 verified against).