mandrel 1.82.0 → 1.84.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 (47) 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/rules/testing-standards.md +14 -0
  7. package/.agents/schemas/agentrc.schema.json +31 -3
  8. package/.agents/schemas/qa-ledger.schema.json +2 -2
  9. package/.agents/scripts/epic-deliver-prepare.js +41 -1
  10. package/.agents/scripts/lib/config/explain.js +4 -1
  11. package/.agents/scripts/lib/config-settings-schema.js +25 -1
  12. package/.agents/scripts/lib/epic-body-sections.js +88 -0
  13. package/.agents/scripts/lib/findings/promote-finding.js +3 -3
  14. package/.agents/scripts/lib/findings/severity.js +5 -6
  15. package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
  16. package/.agents/scripts/lib/orchestration/context-hydration-engine.js +96 -11
  17. package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
  18. package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
  19. package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
  20. package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
  21. package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
  22. package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -5
  23. package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
  24. package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +4 -22
  25. package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
  26. package/.agents/skills/core/epic-plan-premortem/SKILL.md +9 -8
  27. package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
  28. package/.agents/skills/core/epic-plan-spec-author/SKILL.md +21 -81
  29. package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
  30. package/.agents/skills/skills.index.json +3 -3
  31. package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
  32. package/.agents/workflows/helpers/code-review.md +70 -5
  33. package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
  34. package/.agents/workflows/helpers/deliver-epic.md +164 -469
  35. package/.agents/workflows/helpers/epic-deliver-story.md +35 -11
  36. package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
  37. package/.agents/workflows/helpers/plan-epic.md +56 -186
  38. package/.agents/workflows/helpers/plan-story.md +31 -61
  39. package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
  40. package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
  41. package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
  42. package/.agents/workflows/helpers/single-story-deliver.md +128 -392
  43. package/.agents/workflows/qa-explore.md +63 -32
  44. package/.agents/workflows/qa-run.md +293 -130
  45. package/docs/CHANGELOG.md +21 -0
  46. package/package.json +1 -1
  47. package/.agents/schemas/qa-finding.schema.json +0 -133
@@ -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',
@@ -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
  /**