bmad-method-test-architecture-enterprise 3.1.0 → 3.3.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/.claude-plugin/marketplace.json +1 -1
- package/cli/automate.js +7 -0
- package/cli/evaluate.js +20 -0
- package/cli/examples/gitlab-ci.yaml +1 -1
- package/cli/examples/pr-test-review.yml +3 -3
- package/cli/lib/agent-adapters.js +46 -0
- package/cli/lib/automate-inventory.js +258 -25
- package/cli/lib/automate-prompt.js +34 -2
- package/cli/lib/automate-result.js +74 -2
- package/cli/lib/evaluate/configuration-experiment.js +3401 -0
- package/cli/lib/evaluate/experiments.js +1299 -0
- package/cli/lib/evaluate/retain-skill-artifacts.js +78 -0
- package/cli/lib/evaluate/test-design-corpus/clean/docs/epics/epic-9-last-sync-indicator.md +47 -0
- package/cli/lib/evaluate/test-design-corpus/ground-truth.json +222 -0
- package/cli/lib/evaluate/test-design-corpus/native-identity.json +4 -0
- package/cli/lib/evaluate/test-design-corpus/seeded/docs/epics/epic-7-offline-order-capture.md +75 -0
- package/cli/lib/evaluate/test-design-replay.js +452 -0
- package/cli/lib/run-agent.js +18 -2
- package/cli/lib/test-design-staged-config.js +60 -0
- package/cli/lib/test-design-staging.js +229 -0
- package/cli/lib/unit-runners.js +219 -0
- package/cli/lib/vitest-attempts-reporter.mjs +30 -0
- package/cli/test-design-runner.js +245 -11
- package/cli/unit-check.js +595 -0
- package/package.json +13 -3
- package/skills/bmad-tea-automate/red/checklist.md +1 -1
- package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
- package/skills/bmad-tea-evaluate/SKILL.md +11 -0
- package/skills/bmad-tea-evaluate/references/configuration-experiments.md +37 -0
- package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
- package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
- package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
- package/skills/bmod-tea/bmod.toml +1 -1
- package/skills/bmod-tea/help/help.md +1 -1
- package/skills/bmod-tea/knowledge/auth-session.md +25 -20
- package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
|
@@ -0,0 +1,452 @@
|
|
|
1
|
+
/** Shared deterministic Test Design scoring. No execution or harness dependencies. */
|
|
2
|
+
'use strict';
|
|
3
|
+
const fs = require('node:fs');
|
|
4
|
+
const path = require('node:path');
|
|
5
|
+
const { readDesign, RISK_ID_PATTERN, bandFor } = require('../test-design-parser');
|
|
6
|
+
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
7
|
+
const SCALE_MIN = 1;
|
|
8
|
+
const SCALE_MAX = 3;
|
|
9
|
+
const THRESHOLDS = {
|
|
10
|
+
groundedRiskRecall: 0.8,
|
|
11
|
+
riskPrecision: 0.8,
|
|
12
|
+
scaleComplianceAccuracy: 1,
|
|
13
|
+
scoreArithmeticAccuracy: 1,
|
|
14
|
+
categoryValidityAccuracy: 1,
|
|
15
|
+
bandPlacementAccuracy: 1,
|
|
16
|
+
riskIdWellFormedAccuracy: 1,
|
|
17
|
+
riskLinkResolutionAccuracy: 1,
|
|
18
|
+
priorityOrderingAccuracy: 1,
|
|
19
|
+
coverageMappingAccuracy: 0.8,
|
|
20
|
+
maxUnscoredRiskTables: 0,
|
|
21
|
+
maxUngroundedRisks: 0,
|
|
22
|
+
maxTopSeverityMissed: 0,
|
|
23
|
+
maxRiskCeilingExcess: 0,
|
|
24
|
+
maxUnstableCases: 0,
|
|
25
|
+
maxFixtureMutations: 0,
|
|
26
|
+
};
|
|
27
|
+
function normalizeText(value) {
|
|
28
|
+
return String(value ?? '')
|
|
29
|
+
.replaceAll(/\s+/g, ' ')
|
|
30
|
+
.trim()
|
|
31
|
+
.toLowerCase();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function matchesGroups(groups, normalized) {
|
|
35
|
+
if (!Array.isArray(groups) || groups.length === 0) return false;
|
|
36
|
+
return groups.every((group) => group.some((token) => normalized.includes(String(token).toLowerCase())));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function orderedPairsFor(set) {
|
|
40
|
+
const risks = set.materialRisks ?? [];
|
|
41
|
+
const pairs = [];
|
|
42
|
+
for (const first of risks) {
|
|
43
|
+
for (const second of risks) {
|
|
44
|
+
if (first.severityRank < second.severityRank) pairs.push({ higher: first.id, lower: second.id });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return pairs;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function documentMentions(set, design) {
|
|
51
|
+
const normalized = normalizeText(design.text);
|
|
52
|
+
const mentions = {};
|
|
53
|
+
for (const risk of set.materialRisks ?? []) {
|
|
54
|
+
mentions[risk.id] = matchesGroups(risk.anyOf, normalized);
|
|
55
|
+
}
|
|
56
|
+
for (const risk of set.unsupportedRisks ?? []) {
|
|
57
|
+
mentions[risk.id] = design.risks.some((row) => row.score > 3 && matchesGroups(risk.anyOf, normalizeText(row.description)));
|
|
58
|
+
}
|
|
59
|
+
return mentions;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function topSeverityMissed(set, missed) {
|
|
63
|
+
const material = set.materialRisks ?? [];
|
|
64
|
+
if (material.length === 0) return 0;
|
|
65
|
+
const top = Math.min(...material.map((risk) => risk.severityRank));
|
|
66
|
+
return material.filter((risk) => risk.severityRank === top && missed.includes(risk.id)).length;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function scoreRun(set, design, categories) {
|
|
70
|
+
const { risks, coverage } = design;
|
|
71
|
+
|
|
72
|
+
// -- shape: the checks that need no fixture at all ------------------------
|
|
73
|
+
const seenIds = new Set();
|
|
74
|
+
const shape = { rows: risks.length, wellFormedIds: 0, validCategories: 0, inScale: 0, arithmeticOk: 0, banded: 0, bandOk: 0 };
|
|
75
|
+
const shapeFailures = [];
|
|
76
|
+
for (const risk of risks) {
|
|
77
|
+
if (RISK_ID_PATTERN.test(risk.id) && !seenIds.has(risk.id)) shape.wellFormedIds += 1;
|
|
78
|
+
else shapeFailures.push({ id: risk.rawId, check: seenIds.has(risk.id) ? 'duplicate-risk-id' : 'risk-id-format' });
|
|
79
|
+
seenIds.add(risk.id);
|
|
80
|
+
|
|
81
|
+
if (categories.has(risk.category)) shape.validCategories += 1;
|
|
82
|
+
else shapeFailures.push({ id: risk.rawId, check: 'category', actual: risk.category });
|
|
83
|
+
|
|
84
|
+
const inScale = [risk.probability, risk.impact].every((value) => Number.isInteger(value) && value >= SCALE_MIN && value <= SCALE_MAX);
|
|
85
|
+
if (inScale) shape.inScale += 1;
|
|
86
|
+
else shapeFailures.push({ id: risk.rawId, check: 'scale', probability: risk.probability, impact: risk.impact });
|
|
87
|
+
|
|
88
|
+
// Arithmetic is only a question once both factors are on the scale. A row
|
|
89
|
+
// whose probability is missing has already been counted as a scale defect,
|
|
90
|
+
// and counting it twice would report one wrong row as two.
|
|
91
|
+
if (inScale) {
|
|
92
|
+
if (risk.score === risk.probability * risk.impact) shape.arithmeticOk += 1;
|
|
93
|
+
else shapeFailures.push({ id: risk.rawId, check: 'arithmetic', expected: risk.probability * risk.impact, actual: risk.score });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// The band the row is filed under is the document's own second statement about
|
|
97
|
+
// that row's score, and the two have to agree. A register that bands nothing
|
|
98
|
+
// leaves this denominator empty, which reports as unmeasurable rather than as a
|
|
99
|
+
// pass: the template declares three bands and a document that dropped them has
|
|
100
|
+
// dropped the structure the check reads.
|
|
101
|
+
// Every row is checked, and a row under a heading that declares no range is a
|
|
102
|
+
// band failure. Counting only the rows that sat under a declared range let a
|
|
103
|
+
// single rangeless heading exempt them: `### Medium/Low-Priority Risks`, the
|
|
104
|
+
// literal heading test-design-qa-template.md ships, took the denominator to
|
|
105
|
+
// zero and a score-9 risk filed beneath it scored a clean 100%. `banded` is
|
|
106
|
+
// therefore `rows` and the question is how many of them were placed correctly.
|
|
107
|
+
shape.banded += 1;
|
|
108
|
+
const placed = bandFor(risk.headings);
|
|
109
|
+
if (placed && Number.isInteger(risk.score) && risk.score >= placed.band.min && risk.score <= placed.band.max) shape.bandOk += 1;
|
|
110
|
+
else {
|
|
111
|
+
shapeFailures.push({
|
|
112
|
+
id: risk.rawId,
|
|
113
|
+
check: 'band',
|
|
114
|
+
score: risk.score,
|
|
115
|
+
heading: placed ? placed.heading : null,
|
|
116
|
+
reason: placed ? 'the score falls outside the range its heading declares' : 'no enclosing heading declares a score range',
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// Rows the scale rejected are outside the arithmetic denominator, for the reason
|
|
121
|
+
// above: one wrong row is one failure.
|
|
122
|
+
const arithmeticTotal = shape.inScale;
|
|
123
|
+
|
|
124
|
+
// -- risk links resolve ---------------------------------------------------
|
|
125
|
+
const declaredIds = new Set(risks.map((risk) => risk.id));
|
|
126
|
+
const links = { total: 0, resolved: 0, dangling: [] };
|
|
127
|
+
for (const row of coverage) {
|
|
128
|
+
for (const id of row.riskIds) {
|
|
129
|
+
links.total += 1;
|
|
130
|
+
if (declaredIds.has(id)) links.resolved += 1;
|
|
131
|
+
else links.dangling.push(id);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// -- grounding ------------------------------------------------------------
|
|
136
|
+
// Material risks are matched first and a matched row is consumed, so a row that
|
|
137
|
+
// names a real risk in words an exclusion also uses is never counted twice.
|
|
138
|
+
const material = set.materialRisks ?? [];
|
|
139
|
+
// Maximum matching over the eligible pairs, by augmenting path, rather than
|
|
140
|
+
// first-come assignment in declaration order. One prose row can satisfy two
|
|
141
|
+
// declared risks: "uncapped retry storms during a large backlog sync blow the 30
|
|
142
|
+
// second budget" is eligible for both the retry risk and the backlog risk. Taking
|
|
143
|
+
// the first row per risk let the earlier declaration claim it and left the later
|
|
144
|
+
// one unmatched, so a run that reported both risks correctly scored one of them.
|
|
145
|
+
const eligible = material.map((declared) => {
|
|
146
|
+
const admitted = new Set(declared.categories ?? []);
|
|
147
|
+
return risks
|
|
148
|
+
.map((risk, index) => ({ risk, index }))
|
|
149
|
+
.filter(({ risk }) => admitted.has(risk.category) && matchesGroups(declared.anyOf, normalizeText(risk.description)))
|
|
150
|
+
.map(({ index }) => index);
|
|
151
|
+
});
|
|
152
|
+
const rowToRisk = new Map();
|
|
153
|
+
const augment = (declaredIndex, seen) => {
|
|
154
|
+
for (const rowIndex of eligible[declaredIndex]) {
|
|
155
|
+
if (seen.has(rowIndex)) continue;
|
|
156
|
+
seen.add(rowIndex);
|
|
157
|
+
const held = rowToRisk.get(rowIndex);
|
|
158
|
+
if (held === undefined || augment(held, seen)) {
|
|
159
|
+
rowToRisk.set(rowIndex, declaredIndex);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
return false;
|
|
164
|
+
};
|
|
165
|
+
for (const [declaredIndex] of material.entries()) augment(declaredIndex, new Set());
|
|
166
|
+
|
|
167
|
+
const riskToRow = new Map([...rowToRisk].map(([rowIndex, declaredIndex]) => [declaredIndex, rowIndex]));
|
|
168
|
+
const matches = [];
|
|
169
|
+
const missed = [];
|
|
170
|
+
for (const [declaredIndex, declared] of material.entries()) {
|
|
171
|
+
const rowIndex = riskToRow.get(declaredIndex);
|
|
172
|
+
if (rowIndex === undefined) missed.push(declared.id);
|
|
173
|
+
else matches.push({ riskId: declared.id, rowId: risks[rowIndex].id });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Scored rows above the 1–3 guard band are tested against the exclusions,
|
|
177
|
+
// including a row that matched a material risk. A row asserting the unencrypted
|
|
178
|
+
// queue and a cross-tenant leak in one sentence must count both claims.
|
|
179
|
+
const ungrounded = [];
|
|
180
|
+
for (const risk of risks) {
|
|
181
|
+
if (risk.score === null || risk.score <= 3) continue;
|
|
182
|
+
const invented = (set.unsupportedRisks ?? []).find((entry) => matchesGroups(entry.anyOf, normalizeText(risk.description)));
|
|
183
|
+
if (invented) ungrounded.push({ unsupportedId: invented.id, rowId: risk.rawId });
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -- the risk ceiling -----------------------------------------------------
|
|
187
|
+
// Every set declares one, and the threshold on the excess is zero. It was on the
|
|
188
|
+
// clean control alone, which left over-reporting on the seeded set unbounded:
|
|
189
|
+
// four risks the fixture never anticipated could be appended to a correct run and
|
|
190
|
+
// every threshold still passed, because precision counts only the rows matching a
|
|
191
|
+
// risk the epic rules out in as many words and says nothing about invention the
|
|
192
|
+
// fixture did not foresee.
|
|
193
|
+
const ceiling = typeof set.maxRisks === 'number' ? { maxRisks: set.maxRisks, excess: Math.max(0, risks.length - set.maxRisks) } : null;
|
|
194
|
+
|
|
195
|
+
// -- coverage mapping -----------------------------------------------------
|
|
196
|
+
// Evaluated over the material risks that were actually reported. A risk the run
|
|
197
|
+
// never named is already a recall miss, and failing its coverage as well would
|
|
198
|
+
// score one omission twice.
|
|
199
|
+
const rowIdFor = new Map(matches.map((entry) => [entry.riskId, entry.rowId]));
|
|
200
|
+
const coverageChecks = [];
|
|
201
|
+
for (const declared of material) {
|
|
202
|
+
const rowId = rowIdFor.get(declared.id);
|
|
203
|
+
if (rowId === undefined) continue;
|
|
204
|
+
const levels = coverage.filter((row) => row.riskIds.includes(rowId)).map((row) => row.level);
|
|
205
|
+
const admitted = new Set((declared.acceptableCoverage ?? []).map((level) => level.toLowerCase()));
|
|
206
|
+
const ok = levels.some((level) => admitted.has(level.toLowerCase()));
|
|
207
|
+
coverageChecks.push({
|
|
208
|
+
riskId: declared.id,
|
|
209
|
+
rowId,
|
|
210
|
+
ok,
|
|
211
|
+
levels,
|
|
212
|
+
reason: ok ? null : levels.length === 0 ? 'no coverage row links this risk' : 'no linked row uses an admitted level',
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// -- priority ordering ----------------------------------------------------
|
|
217
|
+
// The strongest priority any coverage row gives a risk. A risk covered at both P0
|
|
218
|
+
// and P2 is being treated as P0 work, and reading the weakest instead would fail
|
|
219
|
+
// a run for a thorough coverage plan.
|
|
220
|
+
const priorityFor = (rowId) => {
|
|
221
|
+
const ranks = coverage.filter((row) => row.riskIds.includes(rowId) && row.priority !== null).map((row) => PRIORITY_RANK[row.priority]);
|
|
222
|
+
return ranks.length === 0 ? null : Math.min(...ranks);
|
|
223
|
+
};
|
|
224
|
+
// A plan that files every risk at one priority satisfies `higher <= lower` on every
|
|
225
|
+
// pair without ordering anything, so the constraint set is trivially met. Two or
|
|
226
|
+
// more risks reaching a priority, all of them the same one, is an answer the metric
|
|
227
|
+
// cannot read, and it is reported unmeasurable rather than as a perfect score.
|
|
228
|
+
const orderingChecks = [];
|
|
229
|
+
for (const pair of orderedPairsFor(set)) {
|
|
230
|
+
const higherRow = rowIdFor.get(pair.higher);
|
|
231
|
+
const lowerRow = rowIdFor.get(pair.lower);
|
|
232
|
+
const higher = higherRow === undefined ? null : priorityFor(higherRow);
|
|
233
|
+
const lower = lowerRow === undefined ? null : priorityFor(lowerRow);
|
|
234
|
+
if (higher === null || lower === null) {
|
|
235
|
+
// Unresolvable, so it leaves the denominator rather than passing for free.
|
|
236
|
+
orderingChecks.push({ higher: pair.higher, lower: pair.lower, resolvable: false, ok: null });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
orderingChecks.push({
|
|
240
|
+
higher: pair.higher,
|
|
241
|
+
lower: pair.lower,
|
|
242
|
+
resolvable: true,
|
|
243
|
+
ok: higher <= lower,
|
|
244
|
+
higherPriority: `P${higher}`,
|
|
245
|
+
lowerPriority: `P${lower}`,
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const resolvedPriorities = new Set(
|
|
250
|
+
material
|
|
251
|
+
.map((declared) => rowIdFor.get(declared.id))
|
|
252
|
+
.filter((rowId) => rowId !== undefined)
|
|
253
|
+
.map((rowId) => priorityFor(rowId))
|
|
254
|
+
.filter((rank) => rank !== null),
|
|
255
|
+
);
|
|
256
|
+
const flattened = resolvedPriorities.size === 1 && orderingChecks.some((check) => check.resolvable);
|
|
257
|
+
|
|
258
|
+
return {
|
|
259
|
+
set: set.id,
|
|
260
|
+
mentions: documentMentions(set, design),
|
|
261
|
+
shape: { ...shape, arithmeticTotal },
|
|
262
|
+
shapeFailures,
|
|
263
|
+
links,
|
|
264
|
+
grounding: {
|
|
265
|
+
declared: material.length,
|
|
266
|
+
matched: matches.length,
|
|
267
|
+
matches,
|
|
268
|
+
missed,
|
|
269
|
+
// The recall floor is 0.8 and the seeded set declares five material risks, so
|
|
270
|
+
// "four of five" clears it and nothing distinguished a miss of the least
|
|
271
|
+
// severe risk from a miss of the most severe one. The highest-ranked risk in
|
|
272
|
+
// a set is reported or the run fails, whatever recall comes to.
|
|
273
|
+
topSeverityMissed: topSeverityMissed(set, missed),
|
|
274
|
+
},
|
|
275
|
+
ungrounded,
|
|
276
|
+
ceiling,
|
|
277
|
+
coverageChecks,
|
|
278
|
+
orderingChecks,
|
|
279
|
+
flattenedPriorities: flattened,
|
|
280
|
+
unscoredRiskTables: design.unscoredTables ?? [],
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function signatureOf(scored, mutations) {
|
|
285
|
+
return JSON.stringify([
|
|
286
|
+
scored.set,
|
|
287
|
+
scored.mentions,
|
|
288
|
+
scored.shape,
|
|
289
|
+
scored.shapeFailures,
|
|
290
|
+
scored.links,
|
|
291
|
+
scored.grounding,
|
|
292
|
+
scored.ungrounded,
|
|
293
|
+
scored.ceiling,
|
|
294
|
+
scored.coverageChecks,
|
|
295
|
+
scored.orderingChecks,
|
|
296
|
+
scored.flattenedPriorities,
|
|
297
|
+
scored.unscoredRiskTables,
|
|
298
|
+
mutations,
|
|
299
|
+
]);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function testDesignDiagnosticProjection(scored, mutations) {
|
|
303
|
+
const ordering = scored.orderingChecks.filter((check) => check.resolvable);
|
|
304
|
+
return {
|
|
305
|
+
groundedRiskRecall: {
|
|
306
|
+
expected: scored.grounding.declared,
|
|
307
|
+
numerator: scored.grounding.matched,
|
|
308
|
+
denominator: scored.grounding.declared,
|
|
309
|
+
threshold: THRESHOLDS.groundedRiskRecall,
|
|
310
|
+
},
|
|
311
|
+
riskPrecision: {
|
|
312
|
+
numerator: scored.shape.rows - scored.ungrounded.length,
|
|
313
|
+
denominator: scored.shape.rows,
|
|
314
|
+
threshold: THRESHOLDS.riskPrecision,
|
|
315
|
+
},
|
|
316
|
+
scaleComplianceAccuracy: {
|
|
317
|
+
numerator: scored.shape.inScale,
|
|
318
|
+
denominator: scored.shape.rows,
|
|
319
|
+
threshold: THRESHOLDS.scaleComplianceAccuracy,
|
|
320
|
+
},
|
|
321
|
+
scoreArithmeticAccuracy: {
|
|
322
|
+
numerator: scored.shape.arithmeticOk,
|
|
323
|
+
denominator: scored.shape.arithmeticTotal,
|
|
324
|
+
threshold: THRESHOLDS.scoreArithmeticAccuracy,
|
|
325
|
+
},
|
|
326
|
+
categoryValidityAccuracy: {
|
|
327
|
+
numerator: scored.shape.validCategories,
|
|
328
|
+
denominator: scored.shape.rows,
|
|
329
|
+
threshold: THRESHOLDS.categoryValidityAccuracy,
|
|
330
|
+
},
|
|
331
|
+
bandPlacementAccuracy: {
|
|
332
|
+
numerator: scored.shape.bandOk,
|
|
333
|
+
denominator: scored.shape.banded,
|
|
334
|
+
threshold: THRESHOLDS.bandPlacementAccuracy,
|
|
335
|
+
},
|
|
336
|
+
riskIdWellFormedAccuracy: {
|
|
337
|
+
numerator: scored.shape.wellFormedIds,
|
|
338
|
+
denominator: scored.shape.rows,
|
|
339
|
+
threshold: THRESHOLDS.riskIdWellFormedAccuracy,
|
|
340
|
+
},
|
|
341
|
+
riskLinkResolutionAccuracy: {
|
|
342
|
+
numerator: scored.links.resolved,
|
|
343
|
+
denominator: scored.links.total,
|
|
344
|
+
threshold: THRESHOLDS.riskLinkResolutionAccuracy,
|
|
345
|
+
},
|
|
346
|
+
priorityOrderingAccuracy: {
|
|
347
|
+
expected: scored.orderingChecks.length,
|
|
348
|
+
numerator: scored.flattenedPriorities ? 0 : ordering.filter((check) => check.ok).length,
|
|
349
|
+
denominator: scored.flattenedPriorities ? 0 : ordering.length,
|
|
350
|
+
threshold: THRESHOLDS.priorityOrderingAccuracy,
|
|
351
|
+
},
|
|
352
|
+
coverageMappingAccuracy: {
|
|
353
|
+
expected: scored.coverageChecks.length,
|
|
354
|
+
numerator: scored.coverageChecks.filter((check) => check.ok).length,
|
|
355
|
+
denominator: scored.coverageChecks.length,
|
|
356
|
+
threshold: THRESHOLDS.coverageMappingAccuracy,
|
|
357
|
+
},
|
|
358
|
+
ungroundedRisks: scored.ungrounded.length,
|
|
359
|
+
maxUngroundedRisks: THRESHOLDS.maxUngroundedRisks,
|
|
360
|
+
unscoredRiskTables: scored.unscoredRiskTables.length,
|
|
361
|
+
maxUnscoredRiskTables: THRESHOLDS.maxUnscoredRiskTables,
|
|
362
|
+
topSeverityMissed: scored.grounding.topSeverityMissed,
|
|
363
|
+
maxTopSeverityMissed: THRESHOLDS.maxTopSeverityMissed,
|
|
364
|
+
riskCeilingExcess: scored.ceiling?.excess ?? 0,
|
|
365
|
+
maxRiskCeilingExcess: THRESHOLDS.maxRiskCeilingExcess,
|
|
366
|
+
fixtureMutations: mutations,
|
|
367
|
+
maxFixtureMutations: THRESHOLDS.maxFixtureMutations,
|
|
368
|
+
maxUnstableCases: THRESHOLDS.maxUnstableCases,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function projectTestDesignResult(text, set, categories) {
|
|
372
|
+
const read = readDesign({ kind: 'text', value: text });
|
|
373
|
+
if (!read.ok) return { unmeasurable: read.failureClass };
|
|
374
|
+
|
|
375
|
+
const scored = scoreRun(set, read.design, categories);
|
|
376
|
+
const resolvable = scored.orderingChecks.filter((check) => check.resolvable);
|
|
377
|
+
return {
|
|
378
|
+
mentions: scored.mentions,
|
|
379
|
+
shape: scored.shape,
|
|
380
|
+
shapeFailures: scored.shapeFailures,
|
|
381
|
+
links: { total: scored.links.total, resolved: scored.links.resolved, dangling: scored.links.dangling },
|
|
382
|
+
grounding: {
|
|
383
|
+
declared: scored.grounding.declared,
|
|
384
|
+
matched: scored.grounding.matched,
|
|
385
|
+
missed: scored.grounding.missed,
|
|
386
|
+
topSeverityMissed: scored.grounding.topSeverityMissed,
|
|
387
|
+
},
|
|
388
|
+
ungrounded: scored.ungrounded,
|
|
389
|
+
ceiling: scored.ceiling,
|
|
390
|
+
unscoredRiskTables: scored.unscoredRiskTables,
|
|
391
|
+
coverage: {
|
|
392
|
+
evaluated: scored.coverageChecks.length,
|
|
393
|
+
satisfied: scored.coverageChecks.filter((check) => check.ok).length,
|
|
394
|
+
failures: scored.coverageChecks
|
|
395
|
+
.filter((check) => !check.ok)
|
|
396
|
+
.map((check) => ({ riskId: check.riskId, reason: check.reason, levels: check.levels })),
|
|
397
|
+
},
|
|
398
|
+
ordering: {
|
|
399
|
+
pairs: scored.orderingChecks.length,
|
|
400
|
+
resolvable: resolvable.length,
|
|
401
|
+
satisfied: resolvable.filter((check) => check.ok).length,
|
|
402
|
+
flattened: scored.flattenedPriorities,
|
|
403
|
+
failures: resolvable
|
|
404
|
+
.filter((check) => !check.ok)
|
|
405
|
+
.map((check) => ({
|
|
406
|
+
higher: check.higher,
|
|
407
|
+
lower: check.lower,
|
|
408
|
+
higherPriority: check.higherPriority,
|
|
409
|
+
lowerPriority: check.lowerPriority,
|
|
410
|
+
})),
|
|
411
|
+
},
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function loadGroundTruth() {
|
|
416
|
+
return JSON.parse(fs.readFileSync(path.join(__dirname, 'test-design-corpus/ground-truth.json'), 'utf8'));
|
|
417
|
+
}
|
|
418
|
+
function numericContributions(value, prefix = '', contributions = {}) {
|
|
419
|
+
if (typeof value === 'number') {
|
|
420
|
+
if (Number.isFinite(value) && prefix) contributions[prefix] = value;
|
|
421
|
+
return contributions;
|
|
422
|
+
}
|
|
423
|
+
if (typeof value === 'boolean') {
|
|
424
|
+
if (prefix) contributions[prefix] = value ? 1 : 0;
|
|
425
|
+
return contributions;
|
|
426
|
+
}
|
|
427
|
+
if (Array.isArray(value)) {
|
|
428
|
+
if (prefix) contributions[`${prefix}.count`] = value.length;
|
|
429
|
+
return contributions;
|
|
430
|
+
}
|
|
431
|
+
if (value === null || typeof value !== 'object') return contributions;
|
|
432
|
+
for (const [key, child] of Object.entries(value)) {
|
|
433
|
+
numericContributions(child, prefix ? `${prefix}.${key}` : key, contributions);
|
|
434
|
+
}
|
|
435
|
+
return contributions;
|
|
436
|
+
}
|
|
437
|
+
module.exports = {
|
|
438
|
+
PRIORITY_RANK,
|
|
439
|
+
normalizeText,
|
|
440
|
+
matchesGroups,
|
|
441
|
+
orderedPairsFor,
|
|
442
|
+
documentMentions,
|
|
443
|
+
topSeverityMissed,
|
|
444
|
+
scoreRun,
|
|
445
|
+
signatureOf,
|
|
446
|
+
testDesignDiagnosticProjection,
|
|
447
|
+
THRESHOLDS,
|
|
448
|
+
readDesign,
|
|
449
|
+
projectTestDesignResult,
|
|
450
|
+
loadGroundTruth,
|
|
451
|
+
numericContributions,
|
|
452
|
+
};
|
package/cli/lib/run-agent.js
CHANGED
|
@@ -36,7 +36,14 @@
|
|
|
36
36
|
const fs = require('node:fs');
|
|
37
37
|
const path = require('node:path');
|
|
38
38
|
const { spawn, spawnSync } = require('node:child_process');
|
|
39
|
-
const {
|
|
39
|
+
const {
|
|
40
|
+
AGENT_ADAPTERS,
|
|
41
|
+
DEFAULT_CAPABILITIES,
|
|
42
|
+
RUNNER_CAPABILITIES,
|
|
43
|
+
bridgedArgsRefused,
|
|
44
|
+
resolveModel,
|
|
45
|
+
agentCaptureTelemetry,
|
|
46
|
+
} = require('./agent-adapters');
|
|
40
47
|
const { launchPrefix } = require('./isolation-primitives');
|
|
41
48
|
const { neutralizeWorkflowCommands } = require('./safe-log');
|
|
42
49
|
|
|
@@ -334,7 +341,16 @@ function agentAnswer({ outcome, stdout, stderr }, { command: spawned, agentComma
|
|
|
334
341
|
function runAgent(prompt, options = {}) {
|
|
335
342
|
const invocation = agentInvocation(prompt, options);
|
|
336
343
|
try {
|
|
337
|
-
|
|
344
|
+
const result = runInvocation(invocation);
|
|
345
|
+
if (!options.captureTelemetry) return result;
|
|
346
|
+
const { stdout, ...telemetry } = agentCaptureTelemetry(options.agent ?? 'claude', result.stdout, result.stderr);
|
|
347
|
+
return {
|
|
348
|
+
...result,
|
|
349
|
+
stdout,
|
|
350
|
+
sourceStdout: result.stdout,
|
|
351
|
+
sourceStderr: result.stderr,
|
|
352
|
+
telemetry,
|
|
353
|
+
};
|
|
338
354
|
} finally {
|
|
339
355
|
invocation.settle();
|
|
340
356
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a staged eval workspace carries besides the skill itself.
|
|
3
|
+
*
|
|
4
|
+
* The central config, in the shape `bmad setup tea` writes: `_bmad/config.toml`
|
|
5
|
+
* with a `[core]` table and a `[modules.tea]` table. Every value is a string, as
|
|
6
|
+
* setup writes them ("true"/"false" for booleans).
|
|
7
|
+
*
|
|
8
|
+
* The knowledge base: a staged skill sits at `skill/` and reads fragments from
|
|
9
|
+
* `{tea-knowledge}` = `{skill-root}/../bmod-tea/knowledge`, so the bmod-tea skill
|
|
10
|
+
* is staged beside it as `bmod-tea/`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
'use strict';
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const TEA_CONFIG_RELATIVE_PATH = path.join('_bmad', 'config.toml');
|
|
18
|
+
const BMOD_TEA_SOURCE = path.join(__dirname, '..', '..', 'skills', 'bmod-tea');
|
|
19
|
+
const STAGED_KNOWLEDGE_RELATIVE_PATH = 'bmod-tea/knowledge';
|
|
20
|
+
/** The prompt's run-configuration line for the knowledge placeholder. */
|
|
21
|
+
const TEA_KNOWLEDGE_PROMPT_LINE = `- \`{tea-knowledge}\`: \`${STAGED_KNOWLEDGE_RELATIVE_PATH}\``;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Copy skills/bmod-tea into `<workspaceDir>/bmod-tea`, beside the staged `skill/`.
|
|
25
|
+
*
|
|
26
|
+
* @param {string} workspaceDir
|
|
27
|
+
*/
|
|
28
|
+
function stageTeaKnowledge(workspaceDir) {
|
|
29
|
+
fs.cpSync(BMOD_TEA_SOURCE, path.join(workspaceDir, 'bmod-tea'), { recursive: true });
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render one table body. Each entry is either a `# comment` line or a
|
|
34
|
+
* `[key, value]` pair; values are written as TOML strings.
|
|
35
|
+
*
|
|
36
|
+
* @param {Array<string | [string, string | boolean]>} entries
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
function tableLines(entries) {
|
|
40
|
+
return entries.map((entry) => (typeof entry === 'string' ? entry : `${entry[0]} = ${JSON.stringify(String(entry[1]))}`));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {object} options
|
|
45
|
+
* @param {string[]} [options.header] - Comment lines above the first table.
|
|
46
|
+
* @param {Array<string | [string, string | boolean]>} options.core
|
|
47
|
+
* @param {Array<string | [string, string | boolean]>} options.tea
|
|
48
|
+
* @returns {string} The TOML text.
|
|
49
|
+
*/
|
|
50
|
+
function teaConfigToml({ header = [], core, tea }) {
|
|
51
|
+
return [...header, '[core]', ...tableLines(core), '', '[modules.tea]', ...tableLines(tea), ''].join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
module.exports = {
|
|
55
|
+
TEA_CONFIG_RELATIVE_PATH,
|
|
56
|
+
STAGED_KNOWLEDGE_RELATIVE_PATH,
|
|
57
|
+
TEA_KNOWLEDGE_PROMPT_LINE,
|
|
58
|
+
stageTeaKnowledge,
|
|
59
|
+
teaConfigToml,
|
|
60
|
+
};
|