@rsaglobaltech/specgate 0.24.0 → 0.26.0

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 (79) hide show
  1. package/CHANGELOG.md +91 -42
  2. package/dist/packages/core/src/domain/AgentPrompt.js +12 -0
  3. package/dist/packages/core/src/domain/AgentPrompt.js.map +1 -1
  4. package/dist/packages/core/src/domain/Clarification.js +178 -0
  5. package/dist/packages/core/src/domain/Clarification.js.map +1 -0
  6. package/dist/packages/core/src/domain/DraftChecklist.js +16 -16
  7. package/dist/packages/core/src/domain/DraftChecklist.js.map +1 -1
  8. package/dist/packages/core/src/domain/HarnessConfig.js.map +1 -1
  9. package/dist/packages/core/src/domain/HarnessRun.js.map +1 -1
  10. package/dist/packages/core/src/domain/RequirementPlan.js +11 -0
  11. package/dist/packages/core/src/domain/RequirementPlan.js.map +1 -1
  12. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js +15 -3
  13. package/dist/packages/core/src/infrastructure/ChangeWorkspace.js.map +1 -1
  14. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js +5 -0
  15. package/dist/packages/core/src/infrastructure/HarnessConfigFile.js.map +1 -1
  16. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js +4 -1
  17. package/dist/packages/core/src/infrastructure/IdsAcrossBranches.js.map +1 -1
  18. package/dist/packages/core/test/unit/clarification.test.js +161 -0
  19. package/dist/packages/core/test/unit/clarification.test.js.map +1 -0
  20. package/dist/packages/core/test/unit/rework.test.js +62 -0
  21. package/dist/packages/core/test/unit/rework.test.js.map +1 -0
  22. package/dist/scripts/answer.js +10 -0
  23. package/dist/scripts/answer.js.map +1 -0
  24. package/dist/scripts/ask.js +10 -0
  25. package/dist/scripts/ask.js.map +1 -0
  26. package/dist/scripts/brief_add.js +10 -0
  27. package/dist/scripts/brief_add.js.map +1 -0
  28. package/dist/scripts/cli/commands/change/ChangeCommand.js +124 -1
  29. package/dist/scripts/cli/commands/change/ChangeCommand.js.map +1 -1
  30. package/dist/scripts/cli/commands/harness/RunCommand.js +135 -5
  31. package/dist/scripts/cli/commands/harness/RunCommand.js.map +1 -1
  32. package/dist/scripts/cli/commands/spec/ClarifyCommands.js +275 -0
  33. package/dist/scripts/cli/commands/spec/ClarifyCommands.js.map +1 -0
  34. package/dist/scripts/cli/commands/spec/DraftCommand.js +44 -4
  35. package/dist/scripts/cli/commands/spec/DraftCommand.js.map +1 -1
  36. package/dist/scripts/cli/commands/spec/StatusCommand.js +5 -1
  37. package/dist/scripts/cli/commands/spec/StatusCommand.js.map +1 -1
  38. package/dist/scripts/cli/commands/spec/StudioCommand.js +194 -1
  39. package/dist/scripts/cli/commands/spec/StudioCommand.js.map +1 -1
  40. package/dist/scripts/docs/record-journey.js +1 -0
  41. package/dist/scripts/docs/record-journey.js.map +1 -1
  42. package/dist/scripts/docs/record-terminal.js +18 -0
  43. package/dist/scripts/docs/record-terminal.js.map +1 -1
  44. package/dist/scripts/draft/drafting-guide.js +11 -1
  45. package/dist/scripts/draft/drafting-guide.js.map +1 -1
  46. package/dist/scripts/harness/prompt.js +2 -0
  47. package/dist/scripts/harness/prompt.js.map +1 -1
  48. package/dist/scripts/lib/brief-add.js +197 -0
  49. package/dist/scripts/lib/brief-add.js.map +1 -0
  50. package/dist/scripts/lib/change-impact.js +156 -0
  51. package/dist/scripts/lib/change-impact.js.map +1 -0
  52. package/dist/scripts/lib/change-review.js +145 -0
  53. package/dist/scripts/lib/change-review.js.map +1 -0
  54. package/dist/scripts/lib/clarification.js +319 -0
  55. package/dist/scripts/lib/clarification.js.map +1 -0
  56. package/dist/scripts/lib/harness-guard.js +18 -0
  57. package/dist/scripts/lib/harness-guard.js.map +1 -1
  58. package/dist/scripts/lib/in-flight.js +17 -11
  59. package/dist/scripts/lib/in-flight.js.map +1 -1
  60. package/dist/scripts/lib/live-detail.js +3 -2
  61. package/dist/scripts/lib/live-detail.js.map +1 -1
  62. package/dist/scripts/lib/live-model.js +35 -4
  63. package/dist/scripts/lib/live-model.js.map +1 -1
  64. package/dist/scripts/lib/live-page.js +80 -15
  65. package/dist/scripts/lib/live-page.js.map +1 -1
  66. package/dist/scripts/lib/screen-requirements.js +149 -0
  67. package/dist/scripts/lib/screen-requirements.js.map +1 -0
  68. package/dist/scripts/lib/studio-app-page.js +960 -0
  69. package/dist/scripts/lib/studio-app-page.js.map +1 -0
  70. package/dist/scripts/lib/studio-app.js +362 -0
  71. package/dist/scripts/lib/studio-app.js.map +1 -0
  72. package/dist/scripts/lib/studio-route.js +933 -0
  73. package/dist/scripts/lib/studio-route.js.map +1 -0
  74. package/dist/scripts/lib/surface.js +52 -0
  75. package/dist/scripts/lib/surface.js.map +1 -1
  76. package/dist/scripts/questions.js +10 -0
  77. package/dist/scripts/questions.js.map +1 -0
  78. package/package.json +1 -1
  79. package/templates/harness/harness.config.yaml.tpl +10 -1
