@rsaglobaltech/specgate 0.27.6 → 0.27.8

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 (36) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/packages/core/src/domain/DraftChecklist.js +43 -12
  3. package/dist/packages/core/src/domain/DraftChecklist.js.map +1 -1
  4. package/dist/packages/core/src/domain/HarnessReport.js +46 -10
  5. package/dist/packages/core/src/domain/HarnessReport.js.map +1 -1
  6. package/dist/packages/core/src/domain/HarnessRun.js.map +1 -1
  7. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
  8. package/dist/scripts/cli/commands/config/ConfigSetCommand.js +22 -4
  9. package/dist/scripts/cli/commands/config/ConfigSetCommand.js.map +1 -1
  10. package/dist/scripts/cli/commands/harness/RunCommand.js +200 -25
  11. package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
  12. package/dist/scripts/cli/commands/spec/DraftCommand.js +21 -0
  13. package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
  14. package/dist/scripts/cli/commands/spec/StatusCommand.js +35 -6
  15. package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
  16. package/dist/scripts/draft/brief-setup.js +23 -0
  17. package/dist/scripts/draft/brief-setup.js.map +1 -1
  18. package/dist/scripts/harness/report.js +7 -1
  19. package/dist/scripts/harness/report.js.map +1 -1
  20. package/dist/scripts/lib/agent-permission.js +31 -0
  21. package/dist/scripts/lib/agent-permission.js.map +1 -0
  22. package/dist/scripts/lib/live-model.js +28 -2
  23. package/dist/scripts/lib/live-model.js.map +1 -1
  24. package/dist/scripts/lib/live-page.js +10 -4
  25. package/dist/scripts/lib/live-page.js.map +1 -1
  26. package/dist/scripts/lib/quality-presets.js +3 -1
  27. package/dist/scripts/lib/quality-presets.js.map +1 -1
  28. package/dist/scripts/lib/quality-run.js +55 -0
  29. package/dist/scripts/lib/quality-run.js.map +1 -1
  30. package/dist/scripts/lib/shell-command.js +38 -1
  31. package/dist/scripts/lib/shell-command.js.map +1 -1
  32. package/dist/scripts/lib/studio-app-page.js +5 -2
  33. package/dist/scripts/lib/studio-app-page.js.map +1 -1
  34. package/dist/scripts/lib/studio-app.js +31 -7
  35. package/dist/scripts/lib/studio-app.js.map +1 -1
  36. package/package.json +1 -1
@@ -58,6 +58,7 @@ var __importStar = (this && this.__importStar) || (function () {
58
58
  })();
59
59
  Object.defineProperty(exports, "__esModule", { value: true });
60
60
  exports.RunCommand = exports.RUNS_DIR = void 0;
61
+ exports.promptInFlight = promptInFlight;
61
62
  exports.setJsonMode = setJsonMode;
62
63
  exports.error = error;
63
64
  exports.parseArgs = parseArgs;
@@ -78,6 +79,7 @@ const node_child_process_1 = require("node:child_process");
78
79
  const command_1 = require("../../../lib/command");
79
80
  const project_root_1 = require("../../../lib/project-root");
80
81
  const prompt_1 = require("../../../harness/prompt");
82
+ const agent_permission_1 = require("../../../lib/agent-permission");
81
83
  const init_1 = require("../../../harness/init");
82
84
  const in_flight_1 = require("../../../lib/in-flight");
83
85
  const harness_events_1 = require("../../../lib/harness-events");
@@ -101,6 +103,17 @@ function judged(dir, read) {
101
103
  view.cleanup();
102
104
  }
103
105
  }
106
+ /**
107
+ * A prompt built from the project as the harness judges it (REQ-268).
108
+ *
109
+ * The implementer's prompt was; the reviewer's and the adversary's read the
110
+ * worktree as written, where a reviewed draft's requirement has no text, and
111
+ * the reviewer was told to stop and report that REQ-002 did not exist
112
+ * (truck-flote, S53).
113
+ */
114
+ function promptInFlight(req, dir, opts = {}) {
115
+ return judged(dir, (judgedDir) => (0, prompt_1.buildPrompt)(req, judgedDir, opts));
116
+ }
104
117
  const GherkinQuality_1 = require("../../../../packages/core/src/domain/GherkinQuality");
