@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.
- package/CHANGELOG.md +87 -42
- package/README.md +6 -0
- package/dist/packages/core/src/domain/AgentPrompt.js +12 -0
- package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -1
- package/dist/packages/core/src/domain/ChangeMeasures.js +198 -0
- package/dist/packages/core/src/domain/ChangeMeasures.js.map +1 -0
- package/dist/packages/core/src/domain/Clarification.js +5 -0
- package/dist/packages/core/src/domain/Clarification.js.map +1 -1
- package/dist/packages/core/src/domain/ConventionalCommit.js +73 -0
- package/dist/packages/core/src/domain/ConventionalCommit.js.map +1 -0
- package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -1
- package/dist/packages/core/src/domain/HarnessReport.js +2 -0
- package/dist/packages/core/src/domain/HarnessReport.js.map +1 -1
- package/dist/packages/core/src/domain/QualityChecks.js +218 -0
- package/dist/packages/core/src/domain/QualityChecks.js.map +1 -0
- package/dist/packages/core/src/domain/QualitySummary.js +58 -0
- package/dist/packages/core/src/domain/QualitySummary.js.map +1 -0
- package/dist/packages/core/src/domain/RequirementPlan.js +11 -0
- package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -1
- package/dist/packages/core/src/domain/Sarif.js +121 -0
- package/dist/packages/core/src/domain/Sarif.js.map +1 -0
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +15 -3
- package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -1
- package/dist/packages/core/src/infrastructure/GitContributionStager.js +1 -1
- package/dist/packages/core/src/infrastructure/GitContributionStager.js.map +1 -1
- package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +26 -0
- package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
- package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js +4 -1
- package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js.map +1 -1
- package/dist/packages/core/src/infrastructure/QualityBaselineFile.js +67 -0
- package/dist/packages/core/src/infrastructure/QualityBaselineFile.js.map +1 -0
- package/dist/packages/core/test/unit/rework.test.js +62 -0
- package/dist/packages/core/test/unit/rework.test.js.map +1 -0
- package/dist/scripts/cli/commands/change/ChangeCommand.js +38 -1
- package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
- package/dist/scripts/cli/commands/change/TicketCommand.js +1 -1
- package/dist/scripts/cli/commands/change/TicketCommand.js.map +1 -1
- package/dist/scripts/cli/commands/harness/RunCommand.js +269 -17
- package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
- package/dist/scripts/cli/commands/quality/CheckCommand.js +54 -3
- package/dist/scripts/cli/commands/quality/CheckCommand.js.map +1 -1
- package/dist/scripts/cli/commands/quality/QualityCommand.js +314 -0
- package/dist/scripts/cli/commands/quality/QualityCommand.js.map +1 -0
- package/dist/scripts/cli/commands/spec/DoneCommand.js +4 -1
- package/dist/scripts/cli/commands/spec/DoneCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/DraftCommand.js +44 -4
- package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/StatusCommand.js +5 -1
- package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
- package/dist/scripts/cli/commands/spec/StudioCommand.js +208 -1
- package/dist/scripts/cli/commands/spec/StudioCommand.js.map +1 -1
- package/dist/scripts/docs/nav.js +13 -2
- package/dist/scripts/docs/nav.js.map +1 -1
- package/dist/scripts/docs/record-journey.js +1 -0
- package/dist/scripts/docs/record-journey.js.map +1 -1
- package/dist/scripts/docs/record-terminal.js +18 -0
- package/dist/scripts/docs/record-terminal.js.map +1 -1
- package/dist/scripts/draft/drafting-guide.js +11 -1
- package/dist/scripts/draft/drafting-guide.js.map +1 -1
- package/dist/scripts/harness/prompt.js +4 -0
- package/dist/scripts/harness/prompt.js.map +1 -1
- package/dist/scripts/harness/report.js +13 -0
- package/dist/scripts/harness/report.js.map +1 -1
- package/dist/scripts/lib/change-review.js +145 -0
- package/dist/scripts/lib/change-review.js.map +1 -0
- package/dist/scripts/lib/clarification.js +24 -5
- package/dist/scripts/lib/clarification.js.map +1 -1
- package/dist/scripts/lib/harness-guard.js +18 -0
- package/dist/scripts/lib/harness-guard.js.map +1 -1
- package/dist/scripts/lib/in-flight.js +17 -11
- package/dist/scripts/lib/in-flight.js.map +1 -1
- package/dist/scripts/lib/live-model.js +7 -1
- package/dist/scripts/lib/live-model.js.map +1 -1
- package/dist/scripts/lib/live-page.js +72 -11
- package/dist/scripts/lib/live-page.js.map +1 -1
- package/dist/scripts/lib/quality-presets.js +378 -0
- package/dist/scripts/lib/quality-presets.js.map +1 -0
- package/dist/scripts/lib/quality-run.js +579 -0
- package/dist/scripts/lib/quality-run.js.map +1 -0
- package/dist/scripts/lib/screen-requirements.js +149 -0
- package/dist/scripts/lib/screen-requirements.js.map +1 -0
- package/dist/scripts/lib/sonar-local.js +308 -0
- package/dist/scripts/lib/sonar-local.js.map +1 -0
- package/dist/scripts/lib/studio-app-page.js +1023 -0
- package/dist/scripts/lib/studio-app-page.js.map +1 -0
- package/dist/scripts/lib/studio-app.js +442 -0
- package/dist/scripts/lib/studio-app.js.map +1 -0
- package/dist/scripts/lib/studio-route.js +996 -0
- package/dist/scripts/lib/studio-route.js.map +1 -0
- package/dist/scripts/lib/surface.js +11 -0
- package/dist/scripts/lib/surface.js.map +1 -1
- package/dist/scripts/lib/ticket-keys.js +1 -1
- package/dist/scripts/lib/ticket-keys.js.map +1 -1
- package/dist/scripts/quality.js +10 -0
- package/dist/scripts/quality.js.map +1 -0
- package/package.json +1 -1
- 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
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 `
|
|
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
|
-
|
|
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
|
-
`
|
|
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,
|
|
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) {
|