@rsaglobaltech/specgate 0.25.0 → 0.27.1

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 (97) hide show
  1. package/CHANGELOG.md +87 -42
  2. package/README.md +6 -0
  3. package/dist/packages/core/src/domain/AgentPrompt.js +12 -0
  4. package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -1
  5. package/dist/packages/core/src/domain/ChangeMeasures.js +198 -0
  6. package/dist/packages/core/src/domain/ChangeMeasures.js.map +1 -0
  7. package/dist/packages/core/src/domain/Clarification.js +5 -0
  8. package/dist/packages/core/src/domain/Clarification.js.map +1 -1
  9. package/dist/packages/core/src/domain/ConventionalCommit.js +73 -0
  10. package/dist/packages/core/src/domain/ConventionalCommit.js.map +1 -0
  11. package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -1
  12. package/dist/packages/core/src/domain/HarnessReport.js +2 -0
  13. package/dist/packages/core/src/domain/HarnessReport.js.map +1 -1
  14. package/dist/packages/core/src/domain/QualityChecks.js +218 -0
  15. package/dist/packages/core/src/domain/QualityChecks.js.map +1 -0
  16. package/dist/packages/core/src/domain/QualitySummary.js +58 -0
  17. package/dist/packages/core/src/domain/QualitySummary.js.map +1 -0
  18. package/dist/packages/core/src/domain/RequirementPlan.js +11 -0
  19. package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -1
  20. package/dist/packages/core/src/domain/Sarif.js +121 -0
  21. package/dist/packages/core/src/domain/Sarif.js.map +1 -0
  22. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +15 -3
  23. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -1
  24. package/dist/packages/core/src/infrastructure/GitContributionStager.js +1 -1
  25. package/dist/packages/core/src/infrastructure/GitContributionStager.js.map +1 -1
  26. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +26 -0
  27. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
  28. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js +4 -1
  29. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js.map +1 -1
  30. package/dist/packages/core/src/infrastructure/QualityBaselineFile.js +67 -0
  31. package/dist/packages/core/src/infrastructure/QualityBaselineFile.js.map +1 -0
  32. package/dist/packages/core/test/unit/rework.test.js +62 -0
  33. package/dist/packages/core/test/unit/rework.test.js.map +1 -0
  34. package/dist/scripts/cli/commands/change/ChangeCommand.js +38 -1
  35. package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
  36. package/dist/scripts/cli/commands/change/TicketCommand.js +1 -1
  37. package/dist/scripts/cli/commands/change/TicketCommand.js.map +1 -1
  38. package/dist/scripts/cli/commands/harness/RunCommand.js +269 -17
  39. package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
  40. package/dist/scripts/cli/commands/quality/CheckCommand.js +54 -3
  41. package/dist/scripts/cli/commands/quality/CheckCommand.js.map +1 -1
  42. package/dist/scripts/cli/commands/quality/QualityCommand.js +314 -0
  43. package/dist/scripts/cli/commands/quality/QualityCommand.js.map +1 -0
  44. package/dist/scripts/cli/commands/spec/DoneCommand.js +4 -1
  45. package/dist/scripts/cli/commands/spec/DoneCommand.js.map +1 -1
  46. package/dist/scripts/cli/commands/spec/DraftCommand.js +44 -4
  47. package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
  48. package/dist/scripts/cli/commands/spec/StatusCommand.js +5 -1
  49. package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
  50. package/dist/scripts/cli/commands/spec/StudioCommand.js +208 -1
  51. package/dist/scripts/cli/commands/spec/StudioCommand.js.map +1 -1
  52. package/dist/scripts/docs/nav.js +13 -2
  53. package/dist/scripts/docs/nav.js.map +1 -1
  54. package/dist/scripts/docs/record-journey.js +1 -0
  55. package/dist/scripts/docs/record-journey.js.map +1 -1
  56. package/dist/scripts/docs/record-terminal.js +18 -0
  57. package/dist/scripts/docs/record-terminal.js.map +1 -1
  58. package/dist/scripts/draft/drafting-guide.js +11 -1
  59. package/dist/scripts/draft/drafting-guide.js.map +1 -1
  60. package/dist/scripts/harness/prompt.js +4 -0
  61. package/dist/scripts/harness/prompt.js.map +1 -1
  62. package/dist/scripts/harness/report.js +13 -0
  63. package/dist/scripts/harness/report.js.map +1 -1
  64. package/dist/scripts/lib/change-review.js +145 -0
  65. package/dist/scripts/lib/change-review.js.map +1 -0
  66. package/dist/scripts/lib/clarification.js +24 -5
  67. package/dist/scripts/lib/clarification.js.map +1 -1
  68. package/dist/scripts/lib/harness-guard.js +18 -0
  69. package/dist/scripts/lib/harness-guard.js.map +1 -1
  70. package/dist/scripts/lib/in-flight.js +17 -11
  71. package/dist/scripts/lib/in-flight.js.map +1 -1
  72. package/dist/scripts/lib/live-model.js +7 -1
  73. package/dist/scripts/lib/live-model.js.map +1 -1
  74. package/dist/scripts/lib/live-page.js +72 -11
  75. package/dist/scripts/lib/live-page.js.map +1 -1
  76. package/dist/scripts/lib/quality-presets.js +378 -0
  77. package/dist/scripts/lib/quality-presets.js.map +1 -0
  78. package/dist/scripts/lib/quality-run.js +579 -0
  79. package/dist/scripts/lib/quality-run.js.map +1 -0
  80. package/dist/scripts/lib/screen-requirements.js +149 -0
  81. package/dist/scripts/lib/screen-requirements.js.map +1 -0
  82. package/dist/scripts/lib/sonar-local.js +308 -0
  83. package/dist/scripts/lib/sonar-local.js.map +1 -0
  84. package/dist/scripts/lib/studio-app-page.js +1023 -0
  85. package/dist/scripts/lib/studio-app-page.js.map +1 -0
  86. package/dist/scripts/lib/studio-app.js +442 -0
  87. package/dist/scripts/lib/studio-app.js.map +1 -0
  88. package/dist/scripts/lib/studio-route.js +996 -0
  89. package/dist/scripts/lib/studio-route.js.map +1 -0
  90. package/dist/scripts/lib/surface.js +11 -0
  91. package/dist/scripts/lib/surface.js.map +1 -1
  92. package/dist/scripts/lib/ticket-keys.js +1 -1
  93. package/dist/scripts/lib/ticket-keys.js.map +1 -1
  94. package/dist/scripts/quality.js +10 -0
  95. package/dist/scripts/quality.js.map +1 -0
  96. package/package.json +1 -1
  97. package/templates/harness/harness.config.yaml.tpl +10 -1
@@ -82,7 +82,9 @@ const init_1 = require("../../../harness/init");
82
82
  const in_flight_1 = require("../../../lib/in-flight");
83
83
  const harness_events_1 = require("../../../lib/harness-events");
84
84
  const clarification_1 = require("../../../lib/clarification");
85
+ const screen_requirements_1 = require("../../../lib/screen-requirements");
85
86
  const harness_guard_1 = require("../../../lib/harness-guard");
87
+ const quality_run_1 = require("../../../lib/quality-run");
86
88
  const HarnessEvents_1 = require("../../../../packages/core/src/domain/HarnessEvents");
87
89
  /**
88
90
  * `read` against the project as it will be after its open changes are
@@ -171,6 +173,8 @@ function usage() {
171
173
  "Runs the plan → agent → verify → done loop for every pending requirement.\n\n" +
172
174
  " --agent <cmd> Agent command; must contain the {prompt_file} placeholder.\n" +
173
175
  ' e.g. --agent "claude -p < {prompt_file}"\n' +
176
+ " --profile <name> Run this profile's agent from .harness/profiles.yaml instead\n" +
177
+ " of the configured one, for this run only.\n" +
174
178
  " --test-cmd <cmd> Project test command run as part of the gate (optional).\n" +
175
179
  " Substitutes {req}, {scenario} and {feature_file}, so the\n" +
176
180
  " gate can run the scenario under test.\n" +
@@ -252,6 +256,7 @@ function parseArgs(argv) {
252
256
  prCmd: "",
253
257
  setupCmd: "",
254
258
  chain: false,
259
+ profile: "",
255
260
  };
256
261
  for (let i = 0; i < argv.length; i += 1) {
257
262
  const token = argv[i];
@@ -261,6 +266,9 @@ function parseArgs(argv) {
261
266
  else if (token === "--agent") {
262
267
  args.agent = argv[++i] || "";
263
268
  }
269
+ else if (token === "--profile") {
270
+ args.profile = argv[++i] || "";
271
+ }
264
272
  else if (token === "--test-cmd") {
265
273
  args.testCmd = argv[++i] || "";
266
274
  }
@@ -398,6 +406,14 @@ function runPlan(projectDir) {
398
406
  // `JSON.parse` only ever throws a SyntaxError.
399
407
  throw new Error(`plan produced invalid JSON: ${err.message}`);
400
408
  }
409
+ // Read on the project itself: the judged copy has every open change
410
+ // archived into it, and no longer says which requirements were modified.
411
+ const modified = new Set((0, in_flight_1.inFlightRequirements)(projectDir)
412
+ .filter((r) => r.op === "modified")
413
+ .map((r) => r.id));
414
+ if (modified.size > 0 && Array.isArray(parsed.requirements)) {
415
+ parsed.requirements = (0, RequirementPlan_1.withRework)(parsed.requirements, modified);
416
+ }
401
417
  return parsed;
402
418
  }
403
419
  // Generous maxBuffer for captured subprocess output — Maven/Gradle first runs
@@ -482,6 +498,7 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
482
498
  // The agent's tests are new evidence for a generated matrix: refresh it so
483
499
  // the gate judges the attempt, not the matrix as it was before it.
484
500
  (0, MatrixCommand_1.refreshDerivedMatrix)(worktreeDir);
501
+ let passHint = "";
485
502
  const validate = judged(worktreeDir, (dir, toProject) => {
486
503
  const validateArgs = [VALIDATE_SCRIPT, dir, "--strict"];
487
504
  if (req.requirement)
@@ -503,17 +520,35 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
503
520
  hint: "",
504
521
  };
505
522
  }
523
+ // REQ-199: a requirement used on a screen needs an end-to-end test that
524
+ // names it, when the project says where those live.
525
+ if (settings.e2eDir && req.requirement && (0, screen_requirements_1.onScreen)(worktreeDir, req.requirement)) {
526
+ const named = (0, screen_requirements_1.e2eTestsNaming)(worktreeDir, settings.e2eDir, req.requirement);
527
+ report("e2e test", named.length ? "pass" : "fail");
528
+ if (named.length === 0) {
529
+ return {
530
+ ok: false,
531
+ stage: "e2e test",
532
+ output: `No file under ${settings.e2eDir} names ${req.requirement}.`,
533
+ hint: `${req.requirement} is used on a screen: add an end-to-end test under ` +
534
+ `${settings.e2eDir} that names it, driving the screen its scenarios describe.`,
535
+ };
536
+ }
537
+ }
506
538
  // ADR-0028: the architecture check runs between validation and the tests,
507
539
  // and its failure goes back to the agent like any other gate failure.
508
540
  const archCmd = String(settings.archCmd || "").trim();
509
541
  if (archCmd) {
510
- const arch = (0, node_child_process_1.spawnSync)(archCmd, {
511
- shell: true,
512
- cwd: worktreeDir,
513
- encoding: "utf8",
514
- timeout: timeoutMs,
515
- maxBuffer: SUBPROCESS_MAX_BUFFER,
516
- });
542
+ // REQ-231: with arch_sarif, only a violation this attempt adds fails it.
543
+ const arch = settings.archSarif
544
+ ? (0, quality_run_1.archRun)(worktreeDir, archCmd, settings.archSarif, "", req.requirement || "")
545
+ : (0, node_child_process_1.spawnSync)(archCmd, {
546
+ shell: true,
547
+ cwd: worktreeDir,
548
+ encoding: "utf8",
549
+ timeout: timeoutMs,
550
+ maxBuffer: SUBPROCESS_MAX_BUFFER,
551
+ });
517
552
  report(`architecture check: ${archCmd}`, arch.status === 0 ? "pass" : "fail");
518
553
  if (arch.status !== 0) {
519
554
  return {
@@ -530,7 +565,10 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
530
565
  // Where the message stream will be, if there is one to have.
531
566
  let reportPath = settings.messageReport || "";
532
567
  if (!reportPath && (0, CucumberMessages_1.invokesCucumberDirectly)(resolved)) {
533
- reportPath = path.join(".harness", `messages-${req.requirement || "run"}-${crypto.randomBytes(3).toString("hex")}.ndjson`);
568
+ // Forward slashes on every platform: Cucumber reads `message:.harness\x`
569
+ // on Windows as a path it does not write, and the gate then had no
570
+ // stream to check and passed a run of no scenarios.
571
+ reportPath = path.posix.join(".harness", `messages-${req.requirement || "run"}-${crypto.randomBytes(3).toString("hex")}.ndjson`);
534
572
  fs.mkdirSync(path.join(worktreeDir, ".harness"), { recursive: true });
535
573
  resolved = (0, CucumberMessages_1.withMessageReport)(resolved, reportPath);
536
574
  }
@@ -592,12 +630,52 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
592
630
  errors.map((e) => ` ${e.message}\n fix: ${e.fix}`).join("\n\n"),
593
631
  };
594
632
  }
595
- if (warnings.length > 0) {
596
- return { ok: true, stage: "", output: "", hint: warnings[0].message };
633
+ if (warnings.length > 0)
634
+ passHint = warnings[0].message;
635
+ }
636
+ }
637
+ // REQ-220, REQ-221: the quality checks, after the tests — the attempt ones,
638
+ // then the end ones. A failure goes back to the agent like a failing test.
639
+ const quality = settings.quality;
640
+ if (quality && quality.checks.length > 0) {
641
+ const ctx = (0, quality_run_1.gateContext)(worktreeDir, req.requirement || "", "");
642
+ const outcomes = [];
643
+ for (const stage of ["attempt", "end"]) {
644
+ for (const o of (0, quality_run_1.runStages)(worktreeDir, quality, [stage], ctx, undefined, agentChecks(worktreeDir, req, settings, timeoutMs, testCmd))) {
645
+ outcomes.push(o);
646
+ // REQ-229: the step says what was measured, so /live shows it as it happens.
647
+ const said = o.skipped
648
+ ? " (not run)"
649
+ : o.measure && o.measure.value !== null
650
+ ? ` ${o.measure.value}%`
651
+ : o.findings
652
+ ? ` · ${o.findings.freshCount} new`
653
+ : "";
654
+ report(`quality: ${o.name}${said}`, o.ok || !!o.skipped || !o.gate ? "pass" : "fail", {
655
+ ms: o.ms,
656
+ ...(o.skipped ? { skipped: o.skipped } : {}),
657
+ ...(o.measure ? { value: o.measure.value } : {}),
658
+ });
659
+ if (o.gate && !o.ok && !o.skipped) {
660
+ return {
661
+ ok: false,
662
+ stage: `quality check: ${o.name}`,
663
+ output: o.output,
664
+ hint: o.measure && o.measure.kind === "coverage"
665
+ ? "Write the tests that run the lines named above; do not lower the threshold."
666
+ : o.measure && o.measure.kind === "mutation"
667
+ ? "Strengthen the tests so each surviving mutant named above would fail one."
668
+ : o.name === "commits"
669
+ ? "Reword the commits named above as Conventional Commits, or do not commit: the harness commits for you."
670
+ : "Fix what the check found. Do not silence it: no @SuppressWarnings, eslint-disable or baseline edit.",
671
+ checks: outcomes,
672
+ };
673
+ }
597
674
  }
598
675
  }
676
+ return { ok: true, stage: "", output: "", hint: passHint, checks: outcomes };
599
677
  }
600
- return { ok: true, stage: "", output: "", hint: "" };
678
+ return { ok: true, stage: "", output: "", hint: passHint };
601
679
  }
602
680
  /**
603
681
  * Undo whatever an advisory agent touched.
@@ -640,6 +718,151 @@ function discardWorktreeChanges(worktreeDir) {
640
718
  // reviewer's edits must not discard the evidence.
641
719
  git(worktreeDir, ["clean", "-fd", "-e", EmptyAttempt_1.PROMPT_ARCHIVE_DIR]);
642
720
  }
721
+ /**
722
+ * The worktree exactly as it is, to put back after an agent that must not
723
+ * change it: tracked files through `git stash create` (which touches
724
+ * nothing), untracked ones by content. An agent check runs before the commit,
725
+ * so the implementer's work is uncommitted — `discardWorktreeChanges` would
726
+ * throw it away (REQ-227).
727
+ */
728
+ function snapshotWorktree(worktreeDir) {
729
+ const stash = (git(worktreeDir, ["stash", "create"]).stdout || "").trim();
730
+ const untracked = (git(worktreeDir, ["ls-files", "--others", "--exclude-standard", "-z"]).stdout || "")
731
+ .split("\0")
732
+ .filter(Boolean);
733
+ const saved = new Map(untracked.map((rel) => [rel, fs.readFileSync(path.join(worktreeDir, rel))]));
734
+ return () => {
735
+ git(worktreeDir, ["checkout", stash || "HEAD", "--", "."]);
736
+ const now = (git(worktreeDir, ["ls-files", "--others", "--exclude-standard", "-z"]).stdout || "")
737
+ .split("\0")
738
+ .filter(Boolean);
739
+ for (const rel of now) {
740
+ if (!saved.has(rel) && !rel.startsWith(EmptyAttempt_1.PROMPT_ARCHIVE_DIR)) {
741
+ fs.rmSync(path.join(worktreeDir, rel), { force: true });
742
+ }
743
+ }
744
+ for (const [rel, content] of saved) {
745
+ fs.mkdirSync(path.dirname(path.join(worktreeDir, rel)), { recursive: true });
746
+ fs.writeFileSync(path.join(worktreeDir, rel), content);
747
+ }
748
+ };
749
+ }
750
+ /** Run an agent on an instruction built into the requirement's prompt; its output. */
751
+ function runAgentOnce(worktreeDir, command, prompt, timeoutMs, tag) {
752
+ const promptFile = path.join(os.tmpdir(), `specgate-${tag}-${crypto.randomBytes(4).toString("hex")}.md`);
753
+ try {
754
+ fs.writeFileSync(promptFile, prompt, "utf8");
755
+ const r = (0, node_child_process_1.spawnSync)((0, HarnessRun_1.substituteAgentCommand)(command, promptFile), {
756
+ shell: true,
757
+ cwd: worktreeDir,
758
+ encoding: "utf8",
759
+ timeout: timeoutMs,
760
+ maxBuffer: SUBPROCESS_MAX_BUFFER,
761
+ stdio: ["ignore", "pipe", "pipe"],
762
+ });
763
+ return `${r.stdout || ""}${r.stderr || ""}`.trim();
764
+ }
765
+ finally {
766
+ fs.rmSync(promptFile, { force: true });
767
+ }
768
+ }
769
+ /**
770
+ * The agent checks of REQ-227, lent to the quality stages of a harness gate:
771
+ * `builtin:review` — a second agent reads the change against the requirement
772
+ * and answers VERDICT: PASS or FAIL; `builtin:adversary` — one writes a test
773
+ * that tries to break it, which is run and then removed. Neither may change
774
+ * the work: the worktree is put back exactly as it was.
775
+ */
776
+ function agentChecks(worktreeDir, req, settings, timeoutMs, testCmd) {
777
+ const commandFor = (check, fallback) => {
778
+ const profile = check.profile || fallback;
779
+ if (!profile)
780
+ return settings.agent || "";
781
+ const known = (settings.profileAgents || {})[profile];
782
+ if (known)
783
+ return known;
784
+ try {
785
+ // A profile only a check names is not resolved with the others.
786
+ return (0, HarnessConfigFile_1.resolveProfileAgent)(worktreeDir, profile);
787
+ }
788
+ catch {
789
+ return settings.agent || "";
790
+ }
791
+ };
792
+ const promptFor = (instruction) => (0, prompt_1.buildPrompt)(req, worktreeDir, {
793
+ promptPrefix: settings.promptPrefix,
794
+ hint: instruction,
795
+ withPrecedents: settings.promptPrecedents === true,
796
+ });
797
+ return {
798
+ "builtin:review": (check) => {
799
+ const command = commandFor(check, settings.reviewProfile || "");
800
+ if (!command)
801
+ return { ok: true, output: "no agent configured to review" };
802
+ const diff = (git(worktreeDir, ["diff", "HEAD"]).stdout || "").slice(0, 60000);
803
+ const restore = snapshotWorktree(worktreeDir);
804
+ let said = "";
805
+ try {
806
+ said = runAgentOnce(worktreeDir, command, promptFor(`Review only; change no file. Compare the change below with ${req.requirement}, its ` +
807
+ "statement and every scenario. Answer with a first line `VERDICT: PASS` when the " +
808
+ "change does what the requirement says and nothing it forbids, or `VERDICT: FAIL` " +
809
+ "followed by each reason as `- path:line — what is wrong`.\n\n```diff\n" +
810
+ diff +
811
+ "\n```"), timeoutMs, "review");
812
+ }
813
+ finally {
814
+ restore();
815
+ }
816
+ const verdict = /VERDICT:\s*(PASS|FAIL)/i.exec(said);
817
+ if (!verdict) {
818
+ return {
819
+ ok: true,
820
+ output: `The reviewer gave no verdict:
821
+ ${said.slice(0, 2000)}`,
822
+ };
823
+ }
824
+ return { ok: verdict[1].toUpperCase() === "PASS", output: said.slice(0, 4000) };
825
+ },
826
+ "builtin:adversary": (check) => {
827
+ const command = commandFor(check, settings.adversaryProfile || "");
828
+ if (!command || !testCmd)
829
+ return { ok: true, output: "no agent or test command to probe with" };
830
+ const restore = snapshotWorktree(worktreeDir);
831
+ try {
832
+ const before = new Set((git(worktreeDir, ["status", "--porcelain"]).stdout || "").split("\n").filter(Boolean));
833
+ const said = runAgentOnce(worktreeDir, command, promptFor(`Try to **break** the implementation of ${req.requirement}: write one additional ` +
834
+ "test, in the project's test style, for a case it should handle and might not — an " +
835
+ "empty input, a boundary, a duplicate, a failure path. Write the test and nothing " +
836
+ "else; do not modify the implementation, the specification or any existing test."), timeoutMs, "adversary");
837
+ const wrote = (git(worktreeDir, ["status", "--porcelain"]).stdout || "")
838
+ .split("\n")
839
+ .filter((l) => l && !before.has(l) && !l.slice(3).startsWith(EmptyAttempt_1.PROMPT_ARCHIVE_DIR));
840
+ if (wrote.length === 0)
841
+ return { ok: true, output: "The adversary wrote no test." };
842
+ const test = (0, node_child_process_1.spawnSync)((0, HarnessRun_1.substituteGateCommand)(testCmd, req), {
843
+ shell: true,
844
+ cwd: worktreeDir,
845
+ encoding: "utf8",
846
+ timeout: timeoutMs,
847
+ maxBuffer: SUBPROCESS_MAX_BUFFER,
848
+ });
849
+ return {
850
+ ok: test.status === 0,
851
+ output: [
852
+ `The adversary wrote ${wrote.map((l) => l.slice(3)).join(", ")}; the tests ${test.status === 0 ? "still pass" : "fail"}.`,
853
+ said.slice(0, 1500),
854
+ test.status === 0 ? "" : `${test.stdout || ""}${test.stderr || ""}`.slice(-3000),
855
+ ]
856
+ .filter(Boolean)
857
+ .join("\n\n"),
858
+ };
859
+ }
860
+ finally {
861
+ restore();
862
+ }
863
+ },
864
+ };
865
+ }
643
866
  /**
644
867
  * Let an adversary try to break a green implementation (D3).
645
868
  *
@@ -683,7 +906,10 @@ function runAdversarialProbe(worktreeDir, req, settings, timeoutMs, promptFor) {
683
906
  // case is telling you something mildly reassuring.
684
907
  return { ran: true, broke: false, detail: "The adversary wrote no test.", wrote: [] };
685
908
  }
686
- const after = runGate(worktreeDir, settings.testCmd, timeoutMs, req, settings);
909
+ const after = runGate(worktreeDir, settings.testCmd, timeoutMs, req, {
910
+ ...settings,
911
+ quality: undefined,
912
+ });
687
913
  return {
688
914
  ran: true,
689
915
  broke: !after.ok,
@@ -988,6 +1214,9 @@ function attemptRequirement(req, ctx) {
988
1214
  // commit — the precedent must be work already accepted, not a
989
1215
  // sibling requirement this same run happens to be writing.
990
1216
  withPrecedents: settings.promptPrecedents === true,
1217
+ e2eDir: settings.e2eDir && (0, screen_requirements_1.onScreen)(worktreeDir, req.requirement)
1218
+ ? settings.e2eDir
1219
+ : undefined,
991
1220
  }));
992
1221
  const promptFile = path.join(os.tmpdir(), `specgate-harness-prompt-${req.requirement}-${crypto.randomBytes(4).toString("hex")}.md`);
993
1222
  fs.writeFileSync(promptFile, prompt, "utf8");
@@ -1233,6 +1462,7 @@ function attemptRequirement(req, ctx) {
1233
1462
  // The probe never changes the verdict; it travels beside it so
1234
1463
  // `harness report` and the run record can show what it found.
1235
1464
  ...(probe.ran ? { adversarial: probe } : {}),
1465
+ ...(gate.checks ? { checks: gate.checks.map(({ output: _o, ...rest }) => rest) } : {}),
1236
1466
  };
1237
1467
  }
1238
1468
  // Every attempt is spent. Commit what the agent produced anyway, on the
@@ -1536,13 +1766,14 @@ function resumeFrom(worktreeDir, requirement) {
1536
1766
  return { attempt: 1, previousFailure: "" };
1537
1767
  }
1538
1768
  // Interrupted or exhausted? The two deserve different answers, and the branch
1539
- // says which: `preserveFailedAttempt` leaves a `wip(...): FAILED the gate`
1769
+ // says which: `preserveFailedAttempt` leaves a `chore(...): attempt FAILED the gate`
1540
1770
  // commit when the attempts run out, and an interruption leaves none.
1541
1771
  //
1542
1772
  // exhausted → the last attempt reached a verdict → resume at the next one
1543
1773
  // interrupted → it never did → re-run it, without charging the budget
1544
1774
  const head = git(worktreeDir, ["log", "-1", "--format=%s"]);
1545
- const lastCompleted = /^wip\([^)]*\): FAILED the gate/.test((head.stdout || "").trim());
1775
+ // `chore(…): attempt FAILED` since REQ-234; `wip(…): FAILED` on branches older versions left.
1776
+ const lastCompleted = /^(?:chore\([^)]*\): attempt|wip\([^)]*\):) FAILED the gate/.test((head.stdout || "").trim());
1546
1777
  const point = (0, ResumeState_1.resumePoint)(names, requirement, lastCompleted);
1547
1778
  if (!point.latest)
1548
1779
  return { attempt: 1, previousFailure: "" };
@@ -1563,7 +1794,7 @@ function preserveFailedAttempt(worktreeDir, req, failure) {
1563
1794
  const commit = git(worktreeDir, [
1564
1795
  "commit",
1565
1796
  "-m",
1566
- `wip(${req.requirement}): FAILED the gate — do not merge as is\n\n` +
1797
+ `chore(${req.requirement}): attempt FAILED the gate — do not merge as is\n\n` +
1567
1798
  `${firstLine}\n\n` +
1568
1799
  "Committed by `specgate harness run` so the attempt is reviewable rather than\n" +
1569
1800
  "discarded. The requirement is still Draft: `specgate apply` never ran.",
@@ -2086,6 +2317,14 @@ async function runChain(pending, ctx, hintByReq) {
2086
2317
  results.push(notAttempted(rest, rest.requirement, reason));
2087
2318
  }
2088
2319
  };
2320
+ // REQ-209: a stop asked while the last requirement ran starts no other.
2321
+ if (i > 0 && (0, harness_guard_1.takeStop)(projectDir)) {
2322
+ for (const rest of pending.slice(i)) {
2323
+ results.push(notAttempted(rest, rest.requirement, "a stop was asked"));
2324
+ }
2325
+ info(`stopped as asked: ${pending.length - i} requirement(s) not attempted`);
2326
+ break;
2327
+ }
2089
2328
  const [result] = await runLevels([req], ctx, {
2090
2329
  concurrency: 1,
2091
2330
  hintByReq,
@@ -2140,7 +2379,9 @@ async function runChain(pending, ctx, hintByReq) {
2140
2379
  const eventsFile = (0, harness_events_1.eventsFileFor)(projectDir, req.requirement);
2141
2380
  const attempt = Number(result.attempts) || undefined;
2142
2381
  (0, harness_events_1.appendEvent)(eventsFile, { type: "merge", into: "HEAD", branch }, req.requirement, attempt);
2143
- const gate = runGate(projectDir, ctx.settings.testCmd, ctx.timeoutMs, req, ctx.settings, (stage, res, extra) => (0, harness_events_1.appendEvent)(eventsFile, { type: "gate", stage: `post-merge ${stage}`, result: res, ...(extra || {}) }, req.requirement, attempt));
2382
+ const gate = runGate(projectDir, ctx.settings.testCmd, ctx.timeoutMs, req,
2383
+ // The checks judged the branch already; the post-merge gate is the suite's.
2384
+ { ...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));
2144
2385
  if (!gate.ok) {
2145
2386
  git(projectDir, ["reset", "-q", "--hard", "HEAD~1"]);
2146
2387
  Object.assign(result, {
@@ -2391,6 +2632,11 @@ async function runLevels(pending, ctx, opts) {
2391
2632
  mayStart: () => {
2392
2633
  if (stoppedBy)
2393
2634
  return false;
2635
+ // REQ-209: a stop asked while a requirement ran starts no other.
2636
+ if (started > 0 && (0, harness_guard_1.takeStop)(ctx.projectDir)) {
2637
+ stoppedBy = { stop: true, code: "stop_asked", reason: "a stop was asked" };
2638
+ return false;
2639
+ }
2394
2640
  const verdict = (0, RunBudget_1.budgetVerdict)(limits, { startedAt: budgetStartedAt, started }, Date.now());
2395
2641
  if (verdict.stop) {
2396
2642
  stoppedBy = verdict;
@@ -2450,6 +2696,9 @@ class RunCommand extends command_1.BaseCommand {
2450
2696
  const args = parseArgs(this.args);
2451
2697
  setJsonMode(args.format === "json");
2452
2698
  const projectDir = (0, project_root_1.resolveProjectDir)(args.projectDir, { requireSentinel: true });
2699
+ // REQ-211: a profile chosen for this run is its agent, ahead of the file's.
2700
+ if (args.profile && !args.agent)
2701
+ args.agent = (0, HarnessConfigFile_1.resolveProfileAgent)(projectDir, args.profile);
2453
2702
  const fileConfig = (0, HarnessConfigFile_1.readHarnessConfig)(projectDir);
2454
2703
  const settings = (0, HarnessConfigFile_1.resolveHarnessSettings)(fileConfig, args);
2455
2704
  // A fresh worktree has no dependencies. Unless told, install what the
@@ -2462,7 +2711,7 @@ class RunCommand extends command_1.BaseCommand {
2462
2711
  // may declare profiles without ever writing a `harness.config.yaml`.
2463
2712
  // Reaching them through the config reader made them silently absent
2464
2713
  // there — the same gap the cost hints had, found the same way.
2465
- if (settings.profileRules.length === 0) {
2714
+ if (settings.profileRules.length === 0 && !args.profile) {
2466
2715
  const rules = (0, HarnessConfigFile_1.readProfileRules)(projectDir);
2467
2716
  if (rules.length > 0) {
2468
2717
  settings.profileRules = rules;
@@ -2513,6 +2762,7 @@ class RunCommand extends command_1.BaseCommand {
2513
2762
  const hintByReq = new Map((plan.next_steps || []).map((s) => [s.requirement, s.hint]));
2514
2763
  if (args.dryRun) {
2515
2764
  process.stdout.write(ui.header("harness run", `dry run · ${pending.length} requirement(s) would be processed`) +
2765
+ (settings.agent ? ui.kv("Agent", settings.agent, 14) : "") +
2516
2766
  ui.kv("Setup", settings.setupCmd || "none", 14) +
2517
2767
  (args.chain ? ui.kv("Chain", "each green requirement merged into this branch", 14) : ""));
2518
2768
  for (const req of pending) {
@@ -2609,6 +2859,8 @@ class RunCommand extends command_1.BaseCommand {
2609
2859
  (holder ? ` on ${holder.reqs.join(", ")}` : "") +
2610
2860
  `. Wait for it to finish: its post-merge gate runs in this working copy.`);
2611
2861
  }
2862
+ // A stop left from an earlier run is not this run's (REQ-209).
2863
+ (0, harness_guard_1.takeStop)(projectDir);
2612
2864
  }
2613
2865
  // What the live view lists as up next (REQ-182).
2614
2866
  if (!process.env.SPECGATE_HARNESS_WORKER) {