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.
Files changed (46) hide show
  1. package/.claude-plugin/marketplace.json +1 -3
  2. package/README.md +3 -3
  3. package/cli/evaluate.js +25 -2
  4. package/cli/lib/evaluate/confinement-audit.js +2 -2
  5. package/cli/lib/evaluate/confinement.js +16 -25
  6. package/cli/lib/evaluate/isolation-allowlist.js +6 -4
  7. package/cli/lib/evaluate/preflight.js +80 -3
  8. package/cli/lib/evaluate/run.js +46 -5
  9. package/cli/lib/evaluate/status.js +184 -0
  10. package/package.json +3 -2
  11. package/skills/bmad-tea/SKILL.md +2 -2
  12. package/skills/bmad-tea/customize.toml +4 -4
  13. package/skills/bmad-teach-me-testing/steps-c/step-04-session-01.md +2 -2
  14. package/skills/bmad-teach-me-testing/steps-c/step-04-session-03.md +1 -1
  15. package/skills/bmad-teach-me-testing/steps-c/step-04-session-05.md +2 -2
  16. package/skills/bmad-testarch-automate/red/instructions.md +1 -2
  17. package/skills/bmad-testarch-automate/resources/test-generation-routing.md +17 -21
  18. package/skills/bmad-testarch-evaluate/SKILL.md +5 -3
  19. package/skills/bmad-testarch-evaluate/assets/inspection-record.md +1 -1
  20. package/skills/bmad-testarch-evaluate/references/authoring-routing.md +31 -0
  21. package/skills/bmad-testarch-evaluate/references/ci.md +5 -5
  22. package/skills/bmad-testarch-evaluate/references/run.md +19 -0
  23. package/skills/bmad-testarch-framework/SKILL.md +2 -2
  24. package/skills/bmad-testarch-framework/checklist.md +4 -4
  25. package/skills/bmad-testarch-framework/ci/checklist.md +2 -2
  26. package/skills/bmad-testarch-framework/ci/instructions.md +1 -1
  27. package/skills/bmad-testarch-framework/resources/setup-routing.md +4 -4
  28. package/skills/bmad-testarch-test-design/checklist.md +6 -6
  29. package/skills/bmad-testarch-test-design/resources/test-design-epic-3.example.md +1 -1
  30. package/skills/bmad-testarch-test-design/test-design-template.md +1 -1
  31. package/skills/bmad-testarch-trace/checklist.md +2 -2
  32. package/skills/bmad-testarch-trace/steps-c/step-04-analyze-gaps.md +1 -1
  33. package/skills/bmad-testarch-trace/trace-template.md +1 -1
  34. package/skills/bmod-tea/bmod.toml +2 -4
  35. package/skills/bmod-tea/help/help.md +1 -3
  36. package/skills/bmod-tea/help/integrations.md +1 -1
  37. package/skills/bmod-tea/knowledge/burn-in.md +1 -1
  38. package/skills/bmod-tea/migration-1.toml +1 -1
  39. package/skills/bmad-testarch-atdd/SKILL.md +0 -16
  40. package/skills/bmad-testarch-atdd/bmod.toml +0 -4
  41. package/skills/bmad-testarch-atdd/customize.toml +0 -38
  42. package/skills/bmad-testarch-atdd/workflow.yaml +0 -40
  43. package/skills/bmad-testarch-ci/SKILL.md +0 -14
  44. package/skills/bmad-testarch-ci/bmod.toml +0 -4
  45. package/skills/bmad-testarch-ci/customize.toml +0 -43
  46. 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.2",
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
- Ten commands start the eight skills: Framework includes CI setup; Automate includes red and expand modes.
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-ci` and the `CI` menu code still work and start the same skill's CI setup, with your existing CI customizations.
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. Your prompt selects the mode. `/bmad-testarch-atdd` and `AT` default to red; `/bmad-testarch-automate` and `TA` default to expand. Existing commands and customization files keep working. 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.
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(`${NAME}: ${error.message}\nUsage: ${NAME} <check|digest|preflight|run|score|compare|ci> --evaluation <path>\n`);
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 reports every `file-read-data` it allows
10
- * outside the grants (`with report`) and tags each file rule it
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 allows outside the grants (`with report`) and
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
- * With `audit` (`{ token, exempt }`), the kernel reports what the profile allows or refuses.
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
- * One rule reports each `file-read-data` the profile allows outside `exempt` (the paths the sandbox may read, and the root directory itself) with the same token (`with report`).
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
- * Without `audit` the profile is the one every earlier story generated, byte for byte.
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
- // Seatbelt decides an operation by the rules that name it before the rules that name its wildcard, so the report rule
489
- // (which names `file-read-data`) would override every later `file-read*` rule, whatever their order; an audited profile
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
- // What the sandbox may read is not reported; every other read the profile allows is (macOS reports by real path, so both spellings are named).
546
- const reportRule =
547
- audit === null
548
- ? []
549
- : [
550
- `(allow file-read-data\n (require-all\n ${[
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 opened ${mounts.length} path(s) outside the allowlist (${list}), so every trial will too, and \`score\` refuses a trial that does (exit 3).`
129
- : `isolation manifest violation: the trials opened ${mounts.length} path(s) outside the allowlist, so \`score\` would exit 3 (${list}).`;
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
- if (afterVerdict === null && verdict.exitCode === 0) {
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: () => {
@@ -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') await qualifyEvaluator({ ...context, arms, sealing });
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
- log(`${arm.conditionArm}: trial ${trialIndex} of ${trialCount}`);
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,