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
@@ -117,6 +117,9 @@ top-level keys are validation errors.
117
117
  | `failOnLargeFanOut` | No | `boolean` | — | — |
118
118
  | `largeFanOutThreshold` | No | `integer` | — | — |
119
119
  | `crossCuttingRegistries` | No | `string[]` or `{ append?, prepend? }` | — | — |
120
+ | `navigation` | No | `object` | — | Navigability-reachability config consumed by the epic-plan-healthcheck --paranoid reachability check. Opt-in: absent or empty routeGlobs degrades to a silent no-op. |
121
+ | `navigation.routeGlobs` | No | `array<string>` | — | Glob patterns (pages/**, app/**/route.ts) marking paths that add a user-facing route. |
122
+ | `navigation.navRegistry` | No | `array<string>` | — | Tokens identifying the nav-registry SSOT a route-adding Story is expected to reference. |
120
123
 
121
124
  ### `delivery` (optional)
122
125
 
@@ -259,6 +262,10 @@ top-level keys are validation errors.
259
262
  | `quality.baselineEpsilon.lighthouse` | No | `number` | — | — |
260
263
  | `quality.baselineEpsilon.bundle-size` | No | `number` | — | — |
261
264
  | `quality.baselineEpsilon.duplication` | No | `number` | — | — |
265
+ | `quality.navigability` | No | `object` | — | Navigability lens + post-wave integration gate config (Epic #4131, F2/F3/F1/F4). Read by audit-suite/selector.js (route globs) and the deliver-epic.md Phase 6.5 gate (journey suite). Opt-in: absent or empty routeGlobs degrades to a silent no-op. |
266
+ | `quality.navigability.routeGlobs` | No | `array<string>` | — | Glob patterns (pages/**, app/**/route.ts) marking paths that add a user-facing route — the route-tree SSOT the navigability lens enumerates and the route-added routing predicate matches against. |
267
+ | `quality.navigability.navRegistry` | No | `array<string>` | — | Tokens identifying the nav-registry SSOT the navigability lens checks every route resolves a nav door against. |
268
+ | `quality.navigability.journeySuite` | No | `string` | — | Path or command for the per-persona journey suite the deliver-epic.md Phase 6.5 post-wave integration gate runs. |
262
269
  | `lifecycle` | No | `object` | — | Knobs consumed by the lifecycle event bus (Epic #2172). `timeouts` is a per-event budget map (eventName → seconds) used by `TimeoutWatchdog`; missing entries fall back to in-listener defaults. `heartbeatWarnSeconds` is the no-progress threshold consumed by `HeartbeatMonitor`. Story #2227 lays down the keys; consumers land in later stories. |
263
270
  | `lifecycle.timeouts` | No | `object<map>` | — | — |
264
271
  | `lifecycle.heartbeatWarnSeconds` | No | `integer` | — | — |
@@ -25,7 +25,7 @@ by `node .agents/scripts/generate-workflows-doc.js`; `npm run docs:check`
25
25
  fails when it drifts from the on-disk workflow set. To change a command’s
26
26
  description, edit the workflow file’s front-matter and regenerate.
27
27
 
28
- ## Commands (26)
28
+ ## Commands (27)
29
29
 
30
30
  | Command | Description |
31
31
  | --- | --- |
@@ -36,6 +36,7 @@ description, edit the workflow file’s front-matter and regenerate.
36
36
  | `/audit-devops` | Audit CI/CD workflows, container images, infrastructure-as-code, and deployment pipelines; surface failure modes and hardening gaps. |
37
37
  | `/audit-documentation` | Audit the repository's main documentation for staleness, semantic drift, and completeness; emit a structured High/Medium/Low findings report. |
38
38
  | `/audit-lighthouse` | Run a Lighthouse audit (Performance / Accessibility / Best Practices / SEO) and produce a structured findings report |
39
+ | `/audit-navigability` | Audit the whole route tree against the consumer's nav-registry SSOT — every route has a persona nav door and no nav href is dead. A deliberately-global lens (Epic #4131, F2/F3) exempt from the cross-epic-leak guard and routed onto route-adding change sets. |
39
40
  | `/audit-performance` | Audit hot paths, algorithmic complexity, and I/O bottlenecks in the tooling surface (`epic-close`, dispatcher, gates); propose remediations. |
40
41
  | `/audit-privacy` | Audit logs, telemetry, and persistence paths for PII leakage and retention violations; surface secrets exposure and consent gaps. |
41
42
  | `/audit-quality` | Audit test coverage gaps, flaky tests, missing assertions, and test-pyramid balance; recommend a remediation batch. |
@@ -52,6 +53,6 @@ description, edit the workflow file’s front-matter and regenerate.
52
53
  | `/git-pr-all` | Stage all outstanding changes, commit, push to a feature branch, and open a pull request with native auto-merge enabled. |
53
54
  | `/git-push` | Commit all outstanding changes then push to the remote repository. |
54
55
  | `/plan` | Unified planning entry point. Routes a seed idea (via scope triage) or an existing Epic ID to the right planning path — the full Epic pipeline (PRD, Tech Spec, Acceptance Spec, decomposition) or the standalone-Story authoring path — and absorbs every planning flag. |
55
- | `/qa-assist` | Human-led QA assist loop — ingest one operator observation, enrich it with repro + root-cause (file:line) + a coverage verdict, ask clarifying questions when it is ambiguous, and append a redacted ledger item to a persistent, resumable rolling session under temp/qa/ |
56
+ | `/qa-assist` | Human-led QA assist loop — set up, then ride a rolling multi-observation intake session. The operator reports observations in any order; the agent enriches each (repro + root-cause file:line + coverage verdict for bugs; analysis + options + recommendation for enhancements), asks clarifying questions only when ambiguous, and appends a redacted ledger item — recording, never planning — to a persistent, resumable session under temp/qa/. Only when the operator says they are done does it review the full ledger and hand off to /plan. |
56
57
  | `/qa-explore` | Agent-led exploratory-QA loop — the agent Plans a surface with an explicit static-vs-drive method choice, drives it (browser MCP or static), and captures ledger items read-only, then Triages — a bounded per-surface session, HITL-gated at every phase transition, routed through the shared dedup/coverage/classification/missing-test/redaction/session core under temp/qa/ |
57
58
  | `/qa-run-harness` | Drive Gherkin scenarios through a real browser as an agent-driven QA sweep |
@@ -51,6 +51,33 @@ plan, stop and ask. Do not invent business logic.
51
51
  - **Early Returns:** Use guard clauses to handle errors early and reduce
52
52
  nesting.
53
53
 
54
+ ### C. Implementation Latitude (Logged Deviation)
55
+
56
+ A Story's `changes[]` and `references[]` are an **advisory implementation
57
+ sketch** — the author's best prediction of the file footprint, not a binding
58
+ instruction. Its `acceptance[]` and `verify[]` arrays are the **binding
59
+ contract** and the only definition of "done."
60
+
61
+ - **You MAY deviate from the suggested approach** when the real codebase
62
+ diverges from the sketch — touch a different file, choose a different
63
+ internal seam, fold or split the predicted edits — **provided you record
64
+ the rationale**. Note the deviation and the reason in your commit body (or
65
+ the Story's progress comment), e.g. `the sketched helper already exists in
66
+ lib/x.js, so I extended it rather than creating lib/y.js`. This converts
67
+ silent drift into a logged signal a reviewer can audit.
68
+ - **The latitude is bounded and never overrides the inviolable layer.** It
69
+ applies to the **implementation approach only**. It does **not** license
70
+ deviating from `acceptance[]` / `verify[]` (you MUST still satisfy every
71
+ acceptance item and run every verify command to green), and it does **not**
72
+ license relaxing any `rules/security-baseline.md` MUST or any other
73
+ inviolable rule. When in doubt, the binding contract and the security
74
+ baseline win — per the precedence ordering in
75
+ [`.agents/instructions.md` § 1.K](../instructions.md).
76
+ - **Unlogged deviation is the anti-pattern.** Reshaping the footprint without
77
+ recording why is the silent drift this latitude exists to surface. If you
78
+ cannot state the reason in a sentence, re-examine whether the deviation is
79
+ warranted.
80
+
54
81
  ## 4. Testing & Verification
55
82
 
56
83
  1. **Test-Driven:** Write tests for utilities, logic helpers, and API routes
@@ -389,6 +389,23 @@
389
389
  },
390
390
  "crossCuttingRegistries": {
391
391
  "$ref": "#/$defs/listOrExtenderOfStrings"
392
+ },
393
+ "navigation": {
394
+ "type": "object",
395
+ "description": "Navigability-reachability config consumed by the epic-plan-healthcheck --paranoid reachability check. Opt-in: absent or empty routeGlobs degrades to a silent no-op.",
396
+ "properties": {
397
+ "routeGlobs": {
398
+ "type": "array",
399
+ "description": "Glob patterns (pages/**, app/**/route.ts) marking paths that add a user-facing route.",
400
+ "items": { "type": "string" }
401
+ },
402
+ "navRegistry": {
403
+ "type": "array",
404
+ "description": "Tokens identifying the nav-registry SSOT a route-adding Story is expected to reference.",
405
+ "items": { "type": "string" }
406
+ }
407
+ },
408
+ "additionalProperties": false
392
409
  }
393
410
  },
394
411
  "additionalProperties": false
@@ -1294,6 +1311,27 @@
1294
1311
  },
1295
1312
  "baselineEpsilon": {
1296
1313
  "$ref": "#/$defs/baselineEpsilon"
1314
+ },
1315
+ "navigability": {
1316
+ "type": "object",
1317
+ "description": "Navigability lens + post-wave integration gate config (Epic #4131, F2/F3/F1/F4). Read by audit-suite/selector.js (route globs) and the deliver-epic.md Phase 6.5 gate (journey suite). Opt-in: absent or empty routeGlobs degrades to a silent no-op.",
1318
+ "properties": {
1319
+ "routeGlobs": {
1320
+ "type": "array",
1321
+ "description": "Glob patterns (pages/**, app/**/route.ts) marking paths that add a user-facing route — the route-tree SSOT the navigability lens enumerates and the route-added routing predicate matches against.",
1322
+ "items": { "type": "string" }
1323
+ },
1324
+ "navRegistry": {
1325
+ "type": "array",
1326
+ "description": "Tokens identifying the nav-registry SSOT the navigability lens checks every route resolves a nav door against.",
1327
+ "items": { "type": "string" }
1328
+ },
1329
+ "journeySuite": {
1330
+ "type": "string",
1331
+ "description": "Path or command for the per-persona journey suite the deliver-epic.md Phase 6.5 post-wave integration gate runs."
1332
+ }
1333
+ },
1334
+ "additionalProperties": false
1297
1335
  }
1298
1336
  },
1299
1337
  "additionalProperties": false
@@ -149,6 +149,18 @@
149
149
  ]
150
150
  },
151
151
  "substitutionKeys": []
152
+ },
153
+ "audit-navigability": {
154
+ "triggers": {
155
+ "gates": ["gate2", "gate3"],
156
+ "keywords": [
157
+ "navigability",
158
+ "reachability",
159
+ "nav registry",
160
+ "route tree"
161
+ ]
162
+ },
163
+ "substitutionKeys": []
152
164
  }
153
165
  }
154
166
  }
@@ -55,6 +55,7 @@
55
55
  * "selectedAudits": ["audit-security", "audit-privacy"],
56
56
  * "changeSetAudits": ["audit-privacy"],
57
57
  * "riskRoutedAudits": ["audit-security"],
58
+ * "globalLenses": [],
58
59
  * "changedFiles": ["src/api/admin/users.ts", "..."],
59
60
  * "changedFilesCount": 47,
60
61
  * "substitutionsPayload": "src/api/admin/users.ts\n..."
@@ -67,6 +68,17 @@
67
68
  * `depth` (`light` / `standard` / `deep`) is the orthogonal "how deep each
68
69
  * selected lens runs" signal (Story #3939).
69
70
  *
71
+ * Epic #4131 (F2/F3) — `globalLenses` is the subset of `selectedAudits` on the
72
+ * global-lens allowlist (`GLOBAL_LENS_ALLOWLIST`, e.g. `audit-navigability`):
73
+ * lenses the helper runs against the WHOLE route tree, exempt from the
74
+ * cross-epic-leak guard's change-set narrowing (`#3362`). The exemption is
75
+ * scoped to these lenses only — every other selected lens stays scoped to
76
+ * `changedFiles`, and the guard is not weakened for them. The navigability lens
77
+ * is also auto-selected here when a changed file matches a consumer-configured
78
+ * route glob (`delivery.quality.navigability.routeGlobs`), routed through the
79
+ * SAME risk-routed-lens union; with no route globs configured it routes
80
+ * nothing (silent no-op).
81
+ *
70
82
  * Usage:
71
83
  * node .agents/scripts/epic-audit-prepare.js --epic <epicId> [--base-branch main]
72
84
  *
@@ -76,7 +88,12 @@
76
88
  * 1 — provider / git failure
77
89
  */
78
90
 
79
- import { selectAudits } from './lib/audit-suite/index.js';
91
+ import {
92
+ GLOBAL_LENS_ALLOWLIST,
93
+ isGlobalLens,
94
+ routesNavigabilityLens,
95
+ selectAudits,
96
+ } from './lib/audit-suite/index.js';
80
97
  import { defineFlags } from './lib/cli-args.js';
81
98
  import { runAsCli } from './lib/cli-utils.js';
82
99
  import { resolveConfig } from './lib/config-resolver.js';
@@ -105,7 +122,13 @@ Output (JSON envelope on stdout):
105
122
  fire, the severity taxonomy, or the Phase 4 halting rule.
106
123
  selectedAudits De-duplicated union of changeSetAudits + riskRoutedAudits.
107
124
  changeSetAudits Lenses the change-set selector chose.
108
- riskRoutedAudits Lenses routed from the model-judged high-risk axes.
125
+ riskRoutedAudits Lenses routed from the model-judged high-risk axes plus the
126
+ navigability lens when a changed file matches a configured
127
+ route glob (Epic #4131, F3).
128
+ globalLenses Subset of selectedAudits on the global-lens allowlist
129
+ (e.g. audit-navigability) — run against the WHOLE route
130
+ tree, exempt from the cross-epic-leak guard (Epic #4131,
131
+ F2). Empty unless a global lens was selected.
109
132
  changedFiles, changedFilesCount, substitutionsPayload
110
133
  `;
111
134
 
@@ -359,14 +382,40 @@ export async function runEpicAuditPrepare(values, deps = {}) {
359
382
  // (best-effort; a read failure yields no extra lenses). A high-risk
360
383
  // `security` axis therefore fires `audit-security` even when the change
361
384
  // set alone did not select it; a low-risk Epic adds nothing.
362
- const riskRoutedAudits = await resolveRiskRoutedLenses({
385
+ const riskRoutedFromVerdict = await resolveRiskRoutedLenses({
363
386
  epicId,
364
387
  provider,
365
388
  readPlanState: deps.readPlanState,
366
389
  resolveAuditLenses: deps.resolveAuditLenses,
367
390
  });
391
+
392
+ // Epic #4131 (F3) — route the navigability lens onto route-adding change
393
+ // sets through the SAME risk-routed-lens union: when a changed file matches a
394
+ // consumer-configured route glob (`delivery.quality.navigability.routeGlobs`)
395
+ // the lens joins `riskRoutedAudits`, exactly like a verdict-routed lens. No
396
+ // new routing function is introduced — the predicate feeds the existing
397
+ // `unionAudits` seam. Unconfigured consumers route nothing (silent no-op), so
398
+ // the change-set-scoped selection is unchanged. `cfg` may be `{}` in tests;
399
+ // the predicate tolerates an absent config and returns `false`.
400
+ const navigabilityRouted = routesNavigabilityLens({
401
+ changedFiles,
402
+ config: cfg,
403
+ })
404
+ ? GLOBAL_LENS_ALLOWLIST.slice()
405
+ : [];
406
+ const riskRoutedAudits = unionAudits(
407
+ riskRoutedFromVerdict,
408
+ navigabilityRouted,
409
+ );
368
410
  const selectedAudits = unionAudits(changeSetAudits, riskRoutedAudits);
369
411
 
412
+ // Epic #4131 (F2) — surface which selected lenses are on the global-lens
413
+ // allowlist so the helper runs them against the WHOLE route tree, exempt from
414
+ // the cross-epic-leak guard's change-set narrowing (`#3362`). The exemption
415
+ // is scoped to these lenses only; every other selected lens stays scoped to
416
+ // `changedFiles`. Order follows `selectedAudits` for a deterministic list.
417
+ const globalLenses = selectedAudits.filter(isGlobalLens);
418
+
370
419
  // Story #3939 — resolve the run's audit depth from the SAME model-judged
371
420
  // risk envelope the lenses route from, folded with the changed-file count
372
421
  // the change set just produced. Best-effort: an absent checkpoint degrades
@@ -392,6 +441,7 @@ export async function runEpicAuditPrepare(values, deps = {}) {
392
441
  selectedAudits,
393
442
  changeSetAudits,
394
443
  riskRoutedAudits,
444
+ globalLenses,
395
445
  changedFiles,
396
446
  changedFilesCount: changedFiles.length,
397
447
  substitutionsPayload: changedFiles.join('\n'),
@@ -12,7 +12,10 @@
12
12
  * Modes (additive — the fast checks below always run):
13
13
  * (default) — config validation + git remote check only.
14
14
  * Targets <2s.
15
- * --paranoid — adds ticket-hierarchy revalidation.
15
+ * --paranoid — adds ticket-hierarchy revalidation plus a
16
+ * navigability-reachability semantic check (silent
17
+ * no-op unless `planning.navigation.routeGlobs` is
18
+ * configured).
16
19
  * --prime-install — adds the pnpm content-addressable-store priming
17
20
  * path (up to 300s).
18
21
  *
@@ -214,6 +217,182 @@ async function checkTickets(provider, epicId) {
214
217
  };
215
218
  }
216
219
 
220
+ /**
221
+ * Resolve the navigation config that drives the reachability check.
222
+ *
223
+ * The check is opt-in: a consumer that has not configured
224
+ * `planning.navigation.routeGlobs` gets a silent no-op (F7 / AC-13). The
225
+ * nav-registry token list is what a route-adding Story is expected to
226
+ * reference somewhere in its body or `## Acceptance` section.
227
+ *
228
+ * @param {object} config Resolved `.agentrc.json`.
229
+ * @returns {{ routeGlobs: string[], navRegistry: string[] }}
230
+ */
231
+ function resolveNavConfig(config) {
232
+ const nav = config?.planning?.navigation ?? {};
233
+ const toList = (v) =>
234
+ (Array.isArray(v) ? v : v == null ? [] : [v])
235
+ .filter((s) => typeof s === 'string' && s.trim().length > 0)
236
+ .map((s) => s.trim());
237
+ return {
238
+ routeGlobs: toList(nav.routeGlobs),
239
+ navRegistry: toList(nav.navRegistry),
240
+ };
241
+ }
242
+
243
+ /**
244
+ * Translate a route glob (`pages/**`, `app/**\/route.ts`) into a RegExp that
245
+ * matches a path string. Supports `**` (any depth, including `/`), `*` (any
246
+ * run of non-separator chars), and `?` (single non-separator char). All other
247
+ * characters are matched literally.
248
+ *
249
+ * @param {string} glob
250
+ * @returns {RegExp}
251
+ */
252
+ function globToRegExp(glob) {
253
+ // Collapse adjacent `**` segments before compiling. `**/**` and `***` both
254
+ // mean "any depth", but compiling them literally emits adjacent `.*` runs
255
+ // (`.*/.*` / `.*.*`) that backtrack catastrophically on a long non-matching
256
+ // path. Collapsing to a single `**` preserves semantics and keeps the
257
+ // matcher linear (ReDoS hardening — Epic #4131 audit follow-up).
258
+ const normalized = glob
259
+ .replace(/\*\*(?:\/\*\*)+/g, '**')
260
+ .replace(/\*{3,}/g, '**');
261
+ let re = '';
262
+ for (let i = 0; i < normalized.length; i++) {
263
+ const ch = normalized[i];
264
+ if (ch === '*') {
265
+ if (normalized[i + 1] === '*') {
266
+ re += '.*';
267
+ i++;
268
+ } else {
269
+ re += '[^/]*';
270
+ }
271
+ } else if (ch === '?') {
272
+ re += '[^/]';
273
+ } else {
274
+ re += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&');
275
+ }
276
+ }
277
+ return new RegExp(`^${re}$`);
278
+ }
279
+
280
+ /**
281
+ * Extract the candidate route-touching paths a Story declares. Reads the
282
+ * `## Changes` block (the decompose-author emits one `{"path":...}` JSON
283
+ * object per bullet) and falls back to any bare ```` `path/like/this` ````
284
+ * inline-code spans in the body.
285
+ *
286
+ * @param {string} body
287
+ * @returns {string[]}
288
+ */
289
+ function extractStoryPaths(body) {
290
+ if (typeof body !== 'string' || body.length === 0) return [];
291
+ const paths = new Set();
292
+ // `{"path":"pages/foo.tsx", ...}` change descriptors.
293
+ for (const m of body.matchAll(/"path"\s*:\s*"([^"]+)"/g)) {
294
+ paths.add(m[1]);
295
+ }
296
+ // Inline-code spans that look like a path (contain a slash or a dotted ext).
297
+ for (const m of body.matchAll(/`([^`]+)`/g)) {
298
+ const token = m[1].trim();
299
+ if (/[/.]/.test(token) && !token.includes(' ')) paths.add(token);
300
+ }
301
+ return [...paths];
302
+ }
303
+
304
+ /**
305
+ * Return the `## Acceptance` + full-body text a Story is expected to reference
306
+ * the nav registry from. The whole body is searched (the registry can be cited
307
+ * in the Goal, Changes, or Acceptance), so this just lower-cases the body once.
308
+ *
309
+ * @param {string} body
310
+ * @returns {string}
311
+ */
312
+ function referenceableText(body) {
313
+ return typeof body === 'string' ? body.toLowerCase() : '';
314
+ }
315
+
316
+ /**
317
+ * Navigability-reachability semantic check (F7 / AC-8).
318
+ *
319
+ * Flags every Story that adds a route (touches a path matching a configured
320
+ * `planning.navigation.routeGlobs` entry) but whose body / acceptance never
321
+ * references the configured nav registry. Silent no-op (returns ok with an
322
+ * explicit detail) when no route-glob config is present.
323
+ *
324
+ * @param {object} provider
325
+ * @param {number|null} epicId
326
+ * @param {object} config
327
+ */
328
+ async function checkReachability(provider, epicId, config) {
329
+ const { routeGlobs, navRegistry } = resolveNavConfig(config);
330
+
331
+ // Opt-in: unconfigured consumers degrade to a silent no-op.
332
+ if (routeGlobs.length === 0) {
333
+ return {
334
+ ok: true,
335
+ detail: 'No planning.navigation.routeGlobs configured — skipped.',
336
+ };
337
+ }
338
+
339
+ if (!epicId) {
340
+ return {
341
+ ok: false,
342
+ detail:
343
+ 'reachability check requires --epic <ID> to fetch the ticket hierarchy.',
344
+ };
345
+ }
346
+
347
+ let tickets;
348
+ try {
349
+ tickets = await provider.getSubTickets(epicId);
350
+ } catch (err) {
351
+ return {
352
+ ok: false,
353
+ detail: `Could not fetch Epic #${epicId} tickets: ${err.message}`,
354
+ };
355
+ }
356
+
357
+ const stories = tickets.filter((t) => t.labels.includes(TYPE_LABELS.STORY));
358
+ const matchers = routeGlobs.map(globToRegExp);
359
+ const registryTokens = navRegistry.map((t) => t.toLowerCase());
360
+
361
+ const flagged = [];
362
+ for (const story of stories) {
363
+ const body = story.body ?? '';
364
+ const addsRoute = extractStoryPaths(body).some((p) =>
365
+ matchers.some((rx) => rx.test(p)),
366
+ );
367
+ if (!addsRoute) continue;
368
+
369
+ const text = referenceableText(body);
370
+ // When no explicit registry token is configured, fall back to the
371
+ // generic "nav registry" phrase so a route-adding Story is still
372
+ // expected to mention the navigation surface.
373
+ const tokens =
374
+ registryTokens.length > 0
375
+ ? registryTokens
376
+ : ['nav registry', 'navigation'];
377
+ const referencesRegistry = tokens.some((tok) => text.includes(tok));
378
+ if (!referencesRegistry) flagged.push(`#${story.id}`);
379
+ }
380
+
381
+ if (flagged.length > 0) {
382
+ const registryHint =
383
+ navRegistry.length > 0 ? navRegistry.join(', ') : 'the nav registry';
384
+ return {
385
+ ok: false,
386
+ detail: `${flagged.length} route-adding story/stories never reference ${registryHint}: ${flagged.join(', ')}`,
387
+ };
388
+ }
389
+
390
+ return {
391
+ ok: true,
392
+ detail: `${stories.length} stories scanned — every route-adding story references the nav registry.`,
393
+ };
394
+ }
395
+
217
396
  /** Prime the pnpm content-addressable store via `pnpm install --frozen-lockfile`. */
218
397
  function primePnpmStore(cwd, dryRun) {
219
398
  const lockFile = path.join(cwd, 'pnpm-lock.yaml');
@@ -274,6 +453,9 @@ async function timed(name, fn) {
274
453
  * @returns {Promise<{ok: boolean, degraded: boolean, reason: string|null,
275
454
  * checks: Array<{name: string, ok: boolean, durationMs: number, detail: string}>}>}
276
455
  */
456
+ // exported for tests — direct-unit coverage of the reachability semantics.
457
+ export { checkReachability, extractStoryPaths, globToRegExp };
458
+
277
459
  // exported for tests — Story-level reuse runner reserved for future test coverage
278
460
  export async function runPlanHealthcheck(opts = {}) {
279
461
  const ARG_KEYS = ['epicId', 'paranoid', 'primeInstall', 'dryRun'];
@@ -309,13 +491,20 @@ export async function runPlanHealthcheck(opts = {}) {
309
491
  await timed('git-remote', async () => checkGitRemote(baseBranch, cwd)),
310
492
  );
311
493
 
312
- // Paranoid lane: ticket-hierarchy revalidation (2-tier only).
494
+ // Paranoid lane: ticket-hierarchy revalidation (2-tier only) plus the
495
+ // navigability-reachability semantic check (silent no-op when unconfigured).
313
496
  if (paranoid) {
314
497
  const provider = opts.injectedProvider || createProvider(config);
315
498
  progress('CHECK', 'Validating ticket hierarchy...');
316
499
  checks.push(
317
500
  await timed('ticket-hierarchy', () => checkTickets(provider, epicId)),
318
501
  );
502
+ progress('CHECK', 'Checking route-reachability (nav registry)...');
503
+ checks.push(
504
+ await timed('reachability', () =>
505
+ checkReachability(provider, epicId, config),
506
+ ),
507
+ );
319
508
  }
320
509
 
321
510
  // Optional pnpm-store priming.
@@ -16,7 +16,12 @@
16
16
 
17
17
  export { runAuditSuite } from './runner.js';
18
18
  export {
19
+ GLOBAL_LENS_ALLOWLIST,
20
+ isGlobalLens,
19
21
  matchesAnyFilePattern,
20
22
  matchesFilePattern,
23
+ NAVIGABILITY_LENS,
24
+ resolveNavigabilityRouteGlobs,
25
+ routesNavigabilityLens,
21
26
  selectAudits,
22
27
  } from './selector.js';
@@ -26,6 +26,79 @@ import { withTimeout } from '../util/with-timeout.js';
26
26
 
27
27
  const DEFAULT_GIT_TIMEOUT_MS = 30000;
28
28
 
29
+ /**
30
+ * The audit-lens identifier for the navigability lens (Epic #4131, F2/F3).
31
+ * Authored as `.agents/workflows/audit-navigability.md`; registered here so the
32
+ * roster, the global-lens allowlist, and the route-added routing seam all
33
+ * reference one symbol rather than a hard-coded string.
34
+ */
35
+ export const NAVIGABILITY_LENS = 'audit-navigability';
36
+
37
+ /**
38
+ * The **global-lens allowlist** — lenses that evaluate a property of the
39
+ * **whole** product (not just the Epic's change set) and are therefore exempt
40
+ * from the cross-epic-leak guard (`#3362`) that narrows every other lens's
41
+ * evidence to the Epic's `changedFiles`. A lens in this set still runs through
42
+ * the SAME `runAuditSuite` / `selectAuditStrategy` engine; only the
43
+ * change-set narrowing is bypassed, and only for the listed lenses. The guard
44
+ * is **not** weakened for any lens absent from this set.
45
+ *
46
+ * Navigability is the founding member: reachability is a global property — a
47
+ * change can orphan a route it never touched — so the lens must read the whole
48
+ * route tree + nav registry regardless of which file triggered it.
49
+ */
50
+ export const GLOBAL_LENS_ALLOWLIST = Object.freeze([NAVIGABILITY_LENS]);
51
+
52
+ /**
53
+ * True when `lens` is on the global-lens allowlist and is therefore exempt
54
+ * from the cross-epic-leak guard's change-set narrowing. Pure; the single
55
+ * read-side of {@link GLOBAL_LENS_ALLOWLIST} so callers never hard-code the
56
+ * membership test.
57
+ *
58
+ * @param {string} lens
59
+ * @returns {boolean}
60
+ */
61
+ export function isGlobalLens(lens) {
62
+ return GLOBAL_LENS_ALLOWLIST.includes(lens);
63
+ }
64
+
65
+ /**
66
+ * Resolve the consumer's navigability route globs from the resolved config.
67
+ * Reads `delivery.quality.navigability.routeGlobs` — the route-tree SSOT the
68
+ * navigability lens enumerates and the route-added routing predicate matches
69
+ * against. Returns an empty array when the block (or any ancestor) is absent,
70
+ * so an unconfigured consumer routes nothing and the lens degrades to a silent
71
+ * no-op (Epic #4131 — "no-op when unconfigured").
72
+ *
73
+ * @param {object|null|undefined} config Resolved `.agentrc.json` wrapper.
74
+ * @returns {string[]} Route globs, or `[]` when unconfigured.
75
+ */
76
+ export function resolveNavigabilityRouteGlobs(config) {
77
+ const globs = config?.delivery?.quality?.navigability?.routeGlobs;
78
+ return Array.isArray(globs) ? globs.filter((g) => typeof g === 'string') : [];
79
+ }
80
+
81
+ /**
82
+ * Decide whether a change set routes the navigability lens. The lens is routed
83
+ * when any `changedFiles` entry matches a consumer-configured route glob
84
+ * (`delivery.quality.navigability.routeGlobs`) — i.e. the change set adds or
85
+ * touches a route file. When no route globs are configured, this returns
86
+ * `false` (the unconfigured no-op), so the existing change-set-scoped lens
87
+ * selection is unchanged.
88
+ *
89
+ * This is a pure predicate over the SAME inputs the existing risk-routed-lens
90
+ * union already consumes; the caller folds its result into `riskRoutedAudits`
91
+ * via the existing `unionAudits` — no new routing machinery is added.
92
+ *
93
+ * @param {{ changedFiles?: string[], config?: object|null }} params
94
+ * @returns {boolean}
95
+ */
96
+ export function routesNavigabilityLens({ changedFiles, config } = {}) {
97
+ const globs = resolveNavigabilityRouteGlobs(config);
98
+ if (globs.length === 0) return false;
99
+ return matchesAnyFilePattern(globs, changedFiles ?? []);
100
+ }
101
+
29
102
  /**
30
103
  * Test a single filename against a single glob pattern using the project's
31
104
  * configured matcher semantics (`picomatch` with `dot: true`). Exported so
@@ -96,6 +96,19 @@ export const QUALITY_SCHEMA = {
96
96
  codingGuardrails: CODING_GUARDRAILS_SCHEMA,
97
97
  autoRefresh: AUTO_REFRESH_SCHEMA,
98
98
  baselineEpsilon: BASELINE_EPSILON_SCHEMA,
99
+ // Navigability lens + post-wave integration gate config (Epic #4131,
100
+ // F2/F3/F1/F4). Read by audit-suite/selector.js (route globs) and the
101
+ // deliver-epic.md Phase 6.5 gate (journey suite). Opt-in: absent or empty
102
+ // routeGlobs degrades to a silent no-op.
103
+ navigability: {
104
+ type: 'object',
105
+ properties: {
106
+ routeGlobs: { type: 'array', items: { type: 'string' } },
107
+ navRegistry: { type: 'array', items: { type: 'string' } },
108
+ journeySuite: { type: 'string' },
109
+ },
110
+ additionalProperties: false,
111
+ },
99
112
  },
100
113
  additionalProperties: false,
101
114
  };
@@ -282,6 +282,17 @@ const PLANNING_SCHEMA = {
282
282
  // decompose loop's re-prompt gate.
283
283
  failOnSharedEditors: { type: 'boolean' },
284
284
  requireExplicitCrossStoryDeps: { type: 'boolean' },
285
+ // Navigability-reachability config consumed by the epic-plan-healthcheck
286
+ // --paranoid reachability check (Epic #4131, F7). Opt-in: absent or empty
287
+ // routeGlobs degrades to a silent no-op.
288
+ navigation: {
289
+ type: 'object',
290
+ properties: {
291
+ routeGlobs: { type: 'array', items: { type: 'string' } },
292
+ navRegistry: { type: 'array', items: { type: 'string' } },
293
+ },
294
+ additionalProperties: false,
295
+ },
285
296
  },
286
297
  additionalProperties: false,
287
298
  };