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
|
@@ -52,9 +52,10 @@
|
|
|
52
52
|
|
|
53
53
|
const fs = require('node:fs');
|
|
54
54
|
const path = require('node:path');
|
|
55
|
+
const { createHash, randomUUID } = require('node:crypto');
|
|
55
56
|
const { Command } = require('commander');
|
|
56
57
|
|
|
57
|
-
const { AGENT_ADAPTERS } = require('./lib/agent-adapters');
|
|
58
|
+
const { AGENT_ADAPTERS, resolveModel, agentCaptureTelemetry } = require('./lib/agent-adapters');
|
|
58
59
|
const { runAgent } = require('./lib/run-agent');
|
|
59
60
|
const { EXIT_CODES, classOfAgentError, failureClassForExit, vendorEnvironmentNames } = require('./lib/runner-exit-codes');
|
|
60
61
|
const { readDesign, scoredRiskProjection } = require('./lib/test-design-parser');
|
|
@@ -81,7 +82,8 @@ const TEST_DESIGN_REQUEST_KEYS = {
|
|
|
81
82
|
argument: { required: [], permitted: [] },
|
|
82
83
|
option: {
|
|
83
84
|
required: ['agent', 'design-path'],
|
|
84
|
-
permitted: ['agent', 'agent-cmd', 'agent-arg', 'design-path', 'env-pass', 'model', 'timeout-ms'],
|
|
85
|
+
permitted: ['agent', 'agent-cmd', 'agent-arg', 'design-path', 'env-pass', 'model', 'timeout-ms', 'capture-path', 'capture-test-double'],
|
|
86
|
+
types: { 'capture-test-double': 'boolean' },
|
|
85
87
|
},
|
|
86
88
|
environment: { required: [], permitted: vendorEnvironmentNames() },
|
|
87
89
|
stdin: { required: ['prompt'], permitted: ['prompt'] },
|
|
@@ -107,7 +109,7 @@ function readPrompt() {
|
|
|
107
109
|
}
|
|
108
110
|
|
|
109
111
|
/** Derive a projection only after the agent has finished its document. */
|
|
110
|
-
function
|
|
112
|
+
function designText(cwd, relativeDesignPath) {
|
|
111
113
|
const relative = path.normalize(relativeDesignPath);
|
|
112
114
|
if (path.isAbsolute(relative) || relative.startsWith(`..${path.sep}`) || relative === '..' || !relative.endsWith('.md')) {
|
|
113
115
|
throw new Error(`--design-path must name a markdown file inside the working directory: ${JSON.stringify(relativeDesignPath)}`);
|
|
@@ -120,8 +122,13 @@ function projectScoredRisks(cwd, relativeDesignPath) {
|
|
|
120
122
|
if (fromRoot === '..' || fromRoot.startsWith(`..${path.sep}`) || path.isAbsolute(fromRoot) || fs.lstatSync(designPath).isSymbolicLink()) {
|
|
121
123
|
throw new Error(`design path escapes the working directory: ${JSON.stringify(relativeDesignPath)}`);
|
|
122
124
|
}
|
|
123
|
-
|
|
125
|
+
return fs.readFileSync(designPath, 'utf8');
|
|
126
|
+
}
|
|
127
|
+
function projectScoredRisks(cwd, relativeDesignPath, { requireDeliverable = false } = {}) {
|
|
128
|
+
const text = designText(cwd, relativeDesignPath);
|
|
129
|
+
if (text === null) return null;
|
|
124
130
|
const read = readDesign({ kind: 'text', value: text });
|
|
131
|
+
if (!read.ok && requireDeliverable) throw Object.assign(new Error(read.reason), { failureClass: read.failureClass });
|
|
125
132
|
return scoredRiskProjection(read.ok ? read.design : { risks: [], text });
|
|
126
133
|
}
|
|
127
134
|
|
|
@@ -136,6 +143,8 @@ function main(argv) {
|
|
|
136
143
|
.option('--env-pass <NAME>', 'environment variable name allowed through to the agent (repeatable)', collect, [])
|
|
137
144
|
.option('--model <name>', 'model to pin for this run; defaults to the adapter default')
|
|
138
145
|
.option('--design-path <path>', 'staged design document path for a deterministic scored-risk projection')
|
|
146
|
+
.option('--capture-path <path>', 'opt-in source receipt and telemetry inside the workspace')
|
|
147
|
+
.option('--capture-test-double', 'label custom telemetry as a deterministic test double')
|
|
139
148
|
.option('--timeout-ms <n>', 'agent wall-clock timeout in milliseconds', String(DEFAULT_TIMEOUT_MS));
|
|
140
149
|
|
|
141
150
|
program.exitOverride();
|
|
@@ -151,6 +160,9 @@ function main(argv) {
|
|
|
151
160
|
if (!Object.hasOwn(AGENT_ADAPTERS, options.agent)) {
|
|
152
161
|
fail('environment-configuration', `unknown agent "${options.agent}"; expected one of ${Object.keys(AGENT_ADAPTERS).join(', ')}`);
|
|
153
162
|
}
|
|
163
|
+
if (options.captureTestDouble && options.agent !== 'custom') {
|
|
164
|
+
fail('usage', '--capture-test-double requires --agent custom');
|
|
165
|
+
}
|
|
154
166
|
// Tested on the raw string before the parse. Number.parseInt truncates, so
|
|
155
167
|
// `--timeout-ms 20abc` became 20 and every run then timed out at twenty
|
|
156
168
|
// milliseconds, and `--timeout-ms 1e9` became 1.
|
|
@@ -168,29 +180,251 @@ function main(argv) {
|
|
|
168
180
|
}
|
|
169
181
|
|
|
170
182
|
let stdout = '';
|
|
183
|
+
let source = null;
|
|
184
|
+
let captureFile = null;
|
|
185
|
+
let captureFd = null;
|
|
186
|
+
let captureIdentity = null;
|
|
187
|
+
let captureBytes = null;
|
|
188
|
+
let captureResult = null;
|
|
189
|
+
const reportCapture = () => {
|
|
190
|
+
if (captureResult) process.stdout.write(`${JSON.stringify({ sourceCapture: captureResult })}\n`);
|
|
191
|
+
};
|
|
192
|
+
const saveCapture = () => {
|
|
193
|
+
if (captureFd === null) return;
|
|
194
|
+
let integrityFailure = null;
|
|
195
|
+
try {
|
|
196
|
+
const held = fs.fstatSync(captureFd);
|
|
197
|
+
const named = fs.lstatSync(captureFile);
|
|
198
|
+
if (
|
|
199
|
+
!held.isFile() ||
|
|
200
|
+
held.nlink !== 1 ||
|
|
201
|
+
!named.isFile() ||
|
|
202
|
+
named.nlink !== 1 ||
|
|
203
|
+
held.dev !== captureIdentity.dev ||
|
|
204
|
+
held.ino !== captureIdentity.ino ||
|
|
205
|
+
named.dev !== held.dev ||
|
|
206
|
+
named.ino !== held.ino
|
|
207
|
+
)
|
|
208
|
+
integrityFailure = 'source capture path or link identity changed';
|
|
209
|
+
if (!integrityFailure && captureBytes !== null) {
|
|
210
|
+
if (held.size === captureBytes.length) {
|
|
211
|
+
const current = Buffer.alloc(held.size);
|
|
212
|
+
fs.readSync(captureFd, current, 0, current.length, 0);
|
|
213
|
+
if (!current.equals(captureBytes)) integrityFailure = 'source capture bytes changed';
|
|
214
|
+
} else integrityFailure = 'source capture bytes changed';
|
|
215
|
+
}
|
|
216
|
+
} catch (error) {
|
|
217
|
+
integrityFailure = `source capture identity could not be checked: ${error.message}`;
|
|
218
|
+
}
|
|
219
|
+
if (integrityFailure) {
|
|
220
|
+
const recovery = path.join(process.cwd(), `source-capture-recovery-${randomUUID()}.json`);
|
|
221
|
+
source.captureIntegrityViolation = { reason: integrityFailure, originalPath: captureFile, agentStatus: source.status };
|
|
222
|
+
source.status = 'failed';
|
|
223
|
+
const recoveryBytes = Buffer.from(`${JSON.stringify(source, null, 2)}\n`);
|
|
224
|
+
let recoveryFd = null;
|
|
225
|
+
let recoveryFailure = null;
|
|
226
|
+
try {
|
|
227
|
+
recoveryFd = fs.openSync(recovery, 'wx', 0o600);
|
|
228
|
+
fs.writeFileSync(recoveryFd, recoveryBytes);
|
|
229
|
+
fs.fsyncSync(recoveryFd);
|
|
230
|
+
} catch (error) {
|
|
231
|
+
recoveryFailure = error;
|
|
232
|
+
} finally {
|
|
233
|
+
for (const fd of [recoveryFd, captureFd]) {
|
|
234
|
+
if (fd === null) continue;
|
|
235
|
+
try {
|
|
236
|
+
fs.closeSync(fd);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
recoveryFailure ??= error;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
captureFd = null;
|
|
242
|
+
}
|
|
243
|
+
if (recoveryFailure) {
|
|
244
|
+
captureResult = {
|
|
245
|
+
status: 'unverified',
|
|
246
|
+
reason: integrityFailure,
|
|
247
|
+
recoveryError: recoveryFailure.message,
|
|
248
|
+
path: path.basename(recovery),
|
|
249
|
+
};
|
|
250
|
+
reportCapture();
|
|
251
|
+
fail(
|
|
252
|
+
'environment-configuration',
|
|
253
|
+
`${integrityFailure}; recovery receipt could not be preserved at ${recovery}: ${recoveryFailure.message}`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
captureResult = {
|
|
257
|
+
status: 'rejected',
|
|
258
|
+
path: path.basename(recovery),
|
|
259
|
+
sha256: `sha256:${createHash('sha256').update(recoveryBytes).digest('hex')}`,
|
|
260
|
+
};
|
|
261
|
+
reportCapture();
|
|
262
|
+
fail('environment-configuration', `${integrityFailure}; source evidence preserved at ${recovery}`);
|
|
263
|
+
}
|
|
264
|
+
if (options.designPath) {
|
|
265
|
+
try {
|
|
266
|
+
const text = designText(process.cwd(), options.designPath);
|
|
267
|
+
source.artifactSha256 = text === null ? null : `sha256:${createHash('sha256').update(text).digest('hex')}`;
|
|
268
|
+
} catch {
|
|
269
|
+
source.artifactSha256 = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
captureBytes = Buffer.from(`${JSON.stringify(source, null, 2)}\n`);
|
|
273
|
+
fs.ftruncateSync(captureFd, 0);
|
|
274
|
+
fs.writeSync(captureFd, captureBytes, 0, captureBytes.length, 0);
|
|
275
|
+
fs.fsyncSync(captureFd);
|
|
276
|
+
captureResult = {
|
|
277
|
+
status: 'verified',
|
|
278
|
+
path: path.basename(captureFile),
|
|
279
|
+
sha256: `sha256:${createHash('sha256').update(captureBytes).digest('hex')}`,
|
|
280
|
+
};
|
|
281
|
+
};
|
|
282
|
+
const captureDoubleTelemetry = (stderr) => {
|
|
283
|
+
if (!source || !options.captureTestDouble || options.agent !== 'custom') return;
|
|
284
|
+
const line = stderr.split('\n').find((line) => line.startsWith('TEA_EXPERIMENT_TEST_TELEMETRY:'));
|
|
285
|
+
if (line) {
|
|
286
|
+
try {
|
|
287
|
+
const telemetry = JSON.parse(line.slice('TEA_EXPERIMENT_TEST_TELEMETRY:'.length));
|
|
288
|
+
source.observed = telemetry.observed ?? source.observed;
|
|
289
|
+
source.usage = telemetry.usage ?? null;
|
|
290
|
+
source.cost = telemetry.cost ?? null;
|
|
291
|
+
} catch (error) {
|
|
292
|
+
(source.telemetryErrors ??= []).push(`test-double telemetry unavailable: ${error.message}`);
|
|
293
|
+
source.observed = { resolvedModel: null, appliedEffort: null };
|
|
294
|
+
source.usage = null;
|
|
295
|
+
source.cost = null;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
if (options.capturePath) {
|
|
300
|
+
const relative = path.normalize(options.capturePath);
|
|
301
|
+
if (path.isAbsolute(relative) || relative === '..' || relative.startsWith(`..${path.sep}`))
|
|
302
|
+
fail('usage', '--capture-path must stay inside the workspace');
|
|
303
|
+
captureFile = path.join(process.cwd(), relative);
|
|
304
|
+
if (fs.existsSync(captureFile)) fail('usage', '--capture-path already exists');
|
|
305
|
+
let captureParent;
|
|
306
|
+
let workspaceRoot;
|
|
307
|
+
try {
|
|
308
|
+
captureParent = fs.realpathSync(path.dirname(captureFile));
|
|
309
|
+
workspaceRoot = fs.realpathSync(process.cwd());
|
|
310
|
+
} catch (error) {
|
|
311
|
+
fail('usage', `--capture-path parent cannot be resolved: ${error.message}`);
|
|
312
|
+
}
|
|
313
|
+
if (captureParent !== workspaceRoot) fail('usage', '--capture-path must be directly inside the workspace');
|
|
314
|
+
let requestedModel;
|
|
315
|
+
try {
|
|
316
|
+
requestedModel = options.captureTestDouble ? (options.model ?? null) : resolveModel(options.agent, options.model, options.agentArg);
|
|
317
|
+
} catch (error) {
|
|
318
|
+
fail(classOfAgentError(error), error.message);
|
|
319
|
+
}
|
|
320
|
+
try {
|
|
321
|
+
captureFd = fs.openSync(captureFile, 'wx+', 0o600);
|
|
322
|
+
captureIdentity = fs.fstatSync(captureFd);
|
|
323
|
+
source = {
|
|
324
|
+
kind: 'test-design-source-capture',
|
|
325
|
+
label: options.captureTestDouble ? 'deterministic-test-double' : 'native',
|
|
326
|
+
workspaceId: randomUUID(),
|
|
327
|
+
workspace: process.cwd(),
|
|
328
|
+
requested: {
|
|
329
|
+
agent: options.agent,
|
|
330
|
+
model: requestedModel,
|
|
331
|
+
},
|
|
332
|
+
inputSha256: `sha256:${createHash('sha256').update(prompt).digest('hex')}`,
|
|
333
|
+
startedAt: new Date().toISOString(),
|
|
334
|
+
endedAt: null,
|
|
335
|
+
status: 'dispatched',
|
|
336
|
+
observed: { resolvedModel: null, appliedEffort: null },
|
|
337
|
+
usage: null,
|
|
338
|
+
cost: null,
|
|
339
|
+
};
|
|
340
|
+
saveCapture();
|
|
341
|
+
} catch (error) {
|
|
342
|
+
let message = error.message;
|
|
343
|
+
if (captureFd !== null) {
|
|
344
|
+
try {
|
|
345
|
+
fs.closeSync(captureFd);
|
|
346
|
+
} catch (closeError) {
|
|
347
|
+
message += `; capture close failed: ${closeError.message}`;
|
|
348
|
+
}
|
|
349
|
+
captureFd = null;
|
|
350
|
+
}
|
|
351
|
+
fail('environment-configuration', `could not initialize source capture: ${message}`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
171
354
|
try {
|
|
172
|
-
|
|
355
|
+
const answer = runAgent(prompt, {
|
|
173
356
|
agent: options.agent,
|
|
174
357
|
agentCommand: options.agentCmd,
|
|
175
358
|
agentArgs: options.agentArg,
|
|
176
359
|
envPass: options.envPass,
|
|
177
|
-
model: options.model,
|
|
360
|
+
model: options.captureTestDouble && options.agent === 'custom' ? undefined : options.model,
|
|
178
361
|
timeout,
|
|
179
362
|
cwd: process.cwd(),
|
|
180
363
|
capabilities: RUNNER_CAPABILITIES,
|
|
181
|
-
|
|
364
|
+
usageReport: Boolean(source) && options.agent !== 'custom',
|
|
365
|
+
captureTelemetry: Boolean(source) && options.agent !== 'custom',
|
|
366
|
+
});
|
|
367
|
+
stdout = answer.stdout;
|
|
368
|
+
if (source) {
|
|
369
|
+
source.observed = { resolvedModel: answer.telemetry?.resolvedModel ?? null, appliedEffort: answer.telemetry?.appliedEffort ?? null };
|
|
370
|
+
source.providerStdout = answer.sourceStdout ?? answer.stdout;
|
|
371
|
+
source.providerStderr = answer.sourceStderr ?? answer.stderr;
|
|
372
|
+
source.usage = answer.telemetry?.usage ?? null;
|
|
373
|
+
source.telemetryErrors = answer.telemetry?.errors ?? [];
|
|
374
|
+
const usd = source.usage?.costUsd;
|
|
375
|
+
if (usd !== undefined) source.cost = { currency: 'USD', components: { target: Number(usd), tools: null } };
|
|
376
|
+
captureDoubleTelemetry(answer.stderr);
|
|
377
|
+
source.status = 'completed';
|
|
378
|
+
source.processOutcome = { status: 'completed', exitCode: 0 };
|
|
379
|
+
source.endedAt = new Date().toISOString();
|
|
380
|
+
saveCapture();
|
|
381
|
+
}
|
|
182
382
|
} catch (error) {
|
|
383
|
+
if (source) {
|
|
384
|
+
source.providerStdout = error.stdout ?? '';
|
|
385
|
+
source.providerStderr = error.stderr ?? '';
|
|
386
|
+
captureDoubleTelemetry(error.stderr ?? '');
|
|
387
|
+
if (options.agent !== 'custom') {
|
|
388
|
+
const parsed = agentCaptureTelemetry(options.agent, error.stdout ?? '', error.stderr ?? '');
|
|
389
|
+
source.usage = parsed.usage;
|
|
390
|
+
source.observed.resolvedModel = parsed.resolvedModel;
|
|
391
|
+
source.telemetryErrors = parsed.errors;
|
|
392
|
+
source.providerStdout = error.stdout ?? '';
|
|
393
|
+
source.providerStderr = error.stderr ?? '';
|
|
394
|
+
if (parsed.usage?.costUsd !== undefined)
|
|
395
|
+
source.cost = { currency: 'USD', components: { target: Number(parsed.usage.costUsd), tools: null } };
|
|
396
|
+
}
|
|
397
|
+
source.status = /timed out/.test(error.message) ? 'timeout' : /signal SIG(?:INT|TERM)/.test(error.message) ? 'canceled' : 'failed';
|
|
398
|
+
source.processOutcome = { status: source.status, exitCode: null };
|
|
399
|
+
source.endedAt = new Date().toISOString();
|
|
400
|
+
source.error = { code: error.code ?? null, message: error.message };
|
|
401
|
+
source.stdout = error.stdout ?? '';
|
|
402
|
+
source.stderr = error.stderr ?? '';
|
|
403
|
+
saveCapture();
|
|
404
|
+
}
|
|
405
|
+
reportCapture();
|
|
183
406
|
fail(classOfAgentError(error), error.message);
|
|
184
407
|
}
|
|
185
|
-
|
|
186
408
|
if (options.designPath) {
|
|
187
409
|
try {
|
|
188
|
-
const projection = projectScoredRisks(process.cwd(), options.designPath);
|
|
189
|
-
if (projection)
|
|
410
|
+
const projection = projectScoredRisks(process.cwd(), options.designPath, { requireDeliverable: Boolean(source) });
|
|
411
|
+
if (projection)
|
|
412
|
+
process.stdout.write(`${JSON.stringify(captureResult ? { ...projection, sourceCapture: captureResult } : projection)}\n`);
|
|
413
|
+
else if (source)
|
|
414
|
+
throw Object.assign(new Error('the native design deliverable is absent'), { failureClass: 'environment-missing-artifact' });
|
|
190
415
|
} catch (error) {
|
|
416
|
+
if (source) {
|
|
417
|
+
source.agentStatus = source.status;
|
|
418
|
+
source.status = 'failed';
|
|
419
|
+
source.failureClass = error.failureClass ?? 'environment-parser';
|
|
420
|
+
source.error = { code: 'RUNNER_PROJECTION_FAILED', message: error.message };
|
|
421
|
+
saveCapture();
|
|
422
|
+
}
|
|
423
|
+
reportCapture();
|
|
191
424
|
fail('environment-parser', `could not project scored risks: ${error.message}`);
|
|
192
425
|
}
|
|
193
|
-
}
|
|
426
|
+
} else reportCapture();
|
|
427
|
+
if (captureFd !== null) fs.closeSync(captureFd);
|
|
194
428
|
|
|
195
429
|
if (stdout.length > 0) process.stderr.write(stdout.endsWith('\n') ? stdout : `${stdout}\n`);
|
|
196
430
|
}
|