bmad-method-test-architecture-enterprise 2.0.2 → 2.0.3
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 -3
- package/README.md +3 -3
- package/cli/evaluate.js +25 -2
- package/cli/lib/evaluate/confinement-audit.js +2 -2
- package/cli/lib/evaluate/confinement.js +16 -25
- package/cli/lib/evaluate/isolation-allowlist.js +6 -4
- package/cli/lib/evaluate/preflight.js +80 -3
- package/cli/lib/evaluate/run.js +46 -5
- package/cli/lib/evaluate/status.js +184 -0
- package/package.json +3 -2
- package/skills/bmad-tea/SKILL.md +2 -2
- package/skills/bmad-tea/customize.toml +4 -4
- package/skills/bmad-teach-me-testing/steps-c/step-04-session-01.md +2 -2
- package/skills/bmad-teach-me-testing/steps-c/step-04-session-03.md +1 -1
- package/skills/bmad-teach-me-testing/steps-c/step-04-session-05.md +2 -2
- package/skills/bmad-testarch-automate/red/instructions.md +1 -2
- package/skills/bmad-testarch-automate/resources/test-generation-routing.md +17 -21
- package/skills/bmad-testarch-evaluate/SKILL.md +5 -3
- package/skills/bmad-testarch-evaluate/assets/inspection-record.md +1 -1
- package/skills/bmad-testarch-evaluate/references/authoring-routing.md +31 -0
- package/skills/bmad-testarch-evaluate/references/ci.md +5 -5
- package/skills/bmad-testarch-evaluate/references/run.md +19 -0
- package/skills/bmad-testarch-framework/SKILL.md +2 -2
- package/skills/bmad-testarch-framework/checklist.md +4 -4
- package/skills/bmad-testarch-framework/ci/checklist.md +2 -2
- package/skills/bmad-testarch-framework/ci/instructions.md +1 -1
- package/skills/bmad-testarch-framework/resources/setup-routing.md +4 -4
- package/skills/bmad-testarch-test-design/checklist.md +6 -6
- package/skills/bmad-testarch-test-design/resources/test-design-epic-3.example.md +1 -1
- package/skills/bmad-testarch-test-design/test-design-template.md +1 -1
- package/skills/bmad-testarch-trace/checklist.md +2 -2
- package/skills/bmad-testarch-trace/steps-c/step-04-analyze-gaps.md +1 -1
- package/skills/bmad-testarch-trace/trace-template.md +1 -1
- package/skills/bmod-tea/bmod.toml +2 -4
- package/skills/bmod-tea/help/help.md +1 -3
- package/skills/bmod-tea/help/integrations.md +1 -1
- package/skills/bmod-tea/knowledge/burn-in.md +1 -1
- package/skills/bmod-tea/migration-1.toml +1 -1
- package/skills/bmad-testarch-atdd/SKILL.md +0 -16
- package/skills/bmad-testarch-atdd/bmod.toml +0 -4
- package/skills/bmad-testarch-atdd/customize.toml +0 -38
- package/skills/bmad-testarch-atdd/workflow.yaml +0 -40
- package/skills/bmad-testarch-ci/SKILL.md +0 -14
- package/skills/bmad-testarch-ci/bmod.toml +0 -4
- package/skills/bmad-testarch-ci/customize.toml +0 -43
- package/skills/bmad-testarch-ci/workflow.yaml +0 -44
|
@@ -31,16 +31,14 @@
|
|
|
31
31
|
"name": "bmad-method-test-architecture-enterprise",
|
|
32
32
|
"source": "./",
|
|
33
33
|
"description": "Master Test Architect module for quality strategy, test generation in red and expand modes with automatic run-and-heal, combined framework and CI setup, CI/CD quality gates, and structured testing education. The CI and ATDD commands remain available with existing customizations. Part of the BMad Method ecosystem.",
|
|
34
|
-
"version": "2.0.
|
|
34
|
+
"version": "2.0.3",
|
|
35
35
|
"author": {
|
|
36
36
|
"name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
|
|
37
37
|
},
|
|
38
38
|
"skills": [
|
|
39
39
|
"./skills/bmod-tea",
|
|
40
40
|
"./skills/bmad-tea",
|
|
41
|
-
"./skills/bmad-testarch-atdd",
|
|
42
41
|
"./skills/bmad-testarch-automate",
|
|
43
|
-
"./skills/bmad-testarch-ci",
|
|
44
42
|
"./skills/bmad-testarch-evaluate",
|
|
45
43
|
"./skills/bmad-testarch-framework",
|
|
46
44
|
"./skills/bmad-testarch-nfr",
|
package/README.md
CHANGED
|
@@ -50,7 +50,7 @@ npx skills add https://github.com/bmad-code-org/bmad-method-test-architecture-en
|
|
|
50
50
|
|
|
51
51
|
## Start with one skill
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
Eight commands start the eight workflows: Framework includes CI setup; Automate includes red and expand modes.
|
|
54
54
|
|
|
55
55
|
In Claude Code, Cursor, and Windsurf, type `/bmad-testarch-test-design` in chat.
|
|
56
56
|
In Codex, type `$bmad-testarch-test-design`.
|
|
@@ -73,10 +73,10 @@ Evaluate:
|
|
|
73
73
|
|
|
74
74
|
Framework and CI setup share one skill, `bmad-testarch-framework`.
|
|
75
75
|
Ask for framework only, CI only, or both; TEA infers the scope from your prompt and asks "Do you want CI too?" once when CI scope is unclear in an interactive session. An unattended request with unclear scope runs framework setup only and states that CI was excluded.
|
|
76
|
-
`/bmad-testarch-
|
|
76
|
+
Request CI setup through `/bmad-testarch-framework` or the agent's `CI` menu code. Existing CI customizations remain usable.
|
|
77
77
|
Create, Resume, Validate, and Edit remain available for the selected setup scope.
|
|
78
78
|
|
|
79
|
-
Automation uses `red` mode before implementation and `expand` mode for existing code.
|
|
79
|
+
Automation uses `red` mode before implementation and `expand` mode for existing code. Request red mode through `/bmad-testarch-automate` or the agent's `AT` menu code; the direct command defaults to expand. Existing customization files and saved progress remain usable. Create runs execute the generated tests and repair test issues for up to three rounds. Red confirms the intended missing behavior; expand aims for passing coverage and reports product defects.
|
|
80
80
|
|
|
81
81
|
For Codex, replace the leading `/` in the table with `$`.
|
|
82
82
|
The agent menu also accepts `GATE` to route you through test review, NFR evidence audit, and trace Phase 2.
|
package/cli/evaluate.js
CHANGED
|
@@ -29,6 +29,8 @@
|
|
|
29
29
|
* compare a scored run's evidence with baseline/ through eval-quality's
|
|
30
30
|
* compareDominance (compared, first-run or refused, every one exit 0), or
|
|
31
31
|
* with --accept replace baseline/ with a byte-identical snapshot of the run
|
|
32
|
+
* tea-evaluate status --evaluation <path> [--run <invocationId>] [--follow]
|
|
33
|
+
* show the latest run's progress, or follow it until it ends
|
|
32
34
|
* tea-evaluate ci --evaluation <path> --tier <pr|merge|scheduled|release>
|
|
33
35
|
* run exactly the checks ci/evaluation-ci-plan.json places on the tier, in
|
|
34
36
|
* plan order, each one's exit, stdout and stderr kept under
|
|
@@ -130,6 +132,7 @@ const { runRunCommand } = require('./lib/evaluate/run');
|
|
|
130
132
|
const { runCompareCommand } = require('./lib/evaluate/compare');
|
|
131
133
|
const { runScoreCommand } = require('./lib/evaluate/score');
|
|
132
134
|
const { runCiCommand } = require('./lib/evaluate/ci');
|
|
135
|
+
const { runStatusCommand } = require('./lib/evaluate/status');
|
|
133
136
|
const { TIERS } = require('./lib/evaluate/ci-plan');
|
|
134
137
|
const { escapeUnprintable, findingLine } = require('./lib/evaluate/finding-lines');
|
|
135
138
|
const { calibrationInputsOf } = require('./lib/evaluate/records-calibration');
|
|
@@ -265,6 +268,17 @@ function compareCommand(options) {
|
|
|
265
268
|
return runDriven('compare', runCompareCommand, options, { run: options.run, accept: options.accept === true });
|
|
266
269
|
}
|
|
267
270
|
|
|
271
|
+
async function statusCommand(options) {
|
|
272
|
+
const folder = folderFrom(options);
|
|
273
|
+
const result = await runStatusCommand(folder, {
|
|
274
|
+
run: options.run,
|
|
275
|
+
follow: options.follow === true,
|
|
276
|
+
emit: (line) => process.stdout.write(`${NAME} status: ${escapeUnprintable(line)}\n`),
|
|
277
|
+
});
|
|
278
|
+
if (result.exitCode !== 0) process.stderr.write(`${NAME} status: ${escapeUnprintable(result.message)}\n`);
|
|
279
|
+
return result.exitCode;
|
|
280
|
+
}
|
|
281
|
+
|
|
268
282
|
async function ciCommand(options) {
|
|
269
283
|
if (!TIERS.includes(options.tier)) {
|
|
270
284
|
throw new UsageError(
|
|
@@ -278,7 +292,7 @@ function buildProgram(run) {
|
|
|
278
292
|
const program = new Command();
|
|
279
293
|
program
|
|
280
294
|
.name(NAME)
|
|
281
|
-
.description('Validate, digest, preflight, run, score, compare and run the CI tiers of an Evaluate evaluation folder.')
|
|
295
|
+
.description('Validate, digest, preflight, run, score, compare, report status and run the CI tiers of an Evaluate evaluation folder.')
|
|
282
296
|
.showHelpAfterError()
|
|
283
297
|
.exitOverride()
|
|
284
298
|
.configureOutput({ writeErr: (text) => process.stderr.write(text) });
|
|
@@ -338,6 +352,13 @@ function buildProgram(run) {
|
|
|
338
352
|
.option('--run <invocationId>', 'the run to compare or accept; the most recent run when omitted')
|
|
339
353
|
.option('--accept', 'replace baseline/ with a byte-identical snapshot of the run; refused for a dirty run')
|
|
340
354
|
.action((options) => run(compareCommand, options));
|
|
355
|
+
program
|
|
356
|
+
.command('status')
|
|
357
|
+
.description("Show the newest run's progress, or follow it until completion; --run selects a specific invocation.")
|
|
358
|
+
.option('--evaluation <path>', 'the evaluation folder, or its evaluation.json')
|
|
359
|
+
.option('--run <invocationId>', 'the run to inspect; the most recent run when omitted')
|
|
360
|
+
.option('--follow', 'print progress changes and periodic heartbeats until the run ends')
|
|
361
|
+
.action((options) => run(statusCommand, options));
|
|
341
362
|
program
|
|
342
363
|
.command('ci')
|
|
343
364
|
.description(
|
|
@@ -377,7 +398,9 @@ async function main(argv) {
|
|
|
377
398
|
return await pending;
|
|
378
399
|
} catch (error) {
|
|
379
400
|
if (error instanceof UsageError) {
|
|
380
|
-
process.stderr.write(
|
|
401
|
+
process.stderr.write(
|
|
402
|
+
`${NAME}: ${error.message}\nUsage: ${NAME} <check|digest|preflight|run|score|compare|status|ci> --evaluation <path>\n`,
|
|
403
|
+
);
|
|
381
404
|
return EXIT_CODES.usage;
|
|
382
405
|
}
|
|
383
406
|
if (error instanceof CorpusIndexError) {
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
* runtime reads. `confinement.js` holds the boundary and decides what each
|
|
7
7
|
* sandbox grants; this module only observes.
|
|
8
8
|
*
|
|
9
|
-
* Seatbelt the target's profile
|
|
10
|
-
*
|
|
9
|
+
* Seatbelt the target's profile refuses ungranted `file-read-data` and
|
|
10
|
+
* reports those refusals, tagging each file rule it
|
|
11
11
|
* holds with a random token of the sandbox (`with message`).
|
|
12
12
|
* The kernel logs a report as `Sandbox: <name>(<pid>) allow|
|
|
13
13
|
* deny(1) <operation> <real path>` with the token on the next
|
|
@@ -147,7 +147,7 @@
|
|
|
147
147
|
* as the isolation manifest's `observedMounts`, which
|
|
148
148
|
* eval-quality holds against the allowed mounts and records as
|
|
149
149
|
* an isolation violation (Story 1.60). Seatbelt reports each
|
|
150
|
-
* read its profile
|
|
150
|
+
* ungranted read its profile refuses and
|
|
151
151
|
* tags every file rule with a token of the sandbox (`with
|
|
152
152
|
* message`), and a `/usr/bin/log stream` child the runtime owns
|
|
153
153
|
* writes the kernel's reports of that token to a file beneath
|
|
@@ -463,15 +463,17 @@ function seatbeltLayerProfile(evaluationFolder, gitDirectory = null, hooksDirect
|
|
|
463
463
|
* A `connect()` to a path-based unix socket is refused anywhere but the workspace, the private directories, the home and the two system services in `SEATBELT_SYSTEM_SOCKETS` (Story 1.87).
|
|
464
464
|
* The rule names a path, so it holds for a socket bound after the call started.
|
|
465
465
|
*
|
|
466
|
-
*
|
|
466
|
+
* Read grants restrict file data to the workspace, system roots and explicitly declared dependencies.
|
|
467
|
+
* With `audit` (`{ token }`), the kernel reports refusals with the sandbox token.
|
|
467
468
|
* Every file rule that denies carries the sandbox's token (`with message`).
|
|
468
|
-
*
|
|
469
|
+
* Every ungranted `file-read-data` is denied even when its diagnostic is lost by the unified log.
|
|
469
470
|
* That rule is placed before every rule that follows it, so a later grant (the home, a call directory, the git directory's own entry) overrides it.
|
|
470
|
-
*
|
|
471
|
+
* The same read boundary applies when a qualification port carries no observer.
|
|
471
472
|
*/
|
|
472
473
|
function seatbeltTargetProfile({
|
|
473
474
|
workspace,
|
|
474
475
|
writable,
|
|
476
|
+
readable = [],
|
|
475
477
|
evaluationFolder,
|
|
476
478
|
git = null,
|
|
477
479
|
privateRoot = null,
|
|
@@ -485,11 +487,8 @@ function seatbeltTargetProfile({
|
|
|
485
487
|
const socketRules = seatbeltSocketRules(allowed);
|
|
486
488
|
const socketRoutes = (candidates) => candidates.map((entry) => `(remote unix-socket ${entry})`).join('\n ');
|
|
487
489
|
const tagged = audit === null ? '' : ` (with message "${audit.token}")`;
|
|
488
|
-
//
|
|
489
|
-
|
|
490
|
-
// therefore names `file-read-data` in each rule that governs reads, which keeps the textual order (the last matching rule
|
|
491
|
-
// wins) the unaudited profile relies on.
|
|
492
|
-
const reads = audit === null ? 'file-read*' : 'file-read-data file-read*';
|
|
490
|
+
// Specific data-read grants outrank wildcard rules in Seatbelt. Every protected denial and re-grant must name the data operation.
|
|
491
|
+
const reads = 'file-read-data file-read*';
|
|
493
492
|
const subpaths = (candidate) => spellings(candidate).map((entry) => `(subpath "${assertProfileSafePath(entry, refuseUnsafePath)}")`);
|
|
494
493
|
// A denial of reads and writes of `paths`; an audited profile names `file-read-data` and tags it (see `reads`).
|
|
495
494
|
const denials = (paths) => [`(deny ${reads} file-write*\n ${paths.join('\n ')}${tagged})`];
|
|
@@ -542,18 +541,12 @@ function seatbeltTargetProfile({
|
|
|
542
541
|
`(allow file-read-metadata\n ${reachableAncestors.flatMap(literals).join('\n ')})`,
|
|
543
542
|
`(allow network-outbound\n ${socketRoutes(reachable.flatMap(subpaths))})`,
|
|
544
543
|
];
|
|
545
|
-
//
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
...EXACT_GRANTS.map((entry) => `(require-not (literal "${entry}"))`),
|
|
552
|
-
...audit.exempt
|
|
553
|
-
.flatMap(spellings)
|
|
554
|
-
.map((entry) => `(require-not (subpath "${assertProfileSafePath(entry, refuseUnsafePath)}"))`),
|
|
555
|
-
].join('\n ')})\n (with report)${tagged})`,
|
|
556
|
-
];
|
|
544
|
+
// Kernel enforcement is independent of the lossy log: ungranted data reads are refused even if their diagnostic is dropped.
|
|
545
|
+
// Keep the specific operation in later denials/re-grants so Seatbelt's wildcard precedence cannot reopen protected paths.
|
|
546
|
+
const reportRule = [
|
|
547
|
+
`(deny file-read-data${tagged})`,
|
|
548
|
+
`(allow file-read-data\n ${[...EXACT_GRANTS.map((entry) => `(literal "${entry}")`), ...readable.flatMap(subpaths)].join('\n ')})`,
|
|
549
|
+
];
|
|
557
550
|
// A target's git tries to write the worktree's own entry and the private repository (the index lock of a `git status`), which
|
|
558
551
|
// the profile refuses and the target's git expects to fail; the refusal carries no token, so it is not an observed mount.
|
|
559
552
|
const quiet =
|
|
@@ -1457,15 +1450,13 @@ function targetSandbox({
|
|
|
1457
1450
|
const profile = seatbeltTargetProfile({
|
|
1458
1451
|
workspace,
|
|
1459
1452
|
writable: grants,
|
|
1453
|
+
readable: readRoots([...grants, ...readable]),
|
|
1460
1454
|
evaluationFolder,
|
|
1461
1455
|
git,
|
|
1462
1456
|
privateRoot,
|
|
1463
1457
|
rootHome,
|
|
1464
1458
|
rootGrants: callDirectories,
|
|
1465
|
-
audit:
|
|
1466
|
-
observer === null
|
|
1467
|
-
? null
|
|
1468
|
-
: { token: observer.token, exempt: readRoots([...grants, ...readable]), quiet: [...ownGitEntries(), ...linked] },
|
|
1459
|
+
audit: observer === null ? null : { token: observer.token, quiet: [...ownGitEntries(), ...linked] },
|
|
1469
1460
|
});
|
|
1470
1461
|
// `env` runs outside the sandbox and after the watchdog hop that starts every target, which is where Node adds the host's `NODE_V8_COVERAGE` to the environment; the sandbox then starts the target without it, as Bubblewrap's `--unsetenv` does.
|
|
1471
1462
|
return {
|
|
@@ -115,7 +115,7 @@ function chanceMountNotes(legs, folder) {
|
|
|
115
115
|
* @param {object} options
|
|
116
116
|
* @param {string[]} options.mounts the offending paths, as the audit reported them
|
|
117
117
|
* @param {string} options.folder the evaluation folder, for the neutral form of each path
|
|
118
|
-
* @param {'legs'|'trials'} options.who
|
|
118
|
+
* @param {'legs'|'denied-legs'|'trials'} options.who
|
|
119
119
|
* @returns {string|null}
|
|
120
120
|
*/
|
|
121
121
|
function mountRefusal({ mounts, folder, who }) {
|
|
@@ -124,9 +124,11 @@ function mountRefusal({ mounts, folder, who }) {
|
|
|
124
124
|
const named = mounts.slice(0, NAMED_PATHS).map((mount) => `mount outside allowlist: ${neutral(mount)}`);
|
|
125
125
|
const list = `${named.join('; ')}${mounts.length > named.length ? `; and ${mounts.length - named.length} more` : ''}`;
|
|
126
126
|
const finding =
|
|
127
|
-
who === 'legs'
|
|
128
|
-
? `isolation manifest violation: every preflight leg
|
|
129
|
-
:
|
|
127
|
+
who === 'denied-legs'
|
|
128
|
+
? `isolation manifest violation: every preflight leg attempted access to ${mounts.length} path(s) outside the allowlist (${list}); the kernel refused those accesses (exit 3).`
|
|
129
|
+
: who === 'legs'
|
|
130
|
+
? `isolation manifest violation: every preflight leg opened ${mounts.length} path(s) outside the allowlist (${list}), so every trial will too, and \`score\` refuses a trial that does (exit 3).`
|
|
131
|
+
: `isolation manifest violation: the trials opened ${mounts.length} path(s) outside the allowlist, so \`score\` would exit 3 (${list}).`;
|
|
130
132
|
return [
|
|
131
133
|
finding,
|
|
132
134
|
'If they are the files of a target that launches from outside its workspace, use one of two setups: make the registry `target` a path inside `launch.root` (for example `node_modules/.bin/tea-skill-runner` over a copy workspace, or a git workspace with `workspace.provision`), or keep the bare name and list the directories it runs from in `systemPaths`, with the bin directory that holds its link on `PATH`.',
|
|
@@ -513,6 +513,8 @@ function runPreflightCommand(folder, { partition, requirePartition = false, ...o
|
|
|
513
513
|
* @param {Set<string>|null} [options.selectedProbeIds] the probes the partition selects; every probe when null
|
|
514
514
|
* @param {NodeJS.ProcessEnv} [options.env]
|
|
515
515
|
* @param {(line: string) => void} [options.log]
|
|
516
|
+
* @param {number} [options.progressIntervalMs] heartbeat interval, for tests and long-running runs
|
|
517
|
+
* @param {number} [options.progressStartedAt] invocation start time, before setup
|
|
516
518
|
* @param {(context: object) => PreflightOutcome|null|Promise<PreflightOutcome|null>} [options.prepare]
|
|
517
519
|
* @param {(context: object) => Promise<PreflightOutcome>} [options.afterVerdict]
|
|
518
520
|
* @returns {Promise<PreflightOutcome>}
|
|
@@ -520,10 +522,11 @@ function runPreflightCommand(folder, { partition, requirePartition = false, ...o
|
|
|
520
522
|
async function runPipeline(folder, options) {
|
|
521
523
|
// The invocation's run directory and run.json, held in memory, so its end is
|
|
522
524
|
// recorded from what the runtime knows, never from a file read back.
|
|
523
|
-
const state = { writer: null, run: null, sealed: false, retractUnlessSealed: [] };
|
|
525
|
+
const state = { writer: null, run: null, sealed: false, retractUnlessSealed: [], progressTimer: null, finishProgress: null };
|
|
524
526
|
try {
|
|
525
527
|
return await recordEnd(await pipeline(folder, options, state), state);
|
|
526
528
|
} finally {
|
|
529
|
+
if (state.progressTimer !== null) clearInterval(state.progressTimer);
|
|
527
530
|
// The directories the run made stay held open until here, so none of
|
|
528
531
|
// their inode numbers can pass to a directory the target makes.
|
|
529
532
|
state.writer?.close();
|
|
@@ -543,6 +546,7 @@ function recordEnd(outcome, state) {
|
|
|
543
546
|
const failed = [];
|
|
544
547
|
for (const [what, step] of [
|
|
545
548
|
...state.retractUnlessSealed.map((file) => [`remove ${file}`, () => state.writer.remove(file)]),
|
|
549
|
+
['record final progress', () => state.finishProgress?.('stopped')],
|
|
546
550
|
['record this end in run.json', () => state.writer.replaceJson('run.json', state.run)],
|
|
547
551
|
]) {
|
|
548
552
|
try {
|
|
@@ -574,6 +578,8 @@ async function pipeline(
|
|
|
574
578
|
selectedProbeIds = null,
|
|
575
579
|
partition,
|
|
576
580
|
seed,
|
|
581
|
+
progressIntervalMs = 30_000,
|
|
582
|
+
progressStartedAt,
|
|
577
583
|
},
|
|
578
584
|
state,
|
|
579
585
|
) {
|
|
@@ -658,6 +664,7 @@ async function pipeline(
|
|
|
658
664
|
}
|
|
659
665
|
};
|
|
660
666
|
const onSignal = (name) => {
|
|
667
|
+
if (state.progressTimer !== null) clearInterval(state.progressTimer);
|
|
661
668
|
removeScratch();
|
|
662
669
|
if (journal) {
|
|
663
670
|
try {
|
|
@@ -671,6 +678,7 @@ async function pipeline(
|
|
|
671
678
|
for (const file of [...retractOnSignal, ...state.retractUnlessSealed]) state.writer.remove(file);
|
|
672
679
|
state.run.outcome = { stage: 'signal', exitCode: null, signal: name, message: `stopped by ${name} before it finished` };
|
|
673
680
|
if (state.run.command === 'run') state.run.completed = false;
|
|
681
|
+
state.finishProgress?.('stopped');
|
|
674
682
|
state.writer.replaceJson('run.json', state.run);
|
|
675
683
|
} catch {
|
|
676
684
|
// The signal still ends the process; run.json keeps its last state.
|
|
@@ -735,6 +743,11 @@ async function pipeline(
|
|
|
735
743
|
log(
|
|
736
744
|
`pristine workspace, ${pristine.kind === 'git-worktree' ? `a detached worktree at ${pristine.commit}` : `a temp copy${pristine.dirty ? ' of the working tree' : ''}`}: ${pristine.root}`,
|
|
737
745
|
);
|
|
746
|
+
if (pristine.kind === 'git-worktree' && sharedState) {
|
|
747
|
+
log(
|
|
748
|
+
'Git worktrees share repository refs, config and hooks. Keep Git writes in every worktree of this repository paused until this command ends, or use a dedicated clone for concurrent work.',
|
|
749
|
+
);
|
|
750
|
+
}
|
|
738
751
|
// The evaluation folder is read from the working tree (the workspaces
|
|
739
752
|
// leave it out), so uncommitted work in it reaches the run, which is then
|
|
740
753
|
// dirty: no committed state names what it measured.
|
|
@@ -766,6 +779,8 @@ async function pipeline(
|
|
|
766
779
|
runsDirectory,
|
|
767
780
|
invocationId,
|
|
768
781
|
seed: runSeed,
|
|
782
|
+
progressStartedAt,
|
|
783
|
+
progressIntervalMs,
|
|
769
784
|
retractOnSignal,
|
|
770
785
|
scratch,
|
|
771
786
|
state,
|
|
@@ -826,6 +841,8 @@ async function runInWorkspaces({
|
|
|
826
841
|
runsDirectory,
|
|
827
842
|
invocationId,
|
|
828
843
|
seed,
|
|
844
|
+
progressStartedAt,
|
|
845
|
+
progressIntervalMs,
|
|
829
846
|
retractOnSignal,
|
|
830
847
|
scratch,
|
|
831
848
|
state,
|
|
@@ -877,9 +894,65 @@ async function runInWorkspaces({
|
|
|
877
894
|
adopterTree: { repository: before.repository === null ? null : REPOSITORY, unchanged: null },
|
|
878
895
|
refused: [],
|
|
879
896
|
};
|
|
897
|
+
const progressStarted = progressStartedAt ?? Date.now();
|
|
898
|
+
const progressCreatedAt = Date.now();
|
|
899
|
+
const progressCreatedTick = globalThis.performance.now();
|
|
900
|
+
const setupBeforeRecordMs = Math.max(0, progressCreatedAt - progressStarted);
|
|
901
|
+
let phaseStartedTick = progressCreatedTick;
|
|
902
|
+
if (command === 'run') {
|
|
903
|
+
run.progress = {
|
|
904
|
+
phase: 'setup',
|
|
905
|
+
detail: 'Preparing the run',
|
|
906
|
+
startedAt: new Date(progressStarted).toISOString(),
|
|
907
|
+
updatedAt: new Date(progressCreatedAt).toISOString(),
|
|
908
|
+
phaseStartedAt: new Date(progressStarted).toISOString(),
|
|
909
|
+
phaseElapsedMs: setupBeforeRecordMs,
|
|
910
|
+
elapsedMs: setupBeforeRecordMs,
|
|
911
|
+
phaseDurationsMs: {},
|
|
912
|
+
completedTrials: 0,
|
|
913
|
+
totalTrials: null,
|
|
914
|
+
usage: null,
|
|
915
|
+
};
|
|
916
|
+
}
|
|
880
917
|
state.run = run;
|
|
881
918
|
const writeRun = () => writer.replaceJson('run.json', run);
|
|
882
919
|
writeRun();
|
|
920
|
+
const progress = (phase, fields = {}) => {
|
|
921
|
+
if (command !== 'run' || state.sealed) return;
|
|
922
|
+
const now = Date.now();
|
|
923
|
+
const tick = globalThis.performance.now();
|
|
924
|
+
const sinceCreated = Math.max(0, tick - progressCreatedTick);
|
|
925
|
+
if (phase !== run.progress.phase) {
|
|
926
|
+
const prior = run.progress.phase;
|
|
927
|
+
const priorDuration = Math.max(0, tick - phaseStartedTick) + (prior === 'setup' ? setupBeforeRecordMs : 0);
|
|
928
|
+
run.progress.phaseDurationsMs[prior] = (run.progress.phaseDurationsMs[prior] ?? 0) + priorDuration;
|
|
929
|
+
phaseStartedTick = tick;
|
|
930
|
+
run.progress.phase = phase;
|
|
931
|
+
run.progress.phaseStartedAt = new Date(now).toISOString();
|
|
932
|
+
}
|
|
933
|
+
Object.assign(run.progress, fields, {
|
|
934
|
+
updatedAt: new Date(now).toISOString(),
|
|
935
|
+
elapsedMs: setupBeforeRecordMs + sinceCreated,
|
|
936
|
+
phaseElapsedMs: Math.max(0, tick - phaseStartedTick) + (phase === 'setup' ? setupBeforeRecordMs : 0),
|
|
937
|
+
});
|
|
938
|
+
writeRun();
|
|
939
|
+
};
|
|
940
|
+
state.finishProgress = (phase) => progress(phase, { detail: phase === 'complete' ? 'Run sealed' : 'Run stopped' });
|
|
941
|
+
if (command === 'run') {
|
|
942
|
+
state.progressTimer = setInterval(() => {
|
|
943
|
+
try {
|
|
944
|
+
progress(run.progress.phase);
|
|
945
|
+
log(
|
|
946
|
+
`progress: ${run.progress.phase}; ${run.progress.completedTrials ?? '?'}/${run.progress.totalTrials ?? '?'} trials; ${Math.floor(run.progress.elapsedMs / 1000)}s elapsed`,
|
|
947
|
+
);
|
|
948
|
+
} catch (error) {
|
|
949
|
+
// A progress write is guarded like every other run-directory write. The main
|
|
950
|
+
// pipeline will detect the same changed directory at its next verification.
|
|
951
|
+
log(`progress could not be recorded: ${error.message}`);
|
|
952
|
+
}
|
|
953
|
+
}, progressIntervalMs);
|
|
954
|
+
state.progressTimer.unref?.();
|
|
955
|
+
}
|
|
883
956
|
|
|
884
957
|
// The run keeps its own copy of the contract this partition runs, so every stage below reads the
|
|
885
958
|
// same bytes and the verdict can be reproduced from the run directory alone.
|
|
@@ -951,6 +1024,7 @@ async function runInWorkspaces({
|
|
|
951
1024
|
}
|
|
952
1025
|
};
|
|
953
1026
|
|
|
1027
|
+
progress('qualification', { detail: 'Qualifying probes and clean controls' });
|
|
954
1028
|
const qualified = [];
|
|
955
1029
|
const policyPath = path.join(folder, POLICY_PATH);
|
|
956
1030
|
const policy = fs.existsSync(policyPath) ? readJson(policyPath) : null;
|
|
@@ -1192,6 +1266,7 @@ async function runInWorkspaces({
|
|
|
1192
1266
|
// Every leg has a port, and so a home, of its own.
|
|
1193
1267
|
resetHome() {},
|
|
1194
1268
|
};
|
|
1269
|
+
progress('preflight', { detail: 'Running preflight legs and verdict' });
|
|
1195
1270
|
try {
|
|
1196
1271
|
const recorder = recordingPort({
|
|
1197
1272
|
pristine: { label: 'pristine', cwd: workspaceDirectory(pristine), port: auditedLegs },
|
|
@@ -1286,11 +1361,12 @@ async function runInWorkspaces({
|
|
|
1286
1361
|
// A passed `preflight` is refused when every leg of the pristine workspace opened a path outside the allowlist: a target that lives
|
|
1287
1362
|
// outside its workspace loads its own files on every launch, so every trial opens them too, and `score` refuses a trial that does
|
|
1288
1363
|
// (exit 3). `run` judges the trials' own manifests, the set `score` judges, so it does not refuse on the legs.
|
|
1289
|
-
|
|
1364
|
+
// Seatbelt can refuse an undeclared runner before it produces a passing leg. Preserve the captured denial's path and setup guidance.
|
|
1365
|
+
if (afterVerdict === null && (verdict.exitCode === 0 || confinement.mode === 'seatbelt')) {
|
|
1290
1366
|
const refusal = mountRefusal({
|
|
1291
1367
|
mounts: mountsOfEveryLeg(legMounts, [`${pristine.kind} pristine`]),
|
|
1292
1368
|
folder,
|
|
1293
|
-
who: 'legs',
|
|
1369
|
+
who: confinement.mode === 'seatbelt' ? 'denied-legs' : 'legs',
|
|
1294
1370
|
});
|
|
1295
1371
|
if (refusal !== null) return outcome({ stage: 'leg', exitCode: 3, message: refusal });
|
|
1296
1372
|
}
|
|
@@ -1325,6 +1401,7 @@ async function runInWorkspaces({
|
|
|
1325
1401
|
writer,
|
|
1326
1402
|
run,
|
|
1327
1403
|
writeRun,
|
|
1404
|
+
progress,
|
|
1328
1405
|
treeUnchanged,
|
|
1329
1406
|
retractUnlessSealed: state.retractUnlessSealed,
|
|
1330
1407
|
markSealed: () => {
|
package/cli/lib/evaluate/run.js
CHANGED
|
@@ -236,11 +236,12 @@ function refusal({ folder, selectedProbeIds = null }) {
|
|
|
236
236
|
* (`before-state.js`) may fail its baseline, and the run is marked so it is never accepted as a baseline
|
|
237
237
|
* @param {NodeJS.ProcessEnv} [options.env]
|
|
238
238
|
* @param {(line: string) => void} [options.log]
|
|
239
|
+
* @param {number} [options.progressIntervalMs] heartbeat interval used by integration tests
|
|
239
240
|
* @returns {Promise<PreflightOutcome>}
|
|
240
241
|
*/
|
|
241
242
|
function runRunCommand(
|
|
242
243
|
folder,
|
|
243
|
-
{ fromWorkingTree = false, partition, trials, seed, beforeState = false, env = process.env, log = () => {} } = {},
|
|
244
|
+
{ fromWorkingTree = false, partition, trials, seed, beforeState = false, env = process.env, log = () => {}, progressIntervalMs } = {},
|
|
244
245
|
) {
|
|
245
246
|
if (trials !== undefined && !(Number.isInteger(trials) && trials >= 1))
|
|
246
247
|
return Promise.resolve(
|
|
@@ -260,7 +261,7 @@ function runRunCommand(
|
|
|
260
261
|
} catch {
|
|
261
262
|
// The pipeline's check reports malformed source files with authoring findings; it keeps the partition, so a development
|
|
262
263
|
// run still does not open the held-out plan.
|
|
263
|
-
return runPipeline(folder, { command: 'run', partition, fromWorkingTree, seed, env, log });
|
|
264
|
+
return runPipeline(folder, { command: 'run', partition, fromWorkingTree, seed, env, log, progressIntervalMs });
|
|
264
265
|
}
|
|
265
266
|
if (selection.refusal !== undefined) return Promise.resolve(new PreflightOutcome({ stage: 'check', ...selection.refusal }));
|
|
266
267
|
const { selectedProbeIds } = selection;
|
|
@@ -276,6 +277,8 @@ function runRunCommand(
|
|
|
276
277
|
seed,
|
|
277
278
|
env,
|
|
278
279
|
log,
|
|
280
|
+
progressIntervalMs,
|
|
281
|
+
progressStartedAt: started,
|
|
279
282
|
prepare: async (context) => {
|
|
280
283
|
const refused = refusal({ ...context, selectedProbeIds });
|
|
281
284
|
if (refused !== null) return refused;
|
|
@@ -1148,6 +1151,8 @@ async function runTrialSets(given) {
|
|
|
1148
1151
|
sealed,
|
|
1149
1152
|
} = context;
|
|
1150
1153
|
const { treeUnchanged, stop, log, snapshot } = context;
|
|
1154
|
+
const progress = context.progress ?? (() => {});
|
|
1155
|
+
progress('clean-control-qualification', { detail: 'Qualifying clean controls' });
|
|
1151
1156
|
|
|
1152
1157
|
const cleanControls = await qualifyCleanControls({ ...context, seed: run.seed });
|
|
1153
1158
|
// The gameability probes the shared pipeline qualified before the verdict.
|
|
@@ -1214,8 +1219,19 @@ async function runTrialSets(given) {
|
|
|
1214
1219
|
const { kind } = snapshot.layer.evaluator;
|
|
1215
1220
|
if (kind === 'records') {
|
|
1216
1221
|
if (sealed === null) throw noStages();
|
|
1222
|
+
// Imported sets can share arm work, and the importer does not attest to
|
|
1223
|
+
// complete usage reports. Leave both the trial count and cost unknown.
|
|
1224
|
+
progress('records-import', {
|
|
1225
|
+
detail: 'Importing existing evaluator records',
|
|
1226
|
+
completedTrials: null,
|
|
1227
|
+
totalTrials: null,
|
|
1228
|
+
usage: null,
|
|
1229
|
+
});
|
|
1217
1230
|
return concludeImportedRecords({ ...context, arms, refusedIds });
|
|
1218
1231
|
}
|
|
1232
|
+
const trialCount = snapshot.trials ?? evaluation.trials;
|
|
1233
|
+
const totalTrials = arms.length * trialCount;
|
|
1234
|
+
progress('trial-preparation', { detail: 'Preparing scored trials', totalTrials });
|
|
1219
1235
|
if (convertsRows(kind)) {
|
|
1220
1236
|
// A command evaluator and a sealed-brief agent read the brief the run sealed, as its bytes were written.
|
|
1221
1237
|
if (sealed === null) throw noStages();
|
|
@@ -1242,6 +1258,7 @@ async function runTrialSets(given) {
|
|
|
1242
1258
|
|
|
1243
1259
|
let calibrationDigest = null;
|
|
1244
1260
|
if ((contract.rubrics ?? []).length > 0) {
|
|
1261
|
+
progress('calibration', { detail: 'Calibrating rubric judge' });
|
|
1245
1262
|
const { layer } = snapshot;
|
|
1246
1263
|
const judgeItem = async ({ rubric, criterion, response, responseKind }) => {
|
|
1247
1264
|
const stepId = /^\/interactions\/([^/]+)/.exec(criterion.evidence)?.[1];
|
|
@@ -1357,7 +1374,6 @@ async function runTrialSets(given) {
|
|
|
1357
1374
|
}
|
|
1358
1375
|
}
|
|
1359
1376
|
|
|
1360
|
-
const trialCount = snapshot.trials ?? evaluation.trials;
|
|
1361
1377
|
if (sealed === null) throw noStages();
|
|
1362
1378
|
const { contractDigest, sealedBriefDigest } = sealed;
|
|
1363
1379
|
const { conditions, layer } = snapshot;
|
|
@@ -1439,12 +1455,22 @@ async function runTrialSets(given) {
|
|
|
1439
1455
|
};
|
|
1440
1456
|
|
|
1441
1457
|
// A sealed-brief agent chooses its own calls, so its verdicts count only once it agrees with itself on the arms it will judge.
|
|
1442
|
-
if (kind === 'sealed-brief-agent')
|
|
1458
|
+
if (kind === 'sealed-brief-agent') {
|
|
1459
|
+
progress('evaluator-qualification', { detail: 'Qualifying evaluator agreement' });
|
|
1460
|
+
await qualifyEvaluator({ ...context, arms, sealing });
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
let completedTrials = 0;
|
|
1464
|
+
let reportedUse = ZERO;
|
|
1465
|
+
let missingReports = 0;
|
|
1466
|
+
progress('trials', { detail: 'Running scored trials', totalTrials, completedTrials });
|
|
1443
1467
|
|
|
1444
1468
|
for (const arm of arms) {
|
|
1445
1469
|
arm.trials = [];
|
|
1446
1470
|
for (let trialIndex = 1; trialIndex <= trialCount; trialIndex += 1) {
|
|
1447
|
-
|
|
1471
|
+
const trialDetail = `${arm.conditionArm}: trial ${trialIndex} of ${trialCount}`;
|
|
1472
|
+
log(trialDetail);
|
|
1473
|
+
progress('trials', { detail: trialDetail, completedTrials });
|
|
1448
1474
|
const trial = await runTrial({ ...context, arm, trialIndex });
|
|
1449
1475
|
const versionReadBegan = Date.now();
|
|
1450
1476
|
const agentMoved = await agentVersionChange(context);
|
|
@@ -1452,6 +1478,19 @@ async function runTrialSets(given) {
|
|
|
1452
1478
|
throw stop({ stage: 'trial', exitCode: 12, message: `the installed agent version could not be held after a trial: ${agentMoved}` });
|
|
1453
1479
|
trial.elapsedMs += Date.now() - versionReadBegan;
|
|
1454
1480
|
arm.trials.push(trial);
|
|
1481
|
+
reportedUse = addUsage(reportedUse, trial.resourceUse);
|
|
1482
|
+
missingReports += trial.unreportedSteps.length;
|
|
1483
|
+
completedTrials += 1;
|
|
1484
|
+
progress('trials', {
|
|
1485
|
+
detail: `${trialDetail} completed`,
|
|
1486
|
+
completedTrials,
|
|
1487
|
+
usage: {
|
|
1488
|
+
trialCostUsd: missingReports === 0 && completedTrials === totalTrials ? reportedUse.costUsd : null,
|
|
1489
|
+
reportedTrialCostUsd: reportedUse.costUsd,
|
|
1490
|
+
missingReports,
|
|
1491
|
+
scope: 'trials-only',
|
|
1492
|
+
},
|
|
1493
|
+
});
|
|
1455
1494
|
// Read after every trial, so a target that writes into the project stops the run at once.
|
|
1456
1495
|
treeUnchanged('trials');
|
|
1457
1496
|
}
|
|
@@ -1493,6 +1532,7 @@ async function runTrialSets(given) {
|
|
|
1493
1532
|
// wrote, and the run directory nothing else, before any trial set is written.
|
|
1494
1533
|
writer.verify('after the trials');
|
|
1495
1534
|
|
|
1535
|
+
progress('sealing', { detail: 'Sealing trial sets and evidence' });
|
|
1496
1536
|
const trialSets = [];
|
|
1497
1537
|
const recordDigests = {};
|
|
1498
1538
|
const manifestDigests = {};
|
|
@@ -2103,6 +2143,7 @@ async function completeRun(
|
|
|
2103
2143
|
// runtime wrote, before run.json says completed; that write is the run's last.
|
|
2104
2144
|
treeUnchanged('sealing', { record: false });
|
|
2105
2145
|
writer.verify('after the trial sets were sealed');
|
|
2146
|
+
context.progress?.('complete', { detail: 'Run sealed' });
|
|
2106
2147
|
Object.assign(run, {
|
|
2107
2148
|
artifacts,
|
|
2108
2149
|
contractDigest: sealed.contractDigest,
|