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.
- package/.agents/README.md +41 -0
- package/.agents/docs/SDLC.md +4 -2
- package/.agents/docs/agentrc-reference.json +10 -2
- package/.agents/docs/execution-reference.md +52 -0
- package/.agents/instructions.md +76 -38
- package/.agents/rules/testing-standards.md +14 -0
- package/.agents/schemas/agentrc.schema.json +31 -3
- package/.agents/schemas/qa-ledger.schema.json +2 -2
- package/.agents/scripts/epic-deliver-prepare.js +41 -1
- package/.agents/scripts/lib/config/explain.js +4 -1
- package/.agents/scripts/lib/config-settings-schema.js +25 -1
- package/.agents/scripts/lib/epic-body-sections.js +88 -0
- package/.agents/scripts/lib/findings/promote-finding.js +3 -3
- package/.agents/scripts/lib/findings/severity.js +5 -6
- package/.agents/scripts/lib/orchestration/check-baselines/phases/evaluate.js +65 -2
- package/.agents/scripts/lib/orchestration/context-hydration-engine.js +96 -11
- package/.agents/scripts/lib/orchestration/doc-reader.js +29 -0
- package/.agents/scripts/lib/orchestration/docs-digest.js +134 -0
- package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js +15 -1
- package/.agents/scripts/lib/qa/console-allowlist.js +5 -4
- package/.agents/scripts/lib/qa/resolve-qa-contract.js +144 -8
- package/.agents/skills/core/epic-plan-consolidate/SKILL.md +7 -5
- package/.agents/skills/core/epic-plan-consolidate/examples.md +51 -0
- package/.agents/skills/core/epic-plan-decompose-author/SKILL.md +4 -22
- package/.agents/skills/core/epic-plan-decompose-author/examples.md +47 -0
- package/.agents/skills/core/epic-plan-premortem/SKILL.md +9 -8
- package/.agents/skills/core/epic-plan-premortem/examples.md +53 -0
- package/.agents/skills/core/epic-plan-spec-author/SKILL.md +21 -81
- package/.agents/skills/core/epic-plan-spec-author/examples.md +91 -0
- package/.agents/skills/skills.index.json +3 -3
- package/.agents/skills/stack/qa/qa-explore-driving/SKILL.md +52 -38
- package/.agents/workflows/helpers/code-review.md +70 -5
- package/.agents/workflows/helpers/deliver-epic-reference.md +514 -0
- package/.agents/workflows/helpers/deliver-epic.md +164 -469
- package/.agents/workflows/helpers/epic-deliver-story.md +35 -11
- package/.agents/workflows/helpers/plan-epic-reference.md +136 -0
- package/.agents/workflows/helpers/plan-epic.md +56 -186
- package/.agents/workflows/helpers/plan-story.md +31 -61
- package/.agents/workflows/helpers/qa-run-scenario.md +194 -0
- package/.agents/workflows/helpers/scope-triage-gate.md +97 -0
- package/.agents/workflows/helpers/single-story-deliver-reference.md +423 -0
- package/.agents/workflows/helpers/single-story-deliver.md +128 -392
- package/.agents/workflows/qa-explore.md +63 -32
- package/.agents/workflows/qa-run.md +293 -130
- package/docs/CHANGELOG.md +21 -0
- package/package.json +1 -1
- 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-
|
|
9
|
-
*
|
|
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
|
|
18
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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 {
|
|
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-
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
439
|
+
// Hydration section-slicing guardrail (Story #4340): the Epic body
|
|
440
|
+
// is sliced down to only the sections a delivery story agent acts
|
|
441
|
+
// on — Goal / 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${
|
|
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
|
+
}
|
package/.agents/scripts/lib/orchestration/story-close/baseline-attribution/phases/refresh-commit.js
CHANGED
|
@@ -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
|
-
|
|
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[] } }`).
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
/**
|