@@ -81,6 +81,8 @@ const prompt_1 = require("../../../harness/prompt");
81
81
  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
+ const clarification_1 = require("../../../lib/clarification");
85
+ const screen_requirements_1 = require("../../../lib/screen-requirements");
84
86
  const harness_guard_1 = require("../../../lib/harness-guard");
85
87
  const HarnessEvents_1 = require("../../../../packages/core/src/domain/HarnessEvents");
86
88
  /**
@@ -170,6 +172,8 @@ function usage() {
170
172
  "Runs the plan → agent → verify → done loop for every pending requirement.\n\n" +
171
173
  " --agent <cmd> Agent command; must contain the {prompt_file} placeholder.\n" +
172
174
  ' e.g. --agent "claude -p < {prompt_file}"\n' +
175
+ " --profile <name> Run this profile's agent from .harness/profiles.yaml instead\n" +
176
+ " of the configured one, for this run only.\n" +
173
177
  " --test-cmd <cmd> Project test command run as part of the gate (optional).\n" +
174
178
  " Substitutes {req}, {scenario} and {feature_file}, so the\n" +
175
179
  " gate can run the scenario under test.\n" +
@@ -251,6 +255,7 @@ function parseArgs(argv) {
251
255
  prCmd: "",
252
256
  setupCmd: "",
253
257
  chain: false,
258
+ profile: "",
254
259
  };
255
260
  for (let i = 0; i < argv.length; i += 1) {
256
261
  const token = argv[i];
@@ -260,6 +265,9 @@ function parseArgs(argv) {
260
265
  else if (token === "--agent") {
261
266
  args.agent = argv[++i] || "";
262
267
  }
268
+ else if (token === "--profile") {
269
+ args.profile = argv[++i] || "";
270
+ }
263
271
  else if (token === "--test-cmd") {
264
272
  args.testCmd = argv[++i] || "";
265
273
  }
@@ -397,6 +405,14 @@ function runPlan(projectDir) {
397
405
  // `JSON.parse` only ever throws a SyntaxError.
398
406
  throw new Error(`plan produced invalid JSON: ${err.message}`);
399
407
  }
408
+ // Read on the project itself: the judged copy has every open change
409
+ // archived into it, and no longer says which requirements were modified.
410
+ const modified = new Set((0, in_flight_1.inFlightRequirements)(projectDir)
411
+ .filter((r) => r.op === "modified")
412
+ .map((r) => r.id));
413
+ if (modified.size > 0 && Array.isArray(parsed.requirements)) {
414
+ parsed.requirements = (0, RequirementPlan_1.withRework)(parsed.requirements, modified);
415
+ }
400
416
  return parsed;
401
417
  }
402
418
  // Generous maxBuffer for captured subprocess output — Maven/Gradle first runs
@@ -502,6 +518,21 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
502
518
  hint: "",
503
519
  };
504
520
  }
521
+ // REQ-199: a requirement used on a screen needs an end-to-end test that
522
+ // names it, when the project says where those live.
523
+ if (settings.e2eDir && req.requirement && (0, screen_requirements_1.onScreen)(worktreeDir, req.requirement)) {
524
+ const named = (0, screen_requirements_1.e2eTestsNaming)(worktreeDir, settings.e2eDir, req.requirement);
525
+ report("e2e test", named.length ? "pass" : "fail");
526
+ if (named.length === 0) {
527
+ return {
528
+ ok: false,
529
+ stage: "e2e test",
530
+ output: `No file under ${settings.e2eDir} names ${req.requirement}.`,
531
+ hint: `${req.requirement} is used on a screen: add an end-to-end test under ` +
532
+ `${settings.e2eDir} that names it, driving the screen its scenarios describe.`,
533
+ };
534
+ }
535
+ }
505
536
  // ADR-0028: the architecture check runs between validation and the tests,
506
537
  // and its failure goes back to the agent like any other gate failure.
507
538
  const archCmd = String(settings.archCmd || "").trim();
@@ -529,7 +560,10 @@ function runGate(worktreeDir, testCmd, timeoutMs, req = {}, settings = {}, repor
529
560
  // Where the message stream will be, if there is one to have.
530
561
  let reportPath = settings.messageReport || "";
531
562
  if (!reportPath && (0, CucumberMessages_1.invokesCucumberDirectly)(resolved)) {
532
- reportPath = path.join(".harness", `messages-${req.requirement || "run"}-${crypto.randomBytes(3).toString("hex")}.ndjson`);
563
+ // Forward slashes on every platform: Cucumber reads `message:.harness\x`
564
+ // on Windows as a path it does not write, and the gate then had no
565
+ // stream to check and passed a run of no scenarios.
566
+ reportPath = path.posix.join(".harness", `messages-${req.requirement || "run"}-${crypto.randomBytes(3).toString("hex")}.ndjson`);
533
567
  fs.mkdirSync(path.join(worktreeDir, ".harness"), { recursive: true });
534
568
  resolved = (0, CucumberMessages_1.withMessageReport)(resolved, reportPath);
535
569
  }
@@ -951,7 +985,7 @@ function attemptRequirement(req, ctx) {
951
985
  event({
952
986
  type: "attempt",
953
987
  state: "end",
954
- result: stage === "pass" ? "pass" : "fail",
988
+ result: stage === "pass" ? "pass" : stage === "question" ? "question" : "fail",
955
989
  ...(stage === "pass" ? {} : { stage }),
956
990
  });
957
991
  attemptLog.push({
@@ -987,6 +1021,9 @@ function attemptRequirement(req, ctx) {
987
1021
  // commit — the precedent must be work already accepted, not a
988
1022
  // sibling requirement this same run happens to be writing.
989
1023
  withPrecedents: settings.promptPrecedents === true,
1024
+ e2eDir: settings.e2eDir && (0, screen_requirements_1.onScreen)(worktreeDir, req.requirement)
1025
+ ? settings.e2eDir
1026
+ : undefined,
990
1027
  }));
991
1028
  const promptFile = path.join(os.tmpdir(), `specgate-harness-prompt-${req.requirement}-${crypto.randomBytes(4).toString("hex")}.md`);
992
1029
  fs.writeFileSync(promptFile, prompt, "utf8");
@@ -1082,6 +1119,21 @@ function attemptRequirement(req, ctx) {
1082
1119
  break;
1083
1120
  if (stepFailed)
1084
1121
  continue;
1122
+ // The agent asked instead of guessing (REQ-189): another attempt would
1123
+ // meet the same question, and the gate has nothing to judge.
1124
+ const asked = questionAsked(eventsFile, attempt, attemptStart);
1125
+ if (asked) {
1126
+ warn(`${req.requirement}: the agent asked — ${asked}`);
1127
+ record("question");
1128
+ discardWorktreeChanges(worktreeDir);
1129
+ return {
1130
+ result: "question",
1131
+ attempts: attempt - firstAttempt + 1,
1132
+ attemptLog,
1133
+ question: asked,
1134
+ error: `Needs clarification: ${asked}`,
1135
+ };
1136
+ }
1085
1137
  // Before the gate: an agent that wrote nothing cannot have implemented
1086
1138
  // anything, and the gate would approve it. See `producedNothing` (H19).
1087
1139
  if (producedNothing(worktreeDir)) {
@@ -1242,6 +1294,55 @@ function attemptRequirement(req, ctx) {
1242
1294
  attemptLog,
1243
1295
  };
1244
1296
  }
1297
+ /** The question the agent recorded with `specgate ask` during this attempt, if any (REQ-189). */
1298
+ function questionAsked(eventsFile, attempt, startedMs) {
1299
+ let text = "";
1300
+ try {
1301
+ text = fs.readFileSync(eventsFile, "utf8");
1302
+ }
1303
+ catch {
1304
+ return null;
1305
+ }
1306
+ const since = Math.floor(startedMs / 1000) - 1;
1307
+ let asked = null;
1308
+ for (const line of text.split("\n")) {
1309
+ try {
1310
+ const e = JSON.parse(line);
1311
+ if (e.type === "question" && e.attempt === attempt && Number(e.t) >= since && e.text) {
1312
+ asked = String(e.text);
1313
+ }
1314
+ }
1315
+ catch {
1316
+ /* a line being written */
1317
+ }
1318
+ }
1319
+ return asked;
1320
+ }
1321
+ /**
1322
+ * The questions the run's agents asked, written into the project and
1323
+ * committed (REQ-189): the agent could not edit the specification, so the
1324
+ * harness does, in one commit per question, and the requirement waits.
1325
+ */
1326
+ function recordQuestions(projectDir, results) {
1327
+ for (const r of results) {
1328
+ if (r.result !== "question" || !r.question)
1329
+ continue;
1330
+ const asked = (0, clarification_1.ask)(projectDir, r.requirement, r.question);
1331
+ if (!asked.ok) {
1332
+ warn(`${r.requirement}: could not record its question — ${asked.message}`);
1333
+ continue;
1334
+ }
1335
+ git(projectDir, ["add", "-A", "--", "docs/specs", "spec.md"]);
1336
+ git(projectDir, [
1337
+ "commit",
1338
+ "-q",
1339
+ "-m",
1340
+ `docs(specs): ${r.requirement} needs clarification — ${asked.question}\n\n${r.question}`,
1341
+ ]);
1342
+ r.error = `Needs clarification: ${asked.question} in ${asked.file} — specgate answer ${asked.question} "…"`;
1343
+ info(`${r.requirement}: ${asked.question} recorded in ${asked.file}; answer it with specgate answer`);
1344
+ }
1345
+ }
1245
1346
  /** Where a run's record lands. Local to the machine — see `writeRunRecord`. */
1246
1347
  exports.RUNS_DIR = path.join(".harness", "runs");
1247
1348
  /**
@@ -1887,13 +1988,20 @@ function printReport(results, format, recorded) {
1887
1988
  fail: `${ui.c.bad}${ui.sym.fail}${ui.c.reset}`,
1888
1989
  blocked: `${ui.c.warn}${ui.sym.warn}${ui.c.reset}`,
1889
1990
  skipped: `${ui.c.muted}${ui.sym.empty}${ui.c.reset}`,
1991
+ question: `${ui.c.warn}?${ui.c.reset}`,
1992
+ };
1993
+ const COLOR = {
1994
+ pass: ui.c.ok,
1995
+ fail: ui.c.bad,
1996
+ blocked: ui.c.warn,
1997
+ skipped: ui.c.muted,
1998
+ question: ui.c.warn,
1890
1999
  };
1891
- const COLOR = { pass: ui.c.ok, fail: ui.c.bad, blocked: ui.c.warn, skipped: ui.c.muted };
1892
2000
  const idWidth = Math.max(9, ...results.map((r) => String(r.requirement).length));
1893
2001
  process.stdout.write("\n" + ui.section("Harness report"));
1894
2002
  for (const r of results) {
1895
2003
  process.stdout.write(` ${MARK[r.result] || MARK.fail} ${ui.c.bold}${ui.pad(String(r.requirement), idWidth)}${ui.c.reset} ` +
1896
- `${COLOR[r.result] || ""}${ui.pad(r.result, 8)}${ui.c.reset}` +
2004
+ `${COLOR[r.result] || ""}${ui.pad(r.result, 9)}${ui.c.reset}` +
1897
2005
  ui.muted(`${r.attempts} attempt${r.attempts === 1 ? "" : "s"}`) +
1898
2006
  ` ${ui.c.accent}${ui.sym.arrow} ${r.branch}${ui.c.reset}\n`);
1899
2007
  const sub = (t) => process.stdout.write(` ${t}\n`);
@@ -2014,6 +2122,14 @@ async function runChain(pending, ctx, hintByReq) {
2014
2122
  results.push(notAttempted(rest, rest.requirement, reason));
2015
2123
  }
2016
2124
  };
2125
+ // REQ-209: a stop asked while the last requirement ran starts no other.
2126
+ if (i > 0 && (0, harness_guard_1.takeStop)(projectDir)) {
2127
+ for (const rest of pending.slice(i)) {
2128
+ results.push(notAttempted(rest, rest.requirement, "a stop was asked"));
2129
+ }
2130
+ info(`stopped as asked: ${pending.length - i} requirement(s) not attempted`);
2131
+ break;
2132
+ }
2017
2133
  const [result] = await runLevels([req], ctx, {
2018
2134
  concurrency: 1,
2019
2135
  hintByReq,
@@ -2319,6 +2435,11 @@ async function runLevels(pending, ctx, opts) {
2319
2435
  mayStart: () => {
2320
2436
  if (stoppedBy)
2321
2437
  return false;
2438
+ // REQ-209: a stop asked while a requirement ran starts no other.
2439
+ if (started > 0 && (0, harness_guard_1.takeStop)(ctx.projectDir)) {
2440
+ stoppedBy = { stop: true, code: "stop_asked", reason: "a stop was asked" };
2441
+ return false;
2442
+ }
2322
2443
  const verdict = (0, RunBudget_1.budgetVerdict)(limits, { startedAt: budgetStartedAt, started }, Date.now());
2323
2444
  if (verdict.stop) {
2324
2445
  stoppedBy = verdict;
@@ -2378,6 +2499,9 @@ class RunCommand extends command_1.BaseCommand {
2378
2499
  const args = parseArgs(this.args);
2379
2500
  setJsonMode(args.format === "json");
2380
2501
  const projectDir = (0, project_root_1.resolveProjectDir)(args.projectDir, { requireSentinel: true });
2502
+ // REQ-211: a profile chosen for this run is its agent, ahead of the file's.
2503
+ if (args.profile && !args.agent)
2504
+ args.agent = (0, HarnessConfigFile_1.resolveProfileAgent)(projectDir, args.profile);
2381
2505
  const fileConfig = (0, HarnessConfigFile_1.readHarnessConfig)(projectDir);
2382
2506
  const settings = (0, HarnessConfigFile_1.resolveHarnessSettings)(fileConfig, args);
2383
2507
  // A fresh worktree has no dependencies. Unless told, install what the
@@ -2390,7 +2514,7 @@ class RunCommand extends command_1.BaseCommand {
2390
2514
  // may declare profiles without ever writing a `harness.config.yaml`.
2391
2515
  // Reaching them through the config reader made them silently absent
2392
2516
  // there — the same gap the cost hints had, found the same way.
2393
- if (settings.profileRules.length === 0) {
2517
+ if (settings.profileRules.length === 0 && !args.profile) {
2394
2518
  const rules = (0, HarnessConfigFile_1.readProfileRules)(projectDir);
2395
2519
  if (rules.length > 0) {
2396
2520
  settings.profileRules = rules;
@@ -2441,6 +2565,7 @@ class RunCommand extends command_1.BaseCommand {
2441
2565
  const hintByReq = new Map((plan.next_steps || []).map((s) => [s.requirement, s.hint]));
2442
2566
  if (args.dryRun) {
2443
2567
  process.stdout.write(ui.header("harness run", `dry run · ${pending.length} requirement(s) would be processed`) +
2568
+ (settings.agent ? ui.kv("Agent", settings.agent, 14) : "") +
2444
2569
  ui.kv("Setup", settings.setupCmd || "none", 14) +
2445
2570
  (args.chain ? ui.kv("Chain", "each green requirement merged into this branch", 14) : ""));
2446
2571
  for (const req of pending) {
@@ -2537,6 +2662,8 @@ class RunCommand extends command_1.BaseCommand {
2537
2662
  (holder ? ` on ${holder.reqs.join(", ")}` : "") +
2538
2663
  `. Wait for it to finish: its post-merge gate runs in this working copy.`);
2539
2664
  }
2665
+ // A stop left from an earlier run is not this run's (REQ-209).
2666
+ (0, harness_guard_1.takeStop)(projectDir);
2540
2667
  }
2541
2668
  // What the live view lists as up next (REQ-182).
2542
2669
  if (!process.env.SPECGATE_HARNESS_WORKER) {
@@ -2550,6 +2677,9 @@ class RunCommand extends command_1.BaseCommand {
2550
2677
  hintByReq,
2551
2678
  runOne: dispatchLevel,
2552
2679
  });
2680
+ // What the agents asked goes into the project, once the run is done (REQ-189).
2681
+ if (!process.env.SPECGATE_HARNESS_WORKER)
2682
+ recordQuestions(projectDir, results);
2553
2683
  // Only the run that a person started writes a record. A worker is one
2554
2684
  // slice of that run, and its own record would be a second copy of the
2555
2685
  // same requirement — `harness report` reads every file in the directory,