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.
- 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/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/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 +14 -0
- package/package.json +1 -1
- 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 {
|
|
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
|
/**
|
|
@@ -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,
|
|
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
|
|
20
|
-
* two optional fields
|
|
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
|
-
'
|
|
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,
|
|
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
|
-
|
|
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
|
+
}
|