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
@@ -8,12 +8,69 @@
8
8
  */
9
9
 
10
10
  import { resolveBundleSizeEnvOverrides } from '../../../baselines/env-overrides.js';
11
- import { checkKernelVersion } from '../../../baselines/kernel.js';
11
+ import {
12
+ checkKernelVersion,
13
+ getKindModule,
14
+ } from '../../../baselines/kernel.js';
12
15
  import * as reader from '../../../baselines/reader.js';
13
16
  import { Logger } from '../../../Logger.js';
17
+ import { isIgnoredByGlobs } from '../../../maintainability-utils.js';
14
18
  import { applyTolerance, evaluateCompare, runCompareStage } from './compare.js';
15
19
  import { applyFloors, flattenBreaches } from './floors.js';
16
20
 
21
+ /**
22
+ * Defense-in-depth against an `ignoreGlobs`-poisoned baseline (Epic #4326
23
+ * incident). The generation path already drops `ignoreGlobs`-matched files
24
+ * before they reach `rows` (both the canonical `buildDefaultMaintainabilityScorer`
25
+ * and the story-close `buildKindScorer`), so a freshly-generated baseline's
26
+ * `rollup["*"]` never includes an ignored file. But the floor check trusts the
27
+ * *stored* `rollup["*"]`: if a baseline is poisoned by some other route — a
28
+ * stale branch's older tooling, a hand-edit, a future generation bug — an
29
+ * ignored file's metric can still drag the global floor axis (e.g.
30
+ * maintainability `min`) below its floor and block every downstream close.
31
+ *
32
+ * This recomputes the global `*` aggregate over the baseline rows that are NOT
33
+ * matched by the gate's `ignoreGlobs`, using the kind's own canonical
34
+ * `rollup()` aggregator, so the floor axis reflects only the files the gate is
35
+ * meant to police. It is a **no-op for a correctly-generated baseline** (no
36
+ * ignored file is present in `rows`, so the filtered set is identical and the
37
+ * stored `rollup["*"]` is returned unchanged) and only affects the `*`
38
+ * component the incident poisons; named-component rollups are left as stored.
39
+ * The compare/regression stage is untouched — this only reshapes the floor
40
+ * input. All three `ignoreGlobs`-configured gates (maintainability, crap,
41
+ * duplication) are `path`-keyed, so the shared path matcher applies uniformly.
42
+ *
43
+ * Kept module-local (not exported): the poison-exclusion behaviour is covered
44
+ * end-to-end through `evaluateKind` by the `check-baselines.min-floor` suite,
45
+ * so there is no external consumer to justify widening the surface.
46
+ *
47
+ * @param {{ kind: string, baseline: { rollup?: object, rows?: object[] }, ignoreGlobs?: string[], cwd?: string }} args
48
+ * @returns {object} the effective rollup to feed the floor check
49
+ */
50
+ function rollupExcludingIgnored({ kind, baseline, ignoreGlobs, cwd }) {
51
+ const rollup = baseline?.rollup;
52
+ if (!Array.isArray(ignoreGlobs) || ignoreGlobs.length === 0) return rollup;
53
+ const rows = baseline?.rows;
54
+ if (!Array.isArray(rows) || rows.length === 0) return rollup;
55
+ let mod;
56
+ try {
57
+ mod = getKindModule(kind);
58
+ } catch {
59
+ return rollup;
60
+ }
61
+ if (mod?.keyField !== 'path' || typeof mod.rollup !== 'function')
62
+ return rollup;
63
+ const kept = rows.filter((row) => {
64
+ const p = row?.path;
65
+ return typeof p !== 'string' || !isIgnoredByGlobs(p, ignoreGlobs, cwd);
66
+ });
67
+ // Nothing ignored is present → the stored rollup already excludes ignored
68
+ // files (the correct-baseline fast path); return it untouched.
69
+ if (kept.length === rows.length) return rollup;
70
+ const recomputed = mod.rollup(kept);
71
+ return { ...rollup, '*': recomputed?.['*'] ?? rollup?.['*'] };
72
+ }
73
+
17
74
  function loadHeadBaseline(kind, cwd, configPath) {
18
75
  try {
19
76
  return { baseline: reader.load(kind, { cwd, configPath }) };
@@ -100,7 +157,13 @@ export async function evaluateKind({
100
157
  const headLoad = loadHeadBaseline(kind, cwd, configPath);
101
158
  if (headLoad.schemaError) return { kind, schemaError: headLoad.schemaError };
102
159
  const baseline = headLoad.baseline;
103
- const findings = applyFloors(kind, baseline.rollup, gateBlock.floors ?? {});
160
+ const floorRollup = rollupExcludingIgnored({
161
+ kind,
162
+ baseline,
163
+ ignoreGlobs: gateBlock.ignoreGlobs,
164
+ cwd,
165
+ });
166
+ const findings = applyFloors(kind, floorRollup, gateBlock.floors ?? {});
104
167
  const breaches = flattenBreaches(findings);
105
168
  const cmp = await evaluateCompare({ kind, gateBlock, scope, cwd });
106
169
  const rawCompare = runCompareStage(baseline, cmp);
@@ -27,7 +27,7 @@ import {
27
27
  PROJECT_ROOT,
28
28
  resolveConfig,
29
29
  } from '../config-resolver.js';
30
- import { stripEpicSection } from '../epic-body-sections.js';
30
+ import { sliceEpicBodyForDelivery } from '../epic-body-sections.js';
31
31
  import { Logger } from '../Logger.js';
32
32
  import {
33
33
  buildEnvelope,
@@ -233,6 +233,71 @@ export function extractStorySections(body) {
233
233
  return { acceptance, verify };
234
234
  }
235
235
 
236
+ /**
237
+ * Remove one `## <heading>` section (heading line through the last line
238
+ * before the next `## ` heading, or EOF) from a Story body. Byte-preserving
239
+ * outside the removed span. No-op when the heading is absent. Case- and
240
+ * whitespace-tolerant to match `extractSectionList`.
241
+ *
242
+ * @param {string} body
243
+ * @param {string} heading
244
+ * @returns {string}
245
+ */
246
+ function stripSection(body, heading) {
247
+ if (typeof body !== 'string' || body.length === 0) return body ?? '';
248
+ const pattern = new RegExp(
249
+ `^##\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*$`,
250
+ 'mi',
251
+ );
252
+ const startMatch = body.match(pattern);
253
+ if (!startMatch || startMatch.index == null) return body;
254
+ const start = startMatch.index;
255
+ const afterHeading = start + startMatch[0].length;
256
+ const rest = body.slice(afterHeading);
257
+ const nextHeading = rest.search(/^##\s+/m);
258
+ const end = nextHeading === -1 ? body.length : afterHeading + nextHeading;
259
+ const before = body.slice(0, start).replace(/\n+$/, '\n');
260
+ const after = body.slice(end).replace(/^\n+/, '');
261
+ return (before + after).replace(/\n{3,}/g, '\n\n').trimEnd();
262
+ }
263
+
264
+ /**
265
+ * Strip the inline `## Acceptance Criteria` / `## Acceptance` and `## Verify`
266
+ * sections from a Story body. Used to build the `taskInstructions` section
267
+ * when the dedicated `acceptanceCriteria` / `verificationCommands` envelope
268
+ * sections already carry those lists — so the binding acceptance/verify
269
+ * content appears exactly once in the hydrated envelope rather than being
270
+ * duplicated between the dedicated sections and the full task body.
271
+ *
272
+ * Each heading group is stripped only when its own dedicated section was
273
+ * emitted (`acceptance` gates `## Acceptance Criteria` + `## Acceptance`;
274
+ * `verify` gates `## Verify`). This keeps the strip symmetric with what was
275
+ * reproduced elsewhere in the envelope: a `## Verify` section that carried no
276
+ * bullets (so no dedicated `verificationCommands` section fired) is left
277
+ * intact in `taskInstructions` rather than being silently dropped. Both flags
278
+ * default to `true` so a no-argument call preserves the original
279
+ * strip-everything behaviour.
280
+ *
281
+ * @param {string} body
282
+ * @param {{ acceptance?: boolean, verify?: boolean }} [opts]
283
+ * @returns {string}
284
+ */
285
+ export function stripStorySectionsForTaskInstructions(
286
+ body,
287
+ { acceptance = true, verify = true } = {},
288
+ ) {
289
+ if (typeof body !== 'string' || body.length === 0) return body ?? '';
290
+ let out = body;
291
+ if (acceptance) {
292
+ out = stripSection(out, 'Acceptance Criteria');
293
+ out = stripSection(out, 'Acceptance');
294
+ }
295
+ if (verify) {
296
+ out = stripSection(out, 'Verify');
297
+ }
298
+ return out;
299
+ }
300
+
236
301
  /**
237
302
  * Detect whether the dispatched unit is a 2-tier Story (Story is the
238
303
  * leaf, carries inline acceptance/verify) vs. a 4-tier Task (Task is
@@ -371,16 +436,15 @@ async function buildHierarchySections(task, provider, epicId, agentSettings) {
371
436
  try {
372
437
  const t = await provider.getTicket(item.id);
373
438
  provenance.push(ticketSnapshot(t, retrievedAt));
374
- // Hydration section-elision guardrail (Story #4324): the Epic
375
- // body's ## Acceptance Table managed section is authoring/close
376
- // machinerynever delivery context. Strip it before it reaches
377
- // a story agent's prompt so per-story prompt size stays flat
378
- // versus the pre-fold baseline (the old acceptance-spec ticket
379
- // was never hydrated either).
439
+ // Hydration section-slicing guardrail (Story #4340): the Epic body
440
+ // is sliced down to only the sections a delivery story agent acts
441
+ // onGoal / Non-Goals / User Stories / Tech Spec (plus any
442
+ // operator-authored section, fail-open). Ideation / authoring /
443
+ // close machinery (## Context, ## Scope, ## Acceptance Criteria,
444
+ // and the ## Acceptance Table managed region) is dropped so
445
+ // per-story prompt size stays flat versus the pre-fold baseline.
380
446
  const body =
381
- item.key === 'Epic'
382
- ? stripEpicSection(t.body ?? '', 'acceptanceTable')
383
- : t.body;
447
+ item.key === 'Epic' ? sliceEpicBodyForDelivery(t.body ?? '') : t.body;
384
448
  return `### ${item.key}: ${t.title} (#${t.id})\n\n${body}\n`;
385
449
  } catch (err) {
386
450
  const detail = err?.message ? `: ${err.message}` : '';
@@ -485,9 +549,16 @@ function buildStaticSections(
485
549
  }
486
550
  }
487
551
 
552
+ // Track which dedicated section(s) were emitted so taskInstructions drops
553
+ // only the inline sections that were actually reproduced elsewhere in the
554
+ // envelope — keeping each binding acceptance/verify item present exactly
555
+ // once, without dropping a bulletless section that has no dedicated twin.
556
+ let acceptanceEmitted = false;
557
+ let verifyEmitted = false;
488
558
  if (isTwoTierStoryTask(task)) {
489
559
  const { acceptance, verify } = extractStorySections(task.body ?? '');
490
560
  if (acceptance.length > 0) {
561
+ acceptanceEmitted = true;
491
562
  sections.push({
492
563
  name: 'acceptanceCriteria',
493
564
  priority: DEFAULT_SECTION_PRIORITIES.acceptanceCriteria,
@@ -499,6 +570,7 @@ function buildStaticSections(
499
570
  });
500
571
  }
501
572
  if (verify.length > 0) {
573
+ verifyEmitted = true;
502
574
  sections.push({
503
575
  name: 'verificationCommands',
504
576
  priority: DEFAULT_SECTION_PRIORITIES.verificationCommands,
@@ -511,11 +583,24 @@ function buildStaticSections(
511
583
  }
512
584
  }
513
585
 
586
+ // When a dedicated acceptance/verify section carries that list, strip the
587
+ // matching inline heading(s) from the task body so they are not duplicated
588
+ // in taskInstructions. Each group is gated on its own section: an inline
589
+ // section with no dedicated twin (e.g. a bulletless `## Verify`) is left
590
+ // intact. When neither fired, taskInstructions is byte-identical to the
591
+ // full body.
592
+ const taskBody =
593
+ acceptanceEmitted || verifyEmitted
594
+ ? stripStorySectionsForTaskInstructions(task.body ?? '', {
595
+ acceptance: acceptanceEmitted,
596
+ verify: verifyEmitted,
597
+ })
598
+ : task.body;
514
599
  sections.push({
515
600
  name: 'taskInstructions',
516
601
  priority: DEFAULT_SECTION_PRIORITIES.taskInstructions,
517
602
  elideWhenOverBudget: DEFAULT_ELIDE_POLICIES.taskInstructions,
518
- content: `## Task Instructions (Issue #${task.id}: ${task.title})\n\n${task.body}`,
603
+ content: `## Task Instructions (Issue #${task.id}: ${task.title})\n\n${taskBody}`,
519
604
  source: { kind: 'ticket', ref: String(task.id) },
520
605
  });
521
606
 
@@ -4,6 +4,35 @@ import { getPaths } from '../config-resolver.js';
4
4
  import { Logger } from '../Logger.js';
5
5
  import { applyBudget } from './planning-context-budget.js';
6
6
 
7
+ /**
8
+ * Read an explicit list of doc files relative to `docsRoot`, returning one
9
+ * `{ name, path, content }` object per file that exists and reads cleanly.
10
+ * Missing or unreadable files are skipped silently (mirrors
11
+ * {@link readDocsFromRoot}'s per-file try/catch). Order is preserved from the
12
+ * input list. This is the shared read/normalize seam the per-Epic docs digest
13
+ * builds on (Story #4338) so there is a single home for the fs read path.
14
+ *
15
+ * @param {{ files: string[], docsRoot?: string }} args
16
+ * @returns {Promise<Array<{ name: string, path: string, content: string }>>}
17
+ */
18
+ export async function readDocFiles({ files, docsRoot } = {}) {
19
+ const list = Array.isArray(files) ? files : [];
20
+ const root =
21
+ typeof docsRoot === 'string' && docsRoot.length > 0 ? docsRoot : '.';
22
+ const reads = list.map(async (name) => {
23
+ const full = path.join(root, name);
24
+ try {
25
+ const stat = await fs.promises.stat(full);
26
+ if (!stat.isFile()) return null;
27
+ const content = await fs.promises.readFile(full, 'utf-8');
28
+ return { name, path: name, content };
29
+ } catch (_e) {
30
+ return null;
31
+ }
32
+ });
33
+ return (await Promise.all(reads)).filter(Boolean);
34
+ }
35
+
7
36
  async function readDocsFromRoot(docsRoot, settings) {
8
37
  const explicit =
9
38
  Array.isArray(settings.docsContextFiles) &&
@@ -0,0 +1,134 @@
1
+ /**
2
+ * docs-digest.js — per-Epic docs digest builder (Story #4338).
3
+ *
4
+ * `/deliver` story sub-agents previously re-read every file in
5
+ * `project.docsContextFiles` on every Story, re-paying the full docs payload
6
+ * per child. This module produces a single **digest** — one compact markdown
7
+ * outline per configured doc — that the parent threads into every child prompt
8
+ * once. The digest gives each child enough shape (path, size, heading outline
9
+ * with line numbers, and the first paragraph under each `##` section) to decide
10
+ * which full files to pull on demand, instead of ingesting the whole set up
11
+ * front.
12
+ *
13
+ * The heavy lifting of reading + normalizing doc bodies is delegated to
14
+ * `doc-reader.js` (`readDocFiles`), keeping a single home for the fs read path.
15
+ */
16
+
17
+ import { readDocFiles } from './doc-reader.js';
18
+
19
+ /**
20
+ * Level-2 / level-3 markdown heading matcher. Mirrors the outline granularity
21
+ * the planning-context budget already uses so the two surfaces agree on what a
22
+ * "section" is.
23
+ */
24
+ const HEADING_RE = /^(#{2,3})\s+(.+?)\s*$/;
25
+
26
+ function byteLen(s) {
27
+ if (s == null) return 0;
28
+ return Buffer.byteLength(String(s), 'utf-8');
29
+ }
30
+
31
+ /**
32
+ * Extract the heading outline (level + text + 1-based line number) from a
33
+ * markdown body. Line numbers let a child jump straight to the section it
34
+ * needs when it pulls the full file.
35
+ *
36
+ * @param {string} content
37
+ * @returns {Array<{ level: number, text: string, line: number }>}
38
+ */
39
+ function extractOutline(content) {
40
+ if (!content) return [];
41
+ const lines = String(content).split(/\r?\n/);
42
+ const out = [];
43
+ for (let i = 0; i < lines.length; i++) {
44
+ const m = HEADING_RE.exec(lines[i]);
45
+ if (m) out.push({ level: m[1].length, text: m[2], line: i + 1 });
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * The first non-empty paragraph that follows a given heading line, up to the
52
+ * next heading or a blank-line paragraph break. Returns '' when the section
53
+ * has no prose (e.g. a heading immediately followed by a sub-heading).
54
+ *
55
+ * @param {string[]} lines full doc split into lines
56
+ * @param {number} headingLine 1-based line of the heading
57
+ * @returns {string}
58
+ */
59
+ function firstParagraphAfter(lines, headingLine) {
60
+ const para = [];
61
+ for (let i = headingLine; i < lines.length; i++) {
62
+ const line = lines[i];
63
+ if (HEADING_RE.test(line)) break;
64
+ if (line.trim() === '') {
65
+ if (para.length > 0) break;
66
+ continue;
67
+ }
68
+ para.push(line.trim());
69
+ }
70
+ return para.join(' ').trim();
71
+ }
72
+
73
+ /**
74
+ * Render one doc's digest section: path + byte size, then a bulleted heading
75
+ * outline where each `##` bullet carries the first paragraph beneath it.
76
+ *
77
+ * @param {{ path: string, content: string }} doc
78
+ * @returns {string} markdown block
79
+ */
80
+ function renderDocSection(doc) {
81
+ const content = typeof doc.content === 'string' ? doc.content : '';
82
+ const lines = content.split(/\r?\n/);
83
+ const outline = extractOutline(content);
84
+ const size = byteLen(content);
85
+
86
+ const parts = [`### \`${doc.path}\` (${size} bytes)`, ''];
87
+ if (outline.length === 0) {
88
+ parts.push('_No `##`/`###` headings._', '');
89
+ return parts.join('\n');
90
+ }
91
+
92
+ for (const h of outline) {
93
+ const indent = h.level === 3 ? ' ' : '';
94
+ parts.push(`${indent}- L${h.line} \`${'#'.repeat(h.level)}\` ${h.text}`);
95
+ if (h.level === 2) {
96
+ const para = firstParagraphAfter(lines, h.line);
97
+ if (para) parts.push(`${indent} ${para}`);
98
+ }
99
+ }
100
+ parts.push('');
101
+ return parts.join('\n');
102
+ }
103
+
104
+ /**
105
+ * Build the per-Epic docs digest markdown from the configured docs context
106
+ * files. Missing files are skipped silently (the read seam returns only the
107
+ * files it could stat + read). Returns `null` when there is nothing to digest
108
+ * — i.e. `docsContextFiles` is empty/unset — so callers surface a null
109
+ * `docsDigestPath` rather than writing an empty file.
110
+ *
111
+ * @param {{ docsContextFiles?: string[], docsRoot?: string }} args
112
+ * @returns {Promise<string|null>} the digest markdown, or null when there are
113
+ * no files to digest.
114
+ */
115
+ export async function buildDocsDigest({ docsContextFiles, docsRoot } = {}) {
116
+ const files = Array.isArray(docsContextFiles) ? docsContextFiles : [];
117
+ if (files.length === 0) return null;
118
+
119
+ const docs = await readDocFiles({ files, docsRoot });
120
+ if (docs.length === 0) return null;
121
+
122
+ const header = [
123
+ '# Docs digest',
124
+ '',
125
+ 'Per-Epic outline of the project docs context set. Each entry lists the',
126
+ 'file path, byte size, and its heading outline (with line numbers) plus',
127
+ 'the first paragraph under each `##` section. Read the full file on demand',
128
+ 'when a section looks relevant — do **not** ingest the whole set per Story.',
129
+ '',
130
+ ].join('\n');
131
+
132
+ const sections = docs.map(renderDocSection).join('\n');
133
+ return `${header}\n${sections}`.replace(/\n+$/, '\n');
134
+ }
@@ -32,6 +32,7 @@ import { Logger as DefaultLogger } from '../../../../Logger.js';
32
32
  import {
33
33
  calculateAll as defaultCalculateAll,
34
34
  scanDirectory as defaultScanDirectory,
35
+ isIgnoredByGlobs as isIgnoredByGlobsMi,
35
36
  } from '../../../../maintainability-utils.js';
36
37
 
37
38
  /**
@@ -101,7 +102,20 @@ export function buildKindScorer({
101
102
  const underTarget = targetAbsDirs.some(
102
103
  (root) => abs === root || abs.startsWith(`${root}${path.sep}`),
103
104
  );
104
- if (underTarget) sourceList.push(abs);
105
+ // Apply `ignoreGlobs` here too — the full-scope walk drops
106
+ // ignore-matched files via `scanDirectory`, so the diff-scope path
107
+ // must do the same or an ignored-but-changed file (e.g. one matched
108
+ // by `config-settings-schema*.js`) enters `rows` and drags the
109
+ // `rollup["*"].min` below the maintainability floor. This mirrors the
110
+ // canonical `buildDefaultMaintainabilityScorer` (refresh-service.js,
111
+ // Story #4293); the story-close auto-refresh routes through this
112
+ // scorer, not that one, so the fix has to live here too.
113
+ if (
114
+ underTarget &&
115
+ !isIgnoredByGlobsMi(abs, miIgnoreGlobs, effectiveCwd)
116
+ ) {
117
+ sourceList.push(abs);
118
+ }
105
119
  }
106
120
  }
107
121
  const scores = await calculateAll(sourceList);
@@ -19,10 +19,11 @@
19
19
  *
20
20
  * The emitted finding aligns with the `F#` finding shape from Tech Spec #3285
21
21
  * (`{ id, classification, surface, symptom, likelyRootCause, disposition,
22
- * acceptance, evidence: { console[], network[] } }`). Finding-shape *schema*
23
- * validation (`qa-finding.schema.json`) is a sibling Story; this module only
24
- * produces the console-derived subset and leaves richer enrichment
25
- * (likely-root-cause heuristics, drafting) to later layers.
22
+ * acceptance, evidence: { console[], network[] } }`). This module produces the
23
+ * console-derived subset; `/qa-run` (Story #4330) maps each such finding onto a
24
+ * `QaLedgerItem` (`qa-ledger.schema.json`) before routing it through the shared
25
+ * classify/route/dedup/promote core, leaving richer enrichment
26
+ * (likely-root-cause heuristics, drafting) to those later layers.
26
27
  */
27
28
 
28
29
  /**
@@ -1,9 +1,11 @@
1
1
  /**
2
- * `qa` contract resolver — Epic #3214, Story #3294.
2
+ * `qa` contract resolver — Epic #3214, Story #3294; environment-keyed
3
+ * contract added by Epic #4326, Story #4327.
3
4
  *
4
5
  * The agent-driven QA harness (`/qa-run`) needs the
5
6
  * consumer's `.agentrc.json` `qa` block to know where the `.feature` root
6
- * lives, how to sign in, and which personas the seam accepts. The block is
7
+ * lives, which deployment targets (`environments`) exist and how to sign in
8
+ * to each, and which personas the seam accepts. The block is
7
9
  * *optional in the schema* (most repos never bind the harness, so config
8
10
  * validation must not break them — see Tech Spec #3285 § "qa contract
9
11
  * block"), which means presence is enforced at run time by this resolver
@@ -16,8 +18,14 @@
16
18
  * exists.
17
19
  * - Malformed block → throw an error naming the offending field so the
18
20
  * operator can fix `.agentrc.json` without spelunking the schema.
19
- * - Well-formed block → return the normalized contract object with the
20
- * two optional fields (`consoleAllowlist`, `designTokens`) defaulted.
21
+ * - Well-formed block → return the normalized contract object with
22
+ * `environments` + `defaultEnvironment` and the two optional fields
23
+ * (`consoleAllowlist`, `designTokens`) defaulted.
24
+ *
25
+ * `resolveQaEnvironment(contract, target)` selects one environment per
26
+ * harness invocation — by exact name or by raw-URL origin match against each
27
+ * environment's `baseUrl` — and throws loudly (naming the known environments)
28
+ * on an unknown name or unmatched URL.
21
29
  */
22
30
 
23
31
  import Ajv from 'ajv';
@@ -36,10 +44,19 @@ import { QA_SCHEMA } from '../config-settings-schema.js';
36
44
  export const QA_REQUIRED_FIELDS = Object.freeze([
37
45
  'featureRoot',
38
46
  'fixturesManifest',
39
- 'signInSeam',
47
+ 'environments',
40
48
  'personas',
41
49
  ]);
42
50
 
51
+ /**
52
+ * The environment name whose `allowWrites` defaults to `true` when the
53
+ * consumer omits the flag. Every other environment defaults to read-only
54
+ * (`allowWrites: false`) so an unguarded remote target cannot accept writes
55
+ * by accident — only the conventional `local` environment is write-enabled
56
+ * by default.
57
+ */
58
+ const WRITE_ENABLED_DEFAULT_ENVIRONMENT = 'local';
59
+
43
60
  /** Defaults applied to the optional fields of a well-formed contract. */
44
61
  export const QA_CONTRACT_DEFAULTS = Object.freeze({
45
62
  consoleAllowlist: Object.freeze([]),
@@ -48,7 +65,7 @@ export const QA_CONTRACT_DEFAULTS = Object.freeze({
48
65
 
49
66
  const ABSENT_MESSAGE =
50
67
  'qa: this project has not bound the QA harness — add a `qa` block to ' +
51
- '.agentrc.json (featureRoot, fixturesManifest, signInSeam, personas) ' +
68
+ '.agentrc.json (featureRoot, fixturesManifest, environments, personas) ' +
52
69
  'before invoking the QA harness. See .agents/docs/agentrc-reference.json for the ' +
53
70
  'full contract shape.';
54
71
 
@@ -127,7 +144,8 @@ function describeError(err) {
127
144
  * @returns {{
128
145
  * featureRoot: string,
129
146
  * fixturesManifest: string,
130
- * signInSeam: object,
147
+ * environments: Record<string, { baseUrl: string, signInSeam: object, allowWrites?: boolean }>,
148
+ * defaultEnvironment: string,
131
149
  * personas: Record<string, object>,
132
150
  * personaNames: string[],
133
151
  * consoleAllowlist: string[],
@@ -173,10 +191,28 @@ export function resolveQaContract(config) {
173
191
 
174
192
  const { personas, personaNames } = normalizePersonas(qa.personas);
175
193
 
194
+ // Clone each environment so callers cannot mutate the resolver's input.
195
+ const environments = {};
196
+ for (const [name, env] of Object.entries(qa.environments)) {
197
+ environments[name] = { ...env };
198
+ }
199
+
200
+ // The default environment is the conventional `local` target when present,
201
+ // otherwise the first-declared environment. `resolveQaEnvironment(contract)`
202
+ // (no target) resolves to this one.
203
+ const environmentNames = Object.keys(environments);
204
+ const defaultEnvironment = Object.hasOwn(
205
+ environments,
206
+ WRITE_ENABLED_DEFAULT_ENVIRONMENT,
207
+ )
208
+ ? WRITE_ENABLED_DEFAULT_ENVIRONMENT
209
+ : environmentNames[0];
210
+
176
211
  return {
177
212
  featureRoot: qa.featureRoot,
178
213
  fixturesManifest: qa.fixturesManifest,
179
- signInSeam: qa.signInSeam,
214
+ environments,
215
+ defaultEnvironment,
180
216
  personas,
181
217
  personaNames,
182
218
  consoleAllowlist: Array.isArray(qa.consoleAllowlist)
@@ -188,3 +224,103 @@ export function resolveQaContract(config) {
188
224
  : qa.designTokens,
189
225
  };
190
226
  }
227
+
228
+ /**
229
+ * Normalize a value to its URL origin (`protocol//host:port`), or `null` when
230
+ * it is not a parseable absolute URL. Used to match a raw-URL `target` against
231
+ * each environment's `baseUrl` by origin, so a target carrying a path,
232
+ * query-string, or trailing slash still resolves to the right environment.
233
+ *
234
+ * @param {string} value
235
+ * @returns {string | null}
236
+ */
237
+ function toOrigin(value) {
238
+ try {
239
+ return new URL(value).origin;
240
+ } catch {
241
+ return null;
242
+ }
243
+ }
244
+
245
+ /**
246
+ * Resolve a single QA environment for one harness invocation.
247
+ *
248
+ * `target` selects which of the contract's `environments` to run against:
249
+ * - **Omitted / falsy** → the contract's `defaultEnvironment`.
250
+ * - **Exact environment name** → that environment.
251
+ * - **Raw URL** → the environment whose `baseUrl` shares the same origin
252
+ * (`protocol//host:port`), so a target with a path or query still matches.
253
+ *
254
+ * Resolution is name-first: a `target` that exactly names an environment wins
255
+ * even if it also happens to parse as a URL.
256
+ *
257
+ * `allowWrites` is resolved to an explicit boolean on the returned object: the
258
+ * environment's own value when set, otherwise `true` only for the conventional
259
+ * `local` environment and `false` for every other target — an unguarded remote
260
+ * environment is read-only unless the consumer opts in.
261
+ *
262
+ * Fails **loudly**: an unknown name or an unmatched URL throws an error that
263
+ * names the known environments so the operator can correct the invocation.
264
+ *
265
+ * @param {{ environments: Record<string, { baseUrl: string, signInSeam: object, allowWrites?: boolean }>, defaultEnvironment: string }} contract
266
+ * A contract returned by `resolveQaContract`.
267
+ * @param {string} [target] Environment name or raw URL. Omit for the default.
268
+ * @returns {{ name: string, baseUrl: string, signInSeam: object, allowWrites: boolean }}
269
+ * @throws {Error} on an unknown name or unmatched URL.
270
+ */
271
+ export function resolveQaEnvironment(contract, target) {
272
+ const environments = contract?.environments;
273
+ if (
274
+ environments == null ||
275
+ typeof environments !== 'object' ||
276
+ Object.keys(environments).length === 0
277
+ ) {
278
+ throw new Error(
279
+ 'qa: cannot resolve an environment — the contract carries no ' +
280
+ '`environments`. Call resolveQaContract first.',
281
+ );
282
+ }
283
+
284
+ const known = Object.keys(environments);
285
+ const knownList = known.map((name) => `\`${name}\``).join(', ');
286
+
287
+ // No target → the default environment.
288
+ const name =
289
+ target == null || target === '' ? contract.defaultEnvironment : target;
290
+
291
+ // Exact-name match wins first (a name that also parses as a URL still
292
+ // resolves by name).
293
+ let resolvedName = Object.hasOwn(environments, name) ? name : null;
294
+
295
+ // Otherwise try to match the target as a raw URL against each baseUrl origin.
296
+ if (resolvedName === null) {
297
+ const targetOrigin = toOrigin(name);
298
+ if (targetOrigin !== null) {
299
+ resolvedName =
300
+ known.find(
301
+ (envName) => toOrigin(environments[envName].baseUrl) === targetOrigin,
302
+ ) ?? null;
303
+ }
304
+ }
305
+
306
+ if (resolvedName === null) {
307
+ throw new Error(
308
+ `qa: unknown environment \`${name}\` — the contract declares ${knownList}. ` +
309
+ 'Pass an exact environment name or a URL whose origin matches one of ' +
310
+ 'their baseUrl values.',
311
+ );
312
+ }
313
+
314
+ const env = environments[resolvedName];
315
+ const allowWrites =
316
+ typeof env.allowWrites === 'boolean'
317
+ ? env.allowWrites
318
+ : resolvedName === WRITE_ENABLED_DEFAULT_ENVIRONMENT;
319
+
320
+ return {
321
+ name: resolvedName,
322
+ baseUrl: env.baseUrl,
323
+ signInSeam: env.signInSeam,
324
+ allowWrites,
325
+ };
326
+ }