mandrel 1.82.0 → 1.83.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 (45) hide show
  1. package/.agents/README.md +41 -0
  2. package/.agents/docs/SDLC.md +4 -2
  3. package/.agents/docs/agentrc-reference.json +10 -2
  4. package/.agents/docs/execution-reference.md +52 -0
  5. package/.agents/instructions.md +76 -38
  6. package/.agents/schemas/agentrc.schema.json +31 -3
  7. package/.agents/schemas/qa-ledger.schema.json +2 -2
  8. package/.agents/scripts/epic-deliver-prepare.js +41 -1
  9. package/.agents/scripts/lib/config/explain.js +4 -1
  10. package/.agents/scripts/lib/config-settings-schema.js +25 -1
  11. package/.agents/scripts/lib/epic-body-sections.js +88 -0
  12. package/.agents/scripts/lib/findings/promote-finding.js +3 -3
  13. package/.agents/scripts/lib/findings/severity.js +5 -6
  14. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
  15. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +96 -11
  16. package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
  17. package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
  18. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
  19. package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
  20. package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
  21. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -5
  22. package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
  23. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +4 -22
  24. package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
  25. package/.agents/skills/core/epic-plan-premortem/SKILL.md +9 -8
  26. package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
  27. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +21 -81
  28. package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
  29. package/.agents/skills/skills.index.json +3 -3
  30. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
  31. package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
  32. package/.agents/workflows/helpers/deliver-epic.md +164 -469
  33. package/.agents/workflows/helpers/epic-deliver-story.md +35 -11
  34. package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
  35. package/.agents/workflows/helpers/plan-epic.md +56 -186
  36. package/.agents/workflows/helpers/plan-story.md +31 -61
  37. package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
  38. package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
  39. package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
  40. package/.agents/workflows/helpers/single-story-deliver.md +128 -392
  41. package/.agents/workflows/qa-explore.md +63 -32
  42. package/.agents/workflows/qa-run.md +293 -130
  43. package/docs/CHANGELOG.md +14 -0
  44. package/package.json +1 -1
  45. package/.agents/schemas/qa-finding.schema.json +0 -133
package/.agents/README.md CHANGED
@@ -216,6 +216,47 @@ If your AI tool is not Claude Code, load
216
216
  [`instructions.md`](instructions.md) verbatim through that tool's own
217
217
  system-prompt mechanism (`.cursorrules`, Custom Instructions, etc.).
218
218
 
219
+ ### What to always-load vs read on-demand
220
+
221
+ The always-loaded context is re-paid on every session **and every subagent
222
+ spawn**, so the shipped set is kept deliberately lean. Load this core into your
223
+ system prompt; read everything else only when the task engages it (the same
224
+ read-when-relevant pattern skills use).
225
+
226
+ **Always-load (the recommended core):**
227
+
228
+ - [`instructions.md`](instructions.md) — the core agent protocol.
229
+ - [`personas/engineer.md`](personas/engineer.md) — the default persona (swap in
230
+ another persona when the task calls for it).
231
+ - [`rules/security-baseline.md`](rules/security-baseline.md) — inviolable
232
+ security MUSTs, relevant to every change.
233
+ - [`rules/git-conventions.md`](rules/git-conventions.md) — every commit,
234
+ branch, and PR touches it.
235
+
236
+ **Read on-demand (do the read before the matching work):**
237
+
238
+ - [`rules/shell-conventions.md`](rules/shell-conventions.md) — before chaining
239
+ shell commands or writing cross-platform command strings.
240
+ - [`rules/testing-standards.md`](rules/testing-standards.md) — before authoring
241
+ or restructuring tests.
242
+ - [`rules/orchestration-error-handling.md`](rules/orchestration-error-handling.md)
243
+ — before writing or modifying orchestration scripts under
244
+ `.agents/scripts/**`.
245
+ - The remaining domain rules
246
+ ([`rules/api-conventions.md`](rules/api-conventions.md),
247
+ [`rules/gherkin-standards.md`](rules/gherkin-standards.md),
248
+ [`rules/changelog-style.md`](rules/changelog-style.md),
249
+ [`rules/test-seams.md`](rules/test-seams.md)) — when the task is in that
250
+ domain.
251
+ - Every `SKILL.md` under [`skills/`](skills/) — when the task hits its trigger.
252
+ - [`docs/execution-reference.md`](docs/execution-reference.md) — log-level and
253
+ token-budget reference detail lifted out of `instructions.md`.
254
+
255
+ Each on-demand rule opens with a one-line "this rule applies when…" scope
256
+ header, so a quick skim of its first paragraph tells you whether it governs the
257
+ task at hand. `instructions.md` § 1.F is the canonical in-prompt statement of
258
+ this split.
259
+
219
260
  ---
220
261
 
221
262
  ## Interactive repo / project pickers
@@ -1172,8 +1172,10 @@ non-QA consumer) but enforced at run time: the resolver fails **loudly** with
1172
1172
  "this project has not bound the QA harness" when no `qa` block is present —
1173
1173
  there is no silent fallback. The contract's four required keys are
1174
1174
  `qa.featureRoot` (the `.feature` discovery root), `qa.fixturesManifest`
1175
- (persona → seed-data binding), `qa.signInSeam` (the dev-only sign-in seam,
1176
- either `{ urlTemplate }` or `{ skill }`), and `qa.personas` (the persona set,
1175
+ (persona → seed-data binding), `qa.environments` (the environment-keyed map
1176
+ each entry `{ baseUrl, signInSeam, allowWrites? }`, selected per invocation by
1177
+ `resolveQaEnvironment`; the per-environment `signInSeam` is either
1178
+ `{ urlTemplate }` or `{ skill }`), and `qa.personas` (the persona set,
1177
1179
  authored as a name-only array under a url-template seam or as a per-persona
1178
1180
  credential/skill map under a skill seam); the two optional keys
1179
1181
  `qa.consoleAllowlist` and `qa.designTokens` default to `[]` and `null`.
@@ -264,8 +264,16 @@
264
264
  "qa": {
265
265
  "featureRoot": "tests/features",
266
266
  "fixturesManifest": "tests/fixtures/personas.json",
267
- "signInSeam": {
268
- "skill": "stack/qa/sign-in"
267
+ "environments": {
268
+ "local": {
269
+ "baseUrl": "http://localhost:3000",
270
+ "signInSeam": { "urlTemplate": "/dev/sign-in-as/{persona}" }
271
+ },
272
+ "staging": {
273
+ "baseUrl": "https://staging.example.test",
274
+ "signInSeam": { "skill": "stack/qa/sign-in" },
275
+ "allowWrites": false
276
+ }
269
277
  },
270
278
  "personas": {
271
279
  "admin": { "credentialRef": "QA_ADMIN_CREDENTIAL" },
@@ -0,0 +1,52 @@
1
+ # Execution Reference (on-demand)
2
+
3
+ Reference-only material extracted from
4
+ [`.agents/instructions.md`](../instructions.md) so the always-loaded system
5
+ prompt stays lean (Story #4332). Nothing here is a per-task MUST — it is
6
+ detail an agent consults **only when the relevant lever is in play** (tuning
7
+ log verbosity, reasoning about the token budget). The always-loaded protocol
8
+ links here from the sections that used to inline this content.
9
+
10
+ ---
11
+
12
+ ## Log-level control
13
+
14
+ The orchestrator logger (`lib/Logger.js`) emits progress/trace output based on
15
+ the `AGENT_LOG_LEVEL` environment variable:
16
+
17
+ - `silent` — only `fatal` emits; useful for script embedding where the caller
18
+ owns presentation.
19
+ - `info` — default. Emits `info` / `warn` / `error` / `fatal`.
20
+ - `verbose` — adds `debug` trace output on top of the `info` set. `debug` is
21
+ accepted as a backward-compatible alias.
22
+
23
+ This is a diagnostic knob: set it when you need quieter script embedding
24
+ (`silent`) or a deeper trace (`verbose`). The friction-telemetry MUST it sits
25
+ under — post friction to the relevant ticket via `diagnose-friction.js` — stays
26
+ in [`instructions.md` § 1.H](../instructions.md).
27
+
28
+ ---
29
+
30
+ ## FinOps & token budgeting (economic guardrails)
31
+
32
+ Mandrel does **not** enforce live LLM spend from response metadata. The
33
+ framework limits **hydrated prompt size** and optional **pre-dispatch
34
+ estimates**; your host runtime (editor / CLI) owns session quota and hard
35
+ stops. Consult this section when reasoning about why a task prompt was elided
36
+ or why `/deliver` refused a fan-out on budget grounds.
37
+
38
+ ### Token budget (hydration + pre-dispatch estimates)
39
+
40
+ - **`delivery.maxTokenBudget`** (`.agentrc.json`, resolved via
41
+ `lib/config/limits.js`): caps the task prompt built by
42
+ `hydrate-context` / `hydrateContext`. The pipeline uses a rough token
43
+ estimate (≈4 characters per token) and applies section-aware elision
44
+ (`elideEnvelope`) so oversized envelopes drop or summarize lower-priority
45
+ sections before you receive the prompt.
46
+ - **`delivery.preflight.*`** (optional): before `/deliver` fan-out,
47
+ `epic-deliver-preflight.js` compares **estimated** story count, waves,
48
+ install time, GitHub API volume, and Claude quota tokens against configured
49
+ ceilings (`maxClaudeQuotaTokens`, etc.). A breach surfaces via
50
+ `agent::blocked`; there is no per-tool-call metering.
51
+ - **Host runtime**: session billing, quota exhaustion, and operator overrides
52
+ are enforced by your provider (e.g. Claude Code), not by Mandrel scripts.
@@ -87,10 +87,39 @@ local copy is ignored with a `shadowed` warning).
87
87
 
88
88
  ### F. Modular Global Rules
89
89
 
90
- Before writing code or documentation, verify if any domain-agnostic rules
91
- apply by checking the `.agents/rules/` directory (e.g.,
92
- `security-baseline.md`, `testing-standards.md`, `api-conventions.md`,
93
- `git-conventions.md`, `shell-conventions.md`).
90
+ The `.agents/rules/` directory is split into an **always-on core** and an
91
+ **on-demand set** the same read-when-relevant pattern skills use (§ 1.B).
92
+ The core loads into every session; the on-demand rules are read only when the
93
+ task actually engages them, so a generic task (and every subagent it spawns)
94
+ does not re-pay their bytes on every turn.
95
+
96
+ - **Always-on core** (loaded alongside this file):
97
+ - [`rules/security-baseline.md`](rules/security-baseline.md) — inviolable
98
+ security MUSTs; applies to every piece of code generated.
99
+ - [`rules/git-conventions.md`](rules/git-conventions.md) — every commit,
100
+ branch, and PR touches it.
101
+
102
+ - **On-demand** — read the file **before** doing the matching work; each opens
103
+ with a one-line "this rule applies when…" scope header, so skimming its first
104
+ paragraph confirms whether it governs the task at hand:
105
+ - [`rules/shell-conventions.md`](rules/shell-conventions.md) — before
106
+ chaining shell commands or writing cross-platform command strings.
107
+ - [`rules/testing-standards.md`](rules/testing-standards.md) — before
108
+ authoring or restructuring tests (the three-tier pyramid, assertion
109
+ placement, mocking/isolation MUSTs).
110
+ - [`rules/orchestration-error-handling.md`](rules/orchestration-error-handling.md)
111
+ — before writing or modifying orchestration scripts under
112
+ `.agents/scripts/**`.
113
+ - [`rules/api-conventions.md`](rules/api-conventions.md),
114
+ [`rules/gherkin-standards.md`](rules/gherkin-standards.md),
115
+ [`rules/changelog-style.md`](rules/changelog-style.md),
116
+ [`rules/test-seams.md`](rules/test-seams.md) — when the task is in that
117
+ domain (API surface, Gherkin scenarios, changelog prose, test seams).
118
+
119
+ When in doubt, read the rule — the read is cheap relative to shipping a
120
+ MUST-violating change. Precedence between a rule and any other governance
121
+ document is unchanged (§ 1.K): loading a rule on demand does not lower its
122
+ authority.
94
123
 
95
124
  ### G. Structured Configuration
96
125
 
@@ -123,14 +152,9 @@ GitHub Story (or Epic) ticket:
123
152
 
124
153
  #### Log Level Control
125
154
 
126
- The orchestrator logger (`lib/Logger.js`) emits progress/trace output based
127
- on the `AGENT_LOG_LEVEL` environment variable:
128
-
129
- - `silent` — only `fatal` emits; useful for script embedding where the
130
- caller owns presentation.
131
- - `info` — default. Emits `info` / `warn` / `error` / `fatal`.
132
- - `verbose` — adds `debug` trace output on top of the `info` set. `debug` is
133
- accepted as a backward-compatible alias.
155
+ The orchestrator logger honors `AGENT_LOG_LEVEL` (`silent` / `info` /
156
+ `verbose`). The per-level emission table is reference detail — see
157
+ [`docs/execution-reference.md` § Log-level control](docs/execution-reference.md#log-level-control).
134
158
 
135
159
  ### I. Anti-Thrashing Protocol
136
160
 
@@ -217,27 +241,14 @@ Two carve-outs refine the ordering:
217
241
 
218
242
  ## 2. FinOps & Token Budgeting (Economic Guardrails)
219
243
 
220
- Mandrel does **not** enforce live LLM spend from response metadata. The
221
- framework limits **hydrated prompt size** and optional **pre-dispatch
222
- estimates**; your host runtime (editor / CLI) owns session quota and hard
223
- stops.
224
-
225
- ### A. Token budget (hydration + pre-dispatch estimates)
226
-
227
- - **`delivery.maxTokenBudget`** (`.agentrc.json`, resolved via
228
- `lib/config/limits.js`): caps the task prompt built by
229
- `hydrate-context` / `hydrateContext`. The pipeline uses a rough token
230
- estimate (≈4 characters per token) and applies section-aware elision
231
- (`elideEnvelope`) so oversized envelopes drop or summarize
232
- lower-priority sections before you receive the prompt.
233
- - **`delivery.preflight.*`** (optional): before `/deliver` fan-out,
234
- `epic-deliver-preflight.js` compares **estimated** story count, waves,
235
- install time, GitHub API volume, and Claude quota tokens against
236
- configured ceilings (`maxClaudeQuotaTokens`, etc.). A breach surfaces
237
- via `agent::blocked`; there is no per-tool-call metering.
238
- - **Host runtime**: session billing, quota exhaustion, and operator
239
- overrides are enforced by your provider (e.g. Claude Code), not by
240
- Mandrel scripts.
244
+ Mandrel does **not** enforce live LLM spend from response metadata. It caps
245
+ **hydrated prompt size** (`delivery.maxTokenBudget`, section-aware elision) and
246
+ runs optional **pre-dispatch estimates** (`delivery.preflight.*`); your host
247
+ runtime owns session quota and hard stops. The config keys, the ≈4-char/token
248
+ estimate, and the elision behaviour are reference detail — see
249
+ [`docs/execution-reference.md` § FinOps & token budgeting](docs/execution-reference.md#finops--token-budgeting-economic-guardrails).
250
+ Consult it when a task prompt was elided or `/deliver` refused a fan-out on
251
+ budget grounds.
241
252
 
242
253
  ---
243
254
 
@@ -245,8 +256,9 @@ stops.
245
256
 
246
257
  1. **Context First:** Before proposing any solution, understand the
247
258
  repository's tech stack, historical context, and structure.
248
- - **Mandatory Reading**: Before starting ANY task, you MUST read every
249
- file listed in `project.docsContextFiles` in `.agentrc.json`.
259
+ - **Mandatory Reading (planning & interactive tasks)**: For planning
260
+ (`/plan`) and interactive tasks, before starting ANY work you MUST read
261
+ every file listed in `project.docsContextFiles` in `.agentrc.json`.
250
262
  This list is the project's authoritative reference set (architecture,
251
263
  data dictionary, decisions log, patterns, etc.) and replaces any
252
264
  hardcoded filename list. Resolve each entry against
@@ -258,6 +270,23 @@ stops.
258
270
  When it is an index, only the index is the mandatory-read; the
259
271
  per-ADR bodies under `decisions/` are link-followed on demand
260
272
  (index-only by default), not auto-loaded into every task's context.
273
+ - **Digest-first Reading (`/deliver` story sub-agents)**: A `/deliver`
274
+ Story delivery sub-agent (dispatched via `helpers/epic-deliver-story` or
275
+ `helpers/single-story-deliver`) does **not** re-read the full
276
+ `project.docsContextFiles` set per Story. Instead it reads the **per-Epic
277
+ docs digest** — a single compact outline (path, byte size, heading
278
+ outline with line numbers, and the first paragraph under each `##`) that
279
+ `epic-deliver-prepare.js` writes to
280
+ `temp/epic-<epicId>/docs-digest.md` and the parent threads into the
281
+ child prompt as `docsDigestPath`. Use the digest to decide which docs are
282
+ relevant to the Story at hand, then **pull the full file on demand**
283
+ (reading the section at the line number the digest names) when a section
284
+ bears on the change. When `docsDigestPath` is null (the project has no
285
+ `project.docsContextFiles` configured) there is no digest to read and no
286
+ per-Story docs mandate — read a full doc only if the Story's own context
287
+ points you at one. This is the hard cutover from the former
288
+ read-every-file-per-Story rule: delivery children no longer ingest the
289
+ whole docs set up front.
261
290
  - **Conditional Reads**: When the task touches UI copy, layout, or
262
291
  routing and the corresponding file is present in the project, also
263
292
  read `docs/style-guide.md` and `docs/web-routes.md`. Skip both when
@@ -289,9 +318,18 @@ stops.
289
318
 
290
319
  - **Re-Plan on Failure:** If a strategy fails, **STOP** and re-plan
291
320
  immediately. Do not repeat a broken approach.
292
- - **Subagent Strategy:** Use subagents liberally for research, exploration,
293
- or parallel analysis to keep the main context window focused. One
294
- objective per subagent.
321
+ - **Subagent Strategy:** Spawning a subagent is not free — each spawn
322
+ re-pays the full always-loaded context, so treat it as a cost decision,
323
+ not a reflex. Prefer an **inline search** (grep, a targeted read) for
324
+ small or localized lookups where you already know roughly where to look;
325
+ reach for a subagent **only when the work is large enough to justify
326
+ replicating context** — a broad multi-file investigation, a parallel
327
+ exploration front, or an isolated task that would otherwise crowd the main
328
+ context window. One objective per subagent. When the host exposes a
329
+ cheaper or faster capability, prefer it for **mechanical or read-only**
330
+ spawns (search, doc regeneration, lint, log triage) and keep
331
+ **implementation and design** work on the default capability; name no
332
+ specific model — let the host and operator own the concrete mapping.
295
333
  - **Anti-Laziness:** NEVER use placeholder comments like
296
334
  `// ... existing code ...`, `/* rest of file */`, or
297
335
  `// implementation here`. You MUST output the ENTIRE file or the ENTIRE
@@ -1512,6 +1512,34 @@
1512
1512
  }
1513
1513
  ]
1514
1514
  },
1515
+ "qaEnvironments": {
1516
+ "description": "Deployment targets the QA harness can run against (Epic #4326). A map keyed by environment name (e.g. `local`, `staging`), each carrying its own `baseUrl`, its own per-environment sign-in seam (the same url-template/skill union as `qaSignInSeam`), and an optional `allowWrites` gate. resolveQaEnvironment selects one environment per invocation by name or by raw-URL origin match against `baseUrl`; `allowWrites` defaults to true only for the `local` environment. Replaces the retired top-level single `signInSeam`.",
1517
+ "type": "object",
1518
+ "minProperties": 1,
1519
+ "additionalProperties": {
1520
+ "type": "object",
1521
+ "required": ["baseUrl", "signInSeam"],
1522
+ "additionalProperties": false,
1523
+ "properties": {
1524
+ "baseUrl": {
1525
+ "allOf": [
1526
+ {
1527
+ "$ref": "#/$defs/safeString"
1528
+ },
1529
+ {
1530
+ "minLength": 1
1531
+ }
1532
+ ]
1533
+ },
1534
+ "signInSeam": {
1535
+ "$ref": "#/$defs/qaSignInSeam"
1536
+ },
1537
+ "allowWrites": {
1538
+ "type": "boolean"
1539
+ }
1540
+ }
1541
+ }
1542
+ },
1515
1543
  "qaPersonas": {
1516
1544
  "description": "Personas the QA-harness sign-in seam accepts. Two accepted shapes: (1) a plain array of persona names \u2014 the honest shape for a `urlTemplate` dev-impersonation seam, where the persona name is the sole input the workflow consumes; (2) the object-map form keyed by persona name, where each entry carries per-persona auth material (`credentialRef` or `signInSkill`) consulted only under a `skill`/credential seam.",
1517
1545
  "oneOf": [
@@ -1575,7 +1603,7 @@
1575
1603
  },
1576
1604
  "qa": {
1577
1605
  "type": "object",
1578
- "description": "Agent-driven QA harness contract (Epic #3214). Optional top-level block. All filesystem-pointer fields (featureRoot, fixturesManifest, designTokens) carry safeString guards rejecting shell-injection metacharacters. signInSeam is a url-template/skill union; personas resolve to a stored credential reference or a sign-in skill, never an inline secret.",
1606
+ "description": "Agent-driven QA harness contract (Epic #3214; environment-keyed by Epic #4326). Optional top-level block. All filesystem-pointer fields (featureRoot, fixturesManifest, designTokens) carry safeString guards rejecting shell-injection metacharacters. environments is a map of named deployment targets (each with a baseUrl, a per-environment url-template/skill sign-in seam, and an optional allowWrites gate); personas resolve to a stored credential reference or a sign-in skill, never an inline secret.",
1579
1607
  "additionalProperties": false,
1580
1608
  "properties": {
1581
1609
  "featureRoot": {
@@ -1598,8 +1626,8 @@
1598
1626
  }
1599
1627
  ]
1600
1628
  },
1601
- "signInSeam": {
1602
- "$ref": "#/$defs/qaSignInSeam"
1629
+ "environments": {
1630
+ "$ref": "#/$defs/qaEnvironments"
1603
1631
  },
1604
1632
  "personas": {
1605
1633
  "$ref": "#/$defs/qaPersonas"
@@ -2,7 +2,7 @@
2
2
  "$schema": "http://json-schema.org/draft-07/schema#",
3
3
  "$id": "https://mandrel.dev/schemas/qa-ledger.schema.json",
4
4
  "title": "QaLedgerItem",
5
- "description": "Structured exploratory-QA ledger item (`f3-qa-explore-core`, Feature #3710, Epic #3686). One object per observation captured while exploring a consumer surface — distinct from a `qa-finding` (the browser-sweep `F#` finding): a ledger item classifies a broader exploratory signal (product bug, environment-setup friction, tooling/DX gap, test-coverage gap, or enhancement idea), records the evidence and coverage context, names any missing test, and carries the operator disposition so Triage can parse a whole session. A ledger item has a two-phase lifecycle: the read-only **Capture** phase appends an item with the Capture-phase fields populated (`id`, `class`, `severity`, `evidence`, `coverage`, `missingTest`) but no resolved `disposition`; the **Triage** phase later assigns the `disposition`. The schema therefore validates BOTH the captured-but-untriaged shape (`disposition` absent, `null`, or a `pending`/`untriaged` sentinel — the rolling backlog `.agents/scripts/lib/qa/qa-session.js` reads back on resume) AND the fully-triaged shape (`disposition` resolved to `file`/`defer`/`dismiss`), while still rejecting genuinely malformed items. Evidence MUST already be scrubbed of secrets, session tokens, and PII per `.agents/rules/security-baseline.md` before it is rendered here.",
5
+ "description": "Structured exploratory-QA ledger item (`f3-qa-explore-core`, Feature #3710, Epic #3686). One object per observation captured while exploring a consumer surface, and since Story #4330 unified `/qa-run` onto this ledger — one object per browser-sweep `F#` finding as well: a ledger item classifies an exploratory or sweep signal (product bug, environment-setup friction, tooling/DX gap, test-coverage gap, or enhancement idea), records the evidence and coverage context, names any missing test, and carries the operator disposition so Triage can parse a whole session. A ledger item has a two-phase lifecycle: the read-only **Capture** phase appends an item with the Capture-phase fields populated (`id`, `class`, `severity`, `evidence`, `coverage`, `missingTest`) but no resolved `disposition`; the **Triage** phase later assigns the `disposition`. The schema therefore validates BOTH the captured-but-untriaged shape (`disposition` absent, `null`, or a `pending`/`untriaged` sentinel — the rolling backlog `.agents/scripts/lib/qa/qa-session.js` reads back on resume) AND the fully-triaged shape (`disposition` resolved to `file`/`defer`/`dismiss`), while still rejecting genuinely malformed items. Evidence MUST already be scrubbed of secrets, session tokens, and PII per `.agents/rules/security-baseline.md` before it is rendered here.",
6
6
  "type": "object",
7
7
  "additionalProperties": false,
8
8
  "required": [
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "routedTo": {
66
66
  "type": "object",
67
- "description": "Optional finding-to-issue link recording where Triage routed this item. Populated only after the item is promoted to a follow-up ticket (`disposition: file`); omit while the item is still in Capture or was deferred/dismissed. The same link shape is mirrored on `qa-finding.schema.json`.",
67
+ "description": "Optional finding-to-issue link recording where Triage routed this item. Populated only after the item is promoted to a follow-up ticket (`disposition: file`); omit while the item is still in Capture or was deferred/dismissed.",
68
68
  "additionalProperties": false,
69
69
  "required": ["issue", "url", "kind"],
70
70
  "properties": {
@@ -32,13 +32,16 @@
32
32
  * node .agents/scripts/epic-deliver-prepare.js --epic <epicId>
33
33
  */
34
34
 
35
+ import fs from 'node:fs';
36
+ import path from 'node:path';
35
37
  import { parseArgs } from 'node:util';
36
38
 
37
39
  import { runAsCli } from './lib/cli-utils.js';
38
- import { getRunners, resolveConfig } from './lib/config-resolver.js';
40
+ import { getPaths, getRunners, resolveConfig } from './lib/config-resolver.js';
39
41
  import { currentBranch as gitCurrentBranch } from './lib/git-branch-lifecycle.js';
40
42
  import { getEpicBranch, gitSpawn } from './lib/git-utils.js';
41
43
  import { Logger } from './lib/Logger.js';
44
+ import { buildDocsDigest } from './lib/orchestration/docs-digest.js';
42
45
  import {
43
46
  resolveOperator,
44
47
  runPrepareGuards,
@@ -286,6 +289,40 @@ function evaluatePrepareConcurrencyGate({
286
289
  return gate;
287
290
  }
288
291
 
292
+ /**
293
+ * Build the per-Epic docs digest and write it to
294
+ * `<tempRoot>/epic-<id>/docs-digest.md`, returning its repo-relative path.
295
+ * Story #4338 — the parent threads this path into every child prompt so
296
+ * delivery sub-agents read one compact outline instead of re-ingesting the
297
+ * full `project.docsContextFiles` set per Story.
298
+ *
299
+ * Keyed off the **un-defaulted** config (`config.raw`): when the operator has
300
+ * not configured `project.docsContextFiles`, this returns `null` (no file
301
+ * written) rather than digesting the resolver's built-in default set — the
302
+ * digest is an opt-in surface for projects that curate their docs context.
303
+ *
304
+ * @param {{ epicId: number, cwd?: string, config: object }} args
305
+ * @returns {Promise<string|null>} repo-relative digest path, or null when
306
+ * `project.docsContextFiles` is empty/unset (or every file is missing).
307
+ */
308
+ async function writeDocsDigest({ epicId, cwd, config }) {
309
+ const rawFiles = config?.raw?.project?.docsContextFiles;
310
+ const docsContextFiles = Array.isArray(rawFiles) ? rawFiles : [];
311
+ if (docsContextFiles.length === 0) return null;
312
+
313
+ const paths = getPaths(config);
314
+ const root = path.resolve(cwd ?? process.cwd());
315
+ const docsRoot = path.resolve(root, paths.docsRoot);
316
+ const digest = await buildDocsDigest({ docsContextFiles, docsRoot });
317
+ if (digest == null) return null;
318
+
319
+ const relPath = path.join(paths.tempRoot, `epic-${epicId}`, 'docs-digest.md');
320
+ const absPath = path.resolve(root, relPath);
321
+ await fs.promises.mkdir(path.dirname(absPath), { recursive: true });
322
+ await fs.promises.writeFile(absPath, digest, 'utf-8');
323
+ return relPath;
324
+ }
325
+
289
326
  export async function runEpicDeliverPrepare({
290
327
  epicId,
291
328
  cwd,
@@ -374,6 +411,8 @@ export async function runEpicDeliverPrepare({
374
411
  });
375
412
  }
376
413
 
414
+ const docsDigestPath = await writeDocsDigest({ epicId, cwd, config });
415
+
377
416
  return {
378
417
  epicId,
379
418
  storyCount: openStories.length,
@@ -385,6 +424,7 @@ export async function runEpicDeliverPrepare({
385
424
  new Date().toISOString(),
386
425
  concurrencyHazardsBypassed: gate.bypassed,
387
426
  preflightCache: cacheStatus,
427
+ docsDigestPath,
388
428
  };
389
429
  }
390
430
 
@@ -232,7 +232,10 @@ const PREFIX_MEANINGS = Object.freeze([
232
232
  'planning.taskSizing',
233
233
  'Story-sizing threshold for the decompose validator.',
234
234
  ],
235
- ['qa.signInSeam', 'QA harness sign-in seam configuration.'],
235
+ [
236
+ 'qa.environments',
237
+ 'QA harness deployment target (baseUrl, per-environment sign-in seam, allowWrites gate).',
238
+ ],
236
239
  ['qa.personas', 'QA harness persona / credential mapping.'],
237
240
  ]);
238
241
 
@@ -406,12 +406,36 @@ const QA_PERSONAS_SCHEMA = {
406
406
  ],
407
407
  };
408
408
 
409
+ // `environments` is the environment-keyed contract (Epic #4326, Story #4327).
410
+ // It replaces the retired top-level single `signInSeam` shape: each named
411
+ // environment carries its own `baseUrl`, its own per-environment `signInSeam`
412
+ // (reusing the same url-template/skill union), and an optional `allowWrites`
413
+ // gate. Downstream, `resolveQaEnvironment` selects one environment per
414
+ // invocation by name or by raw-URL origin match against `baseUrl`. The map
415
+ // must carry at least one environment. This is a hard cutover — there is no
416
+ // top-level `signInSeam` acceptance branch (see
417
+ // `.agents/rules/git-conventions.md` § Contract Cutovers).
418
+ const QA_ENVIRONMENTS_SCHEMA = {
419
+ type: 'object',
420
+ minProperties: 1,
421
+ additionalProperties: {
422
+ type: 'object',
423
+ properties: {
424
+ baseUrl: { ...SAFE_STRING, minLength: 1 },
425
+ signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
426
+ allowWrites: { type: 'boolean' },
427
+ },
428
+ required: ['baseUrl', 'signInSeam'],
429
+ additionalProperties: false,
430
+ },
431
+ };
432
+
409
433
  export const QA_SCHEMA = {
410
434
  type: 'object',
411
435
  properties: {
412
436
  featureRoot: { ...SAFE_STRING, minLength: 1 },
413
437
  fixturesManifest: { ...SAFE_STRING, minLength: 1 },
414
- signInSeam: QA_SIGN_IN_SEAM_SCHEMA,
438
+ environments: QA_ENVIRONMENTS_SCHEMA,
415
439
  personas: QA_PERSONAS_SCHEMA,
416
440
  consoleAllowlist: {
417
441
  type: 'array',
@@ -198,6 +198,94 @@ export function hasTechSpecContent(body) {
198
198
  return typeof body === 'string' && DELIVERY_SLICING_RE.test(body);
199
199
  }
200
200
 
201
+ /**
202
+ * `##` headings dropped from the delivery-hydrated Epic body. These are
203
+ * ideation / authoring / close-machinery sections a story agent never acts
204
+ * on: keeping them out of the per-Story prompt trims token cost without
205
+ * losing any binding context (the Story carries its own inline
206
+ * acceptance[] / verify[]).
207
+ *
208
+ * @type {ReadonlySet<string>}
209
+ */
210
+ const DELIVERY_DROP_HEADINGS = new Set([
211
+ 'context',
212
+ 'scope',
213
+ 'acceptance criteria',
214
+ ]);
215
+
216
+ /**
217
+ * Slice an Epic body down to the sections a delivery story agent acts on.
218
+ *
219
+ * KEEP: the Epic title / preamble before the first `##`, `## Goal`,
220
+ * `## Non-Goals`, `## User Stories`, the `techSpec` managed region, and —
221
+ * fail-open — any unknown / operator-authored `##` section not in the drop
222
+ * list. DROP: `## Context`, `## Scope`, `## Acceptance Criteria`, and the
223
+ * `acceptanceTable` managed region.
224
+ *
225
+ * The `techSpec` region is located by its markers (not by heading
226
+ * boundaries) so its inner `## Delivery Slicing` heading is preserved
227
+ * verbatim and never mistaken for a plain droppable section. Plain `##`
228
+ * sections outside the managed regions are sliced by heading boundaries.
229
+ *
230
+ * Fail-open is load-bearing: any heading not explicitly in the drop set is
231
+ * kept, so operator-authored content is never silently lost.
232
+ *
233
+ * @param {string} body
234
+ * @returns {string}
235
+ */
236
+ export function sliceEpicBodyForDelivery(body) {
237
+ if (typeof body !== 'string' || body.length === 0) return '';
238
+
239
+ // 1. Drop the acceptance-table managed region outright (authoring/close
240
+ // machinery, never delivery context).
241
+ let working = stripEpicSection(body, 'acceptanceTable');
242
+
243
+ // 2. Protect the techSpec managed region from heading-boundary slicing by
244
+ // lifting it out behind an opaque placeholder, then restoring it after
245
+ // the plain-section pass. Its inner `## Delivery Slicing` heading must
246
+ // survive verbatim.
247
+ const techLoc = locate(working, 'techSpec');
248
+ let techRegion = null;
249
+ const PLACEHOLDER = 'MANDREL_TECH_SPEC_PLACEHOLDER';
250
+ if (techLoc) {
251
+ const { end } = descriptor('techSpec');
252
+ techRegion = working.slice(techLoc.startIdx, techLoc.endIdx + end.length);
253
+ working =
254
+ working.slice(0, techLoc.startIdx) +
255
+ PLACEHOLDER +
256
+ working.slice(techLoc.endIdx + end.length);
257
+ }
258
+
259
+ // 3. Slice plain `##` sections by heading boundaries, dropping only the
260
+ // known ideation/authoring headings. The preamble before the first
261
+ // `##` (Epic title / lede) is always kept.
262
+ const kept = [];
263
+ let dropping = false;
264
+ for (const line of working.split('\n')) {
265
+ const headingMatch = line.match(/^##\s+(.*?)\s*$/);
266
+ if (headingMatch) {
267
+ dropping = DELIVERY_DROP_HEADINGS.has(
268
+ headingMatch[1].trim().toLowerCase(),
269
+ );
270
+ if (dropping) continue;
271
+ }
272
+ if (dropping) continue;
273
+ kept.push(line);
274
+ }
275
+ working = kept.join('\n');
276
+
277
+ // 4. Restore the techSpec region in place.
278
+ if (techRegion !== null) {
279
+ working = working.replace(PLACEHOLDER, () => techRegion);
280
+ }
281
+
282
+ // 5. Normalise the blank-line runs left by the drops.
283
+ return working
284
+ .replace(/\n{3,}/g, '\n\n')
285
+ .replace(/^\n+/, '')
286
+ .trimEnd();
287
+ }
288
+
201
289
  /**
202
290
  * Strip the retired machine-managed `## Planning Artifacts` checklist from
203
291
  * an Epic body (the section that linked the now-retired context tickets).
@@ -196,8 +196,8 @@ function clusterToFinding(cluster) {
196
196
  /**
197
197
  * Build the `routedTo` link the schema stamps onto a promoted ledger item.
198
198
  *
199
- * The `routedTo.url` field is `minLength: 1` in both `qa-finding.schema.json`
200
- * and `qa-ledger.schema.json`, and the search/create port contract requires a
199
+ * The `routedTo.url` field is `minLength: 1` in `qa-ledger.schema.json`,
200
+ * and the search/create port contract requires a
201
201
  * routed issue to carry its canonical URL. So rather than silently stamp an
202
202
  * empty string (which would persist a schema-invalid ledger item), this guards
203
203
  * the url and throws when it is absent or blank (Story #3816, AC #4).
@@ -213,7 +213,7 @@ function routedToLink(issue, kind) {
213
213
  throw new Error(
214
214
  `promoteFindings: routed issue #${issue?.number ?? '?'} is missing a url; ` +
215
215
  'the search/create port contract requires a non-empty url ' +
216
- '(routedTo.url is minLength:1 in the qa-finding/qa-ledger schemas)',
216
+ '(routedTo.url is minLength:1 in the qa-ledger schema)',
217
217
  );
218
218
  }
219
219
  return {
@@ -5,8 +5,8 @@
5
5
  * the shared findings core previously each declared their own severity list —
6
6
  * `classify-finding.js` (`[unknown, low, medium, high, critical]`),
7
7
  * `promote-finding.js` (`SEVERITY_RANK` over `[critical … info]`), and the
8
- * `qa-finding` / `qa-ledger` JSON schemas (`[critical, high, medium, low,
9
- * info]`). Because `severity` is a `fingerprintFinding` identity field
8
+ * `qa-ledger` JSON schema (`[critical, high, medium, low, info]`). Because
9
+ * `severity` is a `fingerprintFinding` identity field
10
10
  * (`route-finding.js`), the same finding could hash to different SHAs depending
11
11
  * on which path normalised its severity, silently weakening dedup. This module
12
12
  * collapses all three onto one enum + one normaliser so the fingerprint is
@@ -14,9 +14,8 @@
14
14
  *
15
15
  * The canonical order is `critical | high | medium | low | info`, highest →
16
16
  * lowest, and it MUST match the `severity` enum in
17
- * `.agents/schemas/qa-ledger.schema.json` (and the mirrored `qa-finding`
18
- * schema). Pure module: no I/O, no module-level state beyond the frozen
19
- * constants.
17
+ * `.agents/schemas/qa-ledger.schema.json`. Pure module: no I/O, no
18
+ * module-level state beyond the frozen constants.
20
19
  */
21
20
 
22
21
  /**
@@ -24,7 +23,7 @@
24
23
  * This is the ONLY definition of the severity vocabulary in the findings core;
25
24
  * `classify-finding.js` and `promote-finding.js` re-export / import it rather
26
25
  * than re-declaring their own list. Mirrors the `severity` enum in
27
- * `qa-ledger.schema.json` / `qa-finding.schema.json`.
26
+ * `qa-ledger.schema.json`.
28
27
  */
29
28
  export const SEVERITIES = Object.freeze([
30
29
  'critical',