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.
- package/.agents/docs/configuration.md +7 -0
- package/.agents/docs/workflows.md +3 -2
- package/.agents/personas/engineer.md +27 -0
- package/.agents/schemas/agentrc.schema.json +38 -0
- package/.agents/schemas/audit-rules.json +12 -0
- package/.agents/scripts/epic-audit-prepare.js +53 -3
- package/.agents/scripts/epic-plan-healthcheck.js +191 -2
- package/.agents/scripts/lib/audit-suite/index.js +5 -0
- package/.agents/scripts/lib/audit-suite/selector.js +73 -0
- package/.agents/scripts/lib/config-settings-schema-quality.js +13 -0
- package/.agents/scripts/lib/config-settings-schema.js +11 -0
- package/.agents/scripts/lib/feedback-loop/prior-feedback-fetcher.js +82 -0
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/authoring-context.js +43 -18
- package/.agents/scripts/lib/orchestration/epic-plan-spec/phases/spec-authoring-grounding.js +147 -0
- package/.agents/scripts/lib/orchestration/retro/phases/compose-body.js +63 -0
- package/.agents/scripts/lib/orchestration/retro/phases/post-and-mirror.js +11 -0
- package/.agents/scripts/lib/orchestration/retro-runner.js +19 -2
- package/.agents/scripts/lib/orchestration/spec-freshness.js +2 -2
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +17 -0
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +138 -0
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +15 -0
- package/.agents/skills/skills.index.json +12 -2
- package/.agents/workflows/audit-navigability.md +129 -0
- package/.agents/workflows/helpers/deliver-epic.md +116 -1
- package/.agents/workflows/helpers/plan-epic.md +60 -5
- package/.agents/workflows/qa-assist.md +214 -141
- package/docs/CHANGELOG.md +14 -0
- 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 (
|
|
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 —
|
|
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 {
|
|
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
|
|
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
|
};
|