105
118
  const HarnessConfigFile_1 = require("../../../../packages/core/src/infrastructure/HarnessConfigFile");
106
119
  const agent_1 = require("../../../lib/agent");
@@ -492,6 +505,41 @@ function baselineProblem(projectDir, running) {
492
505
  "test. Write those tests or move those requirements back to Draft (`specgate check` shows " +
493
506
  "it whole), or pass --skip-baseline.");
494
507
  }
508
+ /** "0.4 s", "12 s", "3m 05s": how long a gate step took. */
509
+ function stepTime(ms) {
510
+ if (ms < 10_000)
511
+ return `${(ms / 1000).toFixed(1)} s`;
512
+ const secs = Math.round(ms / 1000);
513
+ if (secs < 60)
514
+ return `${secs} s`;
515
+ return `${Math.floor(secs / 60)}m ${String(secs % 60).padStart(2, "0")}s`;
516
+ }
517
+ /**
518
+ * REQ-273: a gate step is an event when it starts and when it ends, and a line
519
+ * of the run's output each time. A check that hung for 15 minutes printed
520
+ * nothing and showed nothing in `/live` until it ended (truck-flote, S54).
521
+ */
522
+ function gateReporter(eventsFile, requirement, attempt, prefix = "") {
523
+ return (stage, result, extra) => {
524
+ const named = `${prefix}${stage}`;
525
+ // The line names the test step, not its command: what the command prints
526
+ // stays off a green run's output unless `--verbose` asks for it.
527
+ const shown = named.replace(/^((?:post-merge )?test command): [\s\S]*$/, "$1");
528
+ if (result === "start") {
529
+ (0, harness_events_1.appendEvent)(eventsFile, { type: "gate-start", stage: named, ...(extra || {}) }, requirement, attempt);
530
+ const where = extra && extra.image
531
+ ? ` (${extra.runner} ${extra.image})`
532
+ : extra && extra.runner
533
+ ? ` (${extra.runner})`
534
+ : "";
535
+ info(`${requirement}: started ${shown}${where}`);
536
+ return;
537
+ }
538
+ (0, harness_events_1.appendEvent)(eventsFile, { type: "gate", stage: named, result, ...(extra || {}) }, requirement, attempt);
539
+ const ms = extra && typeof extra.ms === "number" ? ` in ${stepTime(extra.ms)}` : "";
540
+ info(`${requirement}: ${result === "pass" ? "passed" : "failed"} ${shown}${ms}`);
541
+ };
542
+ }
495
543
  function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, report = () => { }) {
496
544
  // `--delivering`: the requirement is still Draft while its gate runs, and a
497
545
  // Draft row owes no files. Without it the gate would skip the one
@@ -500,6 +548,8 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
500
548
  // the gate judges the attempt, not the matrix as it was before it.
501
549
  (0, MatrixCommand_1.refreshDerivedMatrix)(worktreeDir);
502
550
  let passHint = "";
551
+ report("validate --strict", "start");
552
+ const validateStart = Date.now();
503
553
  const validate = judged(worktreeDir, (dir, toProject) => {
504
554
  const validateArgs = [VALIDATE_SCRIPT, dir, "--strict"];
505
555
  if (req.requirement)
@@ -512,7 +562,9 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
512
562
  // The agent is told about its worktree, not about the copy it was judged in.
513
563
  return { ...run, stdout: toProject(run.stdout || ""), stderr: toProject(run.stderr || "") };
514
564
  });
515
- report("validate --strict", validate.status === 0 ? "pass" : "fail");
565
+ report("validate --strict", validate.status === 0 ? "pass" : "fail", {
566
+ ms: Date.now() - validateStart,
567
+ });
516
568
  if (validate.status !== 0) {
517
569
  return {
518
570
  ok: false,
@@ -524,8 +576,10 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
524
576
  // REQ-199: a requirement used on a screen needs an end-to-end test that
525
577
  // names it, when the project says where those live.
526
578
  if (settings.e2eDir && req.requirement && (0, screen_requirements_1.onScreen)(worktreeDir, req.requirement)) {
579
+ report("e2e test", "start");
580
+ const e2eStart = Date.now();
527
581
  const named = (0, screen_requirements_1.e2eTestsNaming)(worktreeDir, settings.e2eDir, req.requirement);
528
- report("e2e test", named.length ? "pass" : "fail");
582
+ report("e2e test", named.length ? "pass" : "fail", { ms: Date.now() - e2eStart });
529
583
  if (named.length === 0) {
530
584
  return {
531
585
  ok: false,
@@ -540,6 +594,8 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
540
594
  // and its failure goes back to the agent like any other gate failure.
541
595
  const archCmd = String(settings.archCmd || "").trim();
542
596
  if (archCmd) {
597
+ report(`architecture check: ${archCmd}`, "start");
598
+ const archStart = Date.now();
543
599
  // REQ-231: with arch_sarif, only a violation this attempt adds fails it.
544
600
  const arch = settings.archSarif
545
601
  ? (0, quality_run_1.archRun)(worktreeDir, archCmd, settings.archSarif, "", req.requirement || "")
@@ -550,7 +606,9 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
550
606
  timeout: timeoutMs,
551
607
  maxBuffer: SUBPROCESS_MAX_BUFFER,
552
608
  });
553
- report(`architecture check: ${archCmd}`, arch.status === 0 ? "pass" : "fail");
609
+ report(`architecture check: ${archCmd}`, arch.status === 0 ? "pass" : "fail", {
610
+ ms: Date.now() - archStart,
611
+ });
554
612
  if (arch.status !== 0) {
555
613
  return {
556
614
  ok: false,
@@ -587,6 +645,8 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
587
645
  // Stderr rather than inherited stdout, because `--format json` promises one
588
646
  // parseable document on stdout. Letting the test command write there is
589
647
  // precisely H18: prose and JSON sharing a stream.
648
+ report(`test command: ${(0, HarnessEvents_1.maskCommand)(resolved)}`, "start");
649
+ const testStart = Date.now();
590
650
  const test = (0, node_child_process_1.spawnSync)((0, shell_command_1.forShell)(resolved), {
591
651
  shell: true,
592
652
  cwd: worktreeDir,
@@ -595,7 +655,10 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
595
655
  maxBuffer: SUBPROCESS_MAX_BUFFER,
596
656
  stdio: settings.verbose ? ["ignore", 2, 2] : undefined,
597
657
  });
598
- report(`test command: ${(0, HarnessEvents_1.maskCommand)(resolved)}`, test.status === 0 ? "pass" : "fail", (0, HarnessEvents_1.testCounts)(`${test.stdout || ""}${test.stderr || ""}`));
658
+ report(`test command: ${(0, HarnessEvents_1.maskCommand)(resolved)}`, test.status === 0 ? "pass" : "fail", {
659
+ ...(0, HarnessEvents_1.testCounts)(`${test.stdout || ""}${test.stderr || ""}`),
660
+ ms: Date.now() - testStart,
661
+ });
599
662
  if (test.status !== 0) {
600
663
  // Name the command. A gate that silently does the wrong thing — running
601
664
  // the whole suite because a filter did not apply, say — produces a
@@ -642,8 +705,14 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
642
705
  const ctx = (0, quality_run_1.gateContext)(worktreeDir, req.requirement || "", "");
643
706
  const outcomes = [];
644
707
  for (const stage of ["attempt", "end"]) {
645
- for (const o of (0, quality_run_1.runStages)(worktreeDir, quality, [stage], ctx, undefined, agentChecks(worktreeDir, req, settings, timeoutMs, testCmd))) {
646
- outcomes.push(o);
708
+ // REQ-273: each check is reported as it starts and as it ends — the
709
+ // stage used to run every check before the first was reported.
710
+ const asItRuns = (check, run) => {
711
+ report(`quality: ${check.name}`, "start", {
712
+ runner: check.runner,
713
+ ...(check.image ? { image: check.image } : {}),
714
+ });
715
+ const o = run();
647
716
  // REQ-229: the step says what was measured, so /live shows it as it happens.
648
717
  const said = o.skipped
649
718
  ? " (not run)"
@@ -657,6 +726,20 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
657
726
  ...(o.skipped ? { skipped: o.skipped } : {}),
658
727
  ...(o.measure ? { value: o.measure.value } : {}),
659
728
  });
729
+ return o;
730
+ };
731
+ for (const o of (0, quality_run_1.runStages)(worktreeDir, quality, [stage], ctx, asItRuns, agentChecks(worktreeDir, req, settings, timeoutMs, testCmd))) {
732
+ outcomes.push(o);
733
+ // REQ-267: a check that could not run is not the agent's to fix.
734
+ if (o.gate && !o.ok && !o.skipped && o.couldNotRun) {
735
+ return {
736
+ ok: false,
737
+ stage: `quality check: ${o.name}`,
738
+ output: o.output,
739
+ couldNotRun: `the quality check ${o.name} could not run: it ${o.couldNotRun}`,
740
+ checks: outcomes,
741
+ };
742
+ }
660
743
  if (o.gate && !o.ok && !o.skipped) {
661
744
  return {
662
745
  ok: false,
@@ -790,7 +873,7 @@ function agentChecks(worktreeDir, req, settings, timeoutMs, testCmd) {
790
873
  return settings.agent || "";
791
874
  }
792
875
  };
793
- const promptFor = (instruction) => (0, prompt_1.buildPrompt)(req, worktreeDir, {
876
+ const promptFor = (instruction) => promptInFlight(req, worktreeDir, {
794
877
  promptPrefix: settings.promptPrefix,
795
878
  hint: instruction,
796
879
  withPrecedents: settings.promptPrecedents === true,
@@ -1189,6 +1272,7 @@ function attemptRequirement(req, ctx) {
1189
1272
  totalMs: Date.now() - attemptStart,
1190
1273
  profiles: [...profilesUsed],
1191
1274
  });
1275
+ ledgerAttempts(attemptLog);
1192
1276
  };
1193
1277
  // An attempt is a sequence of agent invocations, all bound to this one
1194
1278
  // requirement. Attempt 1 is a single implementing step; a retry may run an
@@ -1330,17 +1414,21 @@ function attemptRequirement(req, ctx) {
1330
1414
  }
1331
1415
  // Before the gate: an agent that wrote nothing cannot have implemented
1332
1416
  // anything, and the gate would approve it. See `producedNothing` (H19).
1417
+ // REQ-264: the same command writes nothing the next time either — three
1418
+ // attempts and 7 min 40 s said so in truck-flote (S50) — so it ends here.
1333
1419
  if (producedNothing(worktreeDir)) {
1334
1420
  previousFailure =
1335
1421
  `You produced no files. Nothing in the worktree changed, so there is ` +
1336
1422
  `nothing implementing ${req.requirement}.\n\n` +
1337
1423
  `If you could not write, that is the problem to report — a non-interactive ` +
1338
1424
  `agent without tool permissions reads the prompt and cannot act on it. ` +
1339
- `See docs/automation.md.`;
1425
+ `See docs/agent-setup.md, § Tool allowances.`;
1340
1426
  warn(`${req.requirement}: the agent produced no files — nothing to gate`);
1341
- warn(` fix: check the agent's write permissions (see docs/automation.md)`);
1427
+ warn(` fix: give the agent write permission and the tools it needs (docs/agent-setup.md, § Tool allowances)`);
1428
+ warn(` not retrying: the same command would write nothing again`);
1342
1429
  record("no-op");
1343
- continue;
1430
+ stopRetrying = true;
1431
+ break;
1344
1432
  }
1345
1433
  // Before the gate: a scenario the agent has just loosened cannot fail, so a
1346
1434
  // green gate proves nothing. See `checkWriteScopeInWorktree`.
@@ -1365,7 +1453,19 @@ function attemptRequirement(req, ctx) {
1365
1453
  }
1366
1454
  // `verbose` is a run flag, not a project setting, so it travels on the
1367
1455
  // context rather than in `harness.config.yaml`.
1368
- const gate = runGate(worktreeDir, settings.testCmd, timeoutMs, req, { ...settings, verbose: ctx.verbose }, (stage, result, extra) => event({ type: "gate", stage, result, ...(extra || {}) }));
1456
+ const gate = runGate(worktreeDir, settings.testCmd, timeoutMs, req, { ...settings, verbose: ctx.verbose }, gateReporter(eventsFile, req.requirement, attempt));
1457
+ if (!gate.ok && gate.couldNotRun) {
1458
+ // REQ-267: nothing in the code can fix the tool's own setup, and another
1459
+ // attempt would meet it again (truck-flote S52: 15 minutes each).
1460
+ previousFailure = `Gate stopped at: ${gate.stage}\n\n${gate.couldNotRun}\n\n${gate.output}`;
1461
+ warn(`${req.requirement}: ${gate.couldNotRun}`);
1462
+ for (const line of gate.output.trim().split("\n").slice(-5))
1463
+ warn(` ${line}`);
1464
+ warn(` not retrying: fix the check (harness.config.yaml) and run it again`);
1465
+ record("check");
1466
+ stopRetrying = true;
1467
+ break;
1468
+ }
1369
1469
  if (!gate.ok) {
1370
1470
  previousFailure =
1371
1471
  `Gate failed at: ${gate.stage}\n\n` + (gate.hint ? `⚠ ${gate.hint}\n\n` : "") + gate.output;
@@ -1444,7 +1544,7 @@ function attemptRequirement(req, ctx) {
1444
1544
  alreadyProbed: probe.ran,
1445
1545
  })) {
1446
1546
  info(`${req.requirement}: probing with '${settings.adversaryProfile}'`);
1447
- probe = runAdversarialProbe(worktreeDir, req, settings, timeoutMs, (instruction) => (0, prompt_1.buildPrompt)(req, worktreeDir, {
1547
+ probe = runAdversarialProbe(worktreeDir, req, settings, timeoutMs, (instruction) => promptInFlight(req, worktreeDir, {
1448
1548
  promptPrefix: settings.promptPrefix,
1449
1549
  hint: instruction,
1450
1550
  withPrecedents: settings.promptPrecedents === true,
@@ -1540,6 +1640,28 @@ function recordQuestions(projectDir, results) {
1540
1640
  }
1541
1641
  /** Where a run's record lands. Local to the machine — see `writeRunRecord`. */
1542
1642
  exports.RUNS_DIR = path.join(".harness", "runs");
1643
+ /**
1644
+ * The run's record, written as it goes (REQ-269).
1645
+ *
1646
+ * It used to be written once, at the end, so a run stopped by hand — the
1647
+ * 27 minutes truck-flote spent on semgrep hanging (S52) — left no trace, and
1648
+ * `harness report` priced the requirement without them. Now it is written when
1649
+ * each requirement starts and each attempt ends; a killed run leaves a record
1650
+ * whose `running` says where it stopped.
1651
+ */
1652
+ let ledger = null;
1653
+ function ledgerWrite() {
1654
+ if (!ledger)
1655
+ return;
1656
+ ledger.record.updatedAt = new Date().toISOString();
1657
+ writeRunRecord(ledger.projectDir, ledger.record);
1658
+ }
1659
+ function ledgerAttempts(attemptLog) {
1660
+ if (!ledger || !ledger.record.running)
1661
+ return;
1662
+ ledger.record.running.attemptLog = [...attemptLog];
1663
+ ledgerWrite();
1664
+ }
1543
1665
  /**
1544
1666
  * Write what a run did, so the next question about it has an answer.
1545
1667
  *
@@ -1922,6 +2044,23 @@ function gitIdentityProblem(projectDir) {
1922
2044
  `Fix: git config user.name "Your Name" && git config user.email "you@example.com"`);
1923
2045
  }
1924
2046
  function processRequirement(req, ctx) {
2047
+ if (ledger) {
2048
+ ledger.record.running = {
2049
+ requirement: req.requirement,
2050
+ startedAt: new Date().toISOString(),
2051
+ attemptLog: [],
2052
+ };
2053
+ ledgerWrite();
2054
+ }
2055
+ const result = processOneRequirement(req, ctx);
2056
+ if (ledger) {
2057
+ ledger.record.results.push(result);
2058
+ delete ledger.record.running;
2059
+ ledgerWrite();
2060
+ }
2061
+ return result;
2062
+ }
2063
+ function processOneRequirement(req, ctx) {
1925
2064
  const { projectDir, baseRef, keepWorktrees, force } = ctx;
1926
2065
  const branch = `harness/${req.requirement}`;
1927
2066
  // D1: which profile this requirement matches, resolved once. The bounded
@@ -2049,8 +2188,13 @@ function processRequirement(req, ctx) {
2049
2188
  try {
2050
2189
  const setupCmd = String((ctx.settings && ctx.settings.setupCmd) || "").trim();
2051
2190
  if (setupCmd && !survivor) {
2052
- info(`${req.requirement}: setup — ${setupCmd}`);
2191
+ const report = gateReporter((0, harness_events_1.eventsFileFor)(ctx.projectDir || dir, req.requirement), req.requirement, undefined);
2192
+ report(`setup: ${setupCmd}`, "start");
2193
+ const setupStart = Date.now();
2053
2194
  const setup = (0, node_child_process_1.spawnSync)((0, shell_command_1.forShell)(setupCmd), { cwd: dir, shell: true, encoding: "utf8" });
2195
+ report(`setup: ${setupCmd}`, setup.status === 0 ? "pass" : "fail", {
2196
+ ms: Date.now() - setupStart,
2197
+ });
2054
2198
  if (setup.status !== 0) {
2055
2199
  return {
2056
2200
  requirement: req.requirement,
@@ -2382,7 +2526,7 @@ async function runChain(pending, ctx, hintByReq) {
2382
2526
  (0, harness_events_1.appendEvent)(eventsFile, { type: "merge", into: "HEAD", branch }, req.requirement, attempt);
2383
2527
  const gate = runGate(projectDir, ctx.settings.testCmd, ctx.timeoutMs, req,
2384
2528
  // The checks judged the branch already; the post-merge gate is the suite's.
2385
- { ...ctx.settings, quality: undefined }, (stage, res, extra) => (0, harness_events_1.appendEvent)(eventsFile, { type: "gate", stage: `post-merge ${stage}`, result: res, ...(extra || {}) }, req.requirement, attempt));
2529
+ { ...ctx.settings, quality: undefined }, gateReporter(eventsFile, req.requirement, attempt, "post-merge "));
2386
2530
  if (!gate.ok) {
2387
2531
  git(projectDir, ["reset", "-q", "--hard", "HEAD~1"]);
2388
2532
  Object.assign(result, {
@@ -2724,6 +2868,24 @@ class RunCommand extends command_1.BaseCommand {
2724
2868
  }
2725
2869
  }
2726
2870
  }
2871
+ // REQ-264: Claude Code in print mode writes nothing without permission.
2872
+ let permitted = false;
2873
+ const permit = (command) => {
2874
+ const p = (0, agent_permission_1.withAgentPermission)(command);
2875
+ permitted = permitted || p.added;
2876
+ return p.command;
2877
+ };
2878
+ if (settings.agent)
2879
+ settings.agent = permit(settings.agent);
2880
+ for (const name of Object.keys(settings.profileAgents || {})) {
2881
+ settings.profileAgents[name] = permit(settings.profileAgents[name]);
2882
+ }
2883
+ if (permitted) {
2884
+ info("Claude Code runs with --permission-mode acceptEdits: in print mode it cannot write " +
2885
+ "without it, and the harness's worktree confines what it writes.");
2886
+ }
2887
+ // REQ-271: an agent given on the command line is forgotten after the run.
2888
+ const unkept = Boolean(args.agent) && !(fileConfig && fileConfig.agent);
2727
2889
  // A ladder of per-attempt profiles configures agents just as much as a
2728
2890
  // single `agent:` does, so a project that declares one is configured.
2729
2891
  const hasAgent = Boolean(settings.agent) || settings.attemptProfiles.length > 0;
@@ -2868,6 +3030,23 @@ class RunCommand extends command_1.BaseCommand {
2868
3030
  (0, harness_events_1.writeQueue)(projectDir, pending.map((r) => r.requirement));
2869
3031
  }
2870
3032
  const startedAt = new Date().toISOString();
3033
+ const runRecordBase = {
3034
+ schemaVersion: 1,
3035
+ startedAt,
3036
+ baseRef,
3037
+ concurrency: settings.concurrency,
3038
+ maxAttempts: settings.maxAttempts,
3039
+ // Whether this run had a ceiling at all. Reading the ledger later,
3040
+ // a short run is a different thing depending on the answer.
3041
+ bounded: (0, RunBudget_1.hasBudget)({
3042
+ budgetSeconds: args.budgetSeconds,
3043
+ maxRequirements: args.maxRequirements,
3044
+ }),
3045
+ };
3046
+ if (!process.env.SPECGATE_HARNESS_WORKER) {
3047
+ ledger = { projectDir, record: { ...runRecordBase, finishedAt: "", results: [] } };
3048
+ ledgerWrite();
3049
+ }
2871
3050
  const results = args.chain
2872
3051
  ? await runChain(pending, ctx, hintByReq)
2873
3052
  : await runLevels(pending, ctx, {
@@ -2885,21 +3064,17 @@ class RunCommand extends command_1.BaseCommand {
2885
3064
  const recordPath = process.env.SPECGATE_HARNESS_WORKER
2886
3065
  ? null
2887
3066
  : writeRunRecord(projectDir, {
2888
- schemaVersion: 1,
2889
- startedAt,
3067
+ ...runRecordBase,
2890
3068
  finishedAt: new Date().toISOString(),
2891
- baseRef,
2892
- concurrency: settings.concurrency,
2893
- maxAttempts: settings.maxAttempts,
2894
- // Whether this run had a ceiling at all. Reading the ledger later,
2895
- // a short run is a different thing depending on the answer.
2896
- bounded: (0, RunBudget_1.hasBudget)({
2897
- budgetSeconds: args.budgetSeconds,
2898
- maxRequirements: args.maxRequirements,
2899
- }),
2900
3069
  results,
2901
3070
  });
3071
+ ledger = null;
2902
3072
  printReport(results, args.format, recordPath ? path.relative(projectDir, recordPath) : undefined);
3073
+ // REQ-271: the studio's Build reads the agent from harness.config.yaml.
3074
+ if (unkept && !jsonMode && !process.env.SPECGATE_HARNESS_WORKER) {
3075
+ process.stdout.write(` ${ui.muted("Keep this agent for the next run and the studio's Build:")}\n` +
3076
+ ` specgate config set agent "${args.agent.replace(/"/g, '\\"')}"\n\n`);
3077
+ }
2903
3078
  // A blocked requirement was never attempted, so it is not a pass — but it
2904
3079
  // is also not evidence that anything is broken beyond the failure that
2905
3080
  // caused it. It still fails the run, because work was left undone.