@pmelab/gtd 27.6.0 → 27.7.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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "27.6.0",
3
+ "version": "27.7.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -416,7 +416,7 @@ noted in steps 3 and 4 below.
416
416
  `gtd judge run --provider jev` asks TypeSafe's Jev instead, with the key in
417
417
  `TYPESAFE_API_KEY` and `JEV_BASE_URL` optionally overriding the endpoint;
418
418
  `gtd judge run --provider llm` asks the `claude` CLI on `PATH` (Claude Code
419
- users only; model `haiku`, `--model <name>` overrides) and reuses its login.
419
+ users only; model `sonnet`; `--model <name>` overrides) and reuses its login.
420
420
  With no `--provider`, `gtd judge run` picks jev when `TYPESAFE_API_KEY` is
421
421
  set and non-empty and no `--model` is given, else llm — never falling back
422
422
  across them; the `GTD_JUDGE_PROVIDER` / `GTD_JUDGE_MODEL` environment
@@ -431,7 +431,8 @@ noted in steps 3 and 4 below.
431
431
 
432
432
  gtd itself never calls a judge: `gtd loop`, the Claude Code mod and the
433
433
  reference driver answer these through `gtd judge run` (auto selection: jev
434
- when `TYPESAFE_API_KEY` is set, else `llm` via `claude`, default model haiku,
434
+ when `TYPESAFE_API_KEY` is set, else `llm` via `claude`, default model
435
+ sonnet, while the Claude Code mod's own judge subagent stays on `haiku` —
435
436
  `--model <name>` overrides); if that fails it shows you the message and
436
437
  stops. **The `llm` provider's `p` is self-reported by the model, not a
437
438
  measured probability, so a confidently wrong verdict can clear the 0.7 floor
@@ -41695,7 +41695,7 @@ const FLAGS = [
41695
41695
  help: [
41696
41696
  "(gtd land, with --cost) tag that cost's model",
41697
41697
  "(gtd judge run, llm answerer) the claude model; omitted:",
41698
- "GTD_JUDGE_MODEL, then judge.json, then haiku"
41698
+ "GTD_JUDGE_MODEL, then judge.json, then sonnet"
41699
41699
  ]
41700
41700
  },
41701
41701
  {
@@ -42088,7 +42088,7 @@ const COMMAND_ROWS = [
42088
42088
  "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
42089
42089
  "does not cover are left out. --provider jev asks TypeSafe",
42090
42090
  "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
42091
- "on PATH (Claude Code only), model haiku unless --model.",
42091
+ "on PATH (Claude Code only), model sonnet unless --model.",
42092
42092
  "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
42093
42093
  "judge.json, else jev when TYPESAFE_API_KEY is set and no",
42094
42094
  "model is given, else llm; --model likewise falls back to",
@@ -58805,7 +58805,7 @@ var DEFAULT_MODEL, TIMEOUT_MS, SYSTEM_PROMPT, fail$1, unreadable, levelsOf, opti
58805
58805
  var init_llm = __esmMin((() => {
58806
58806
  init_criteria();
58807
58807
  init_io();
58808
- DEFAULT_MODEL = "haiku";
58808
+ DEFAULT_MODEL = "sonnet";
58809
58809
  TIMEOUT_MS = 18e4;
58810
58810
  SYSTEM_PROMPT = "You are a strict, impartial judge. Answer each question from the supplied evidence alone.\nReply only with the structured answer; never use tools or ask questions.";
58811
58811
  fail$1 = (message) => {
package/dist/testkit.mjs CHANGED
@@ -10825,7 +10825,7 @@ const FLAGS = [
10825
10825
  help: [
10826
10826
  "(gtd land, with --cost) tag that cost's model",
10827
10827
  "(gtd judge run, llm answerer) the claude model; omitted:",
10828
- "GTD_JUDGE_MODEL, then judge.json, then haiku"
10828
+ "GTD_JUDGE_MODEL, then judge.json, then sonnet"
10829
10829
  ]
10830
10830
  },
10831
10831
  {
@@ -11218,7 +11218,7 @@ const COMMAND_ROWS = [
11218
11218
  "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
11219
11219
  "does not cover are left out. --provider jev asks TypeSafe",
11220
11220
  "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
11221
- "on PATH (Claude Code only), model haiku unless --model.",
11221
+ "on PATH (Claude Code only), model sonnet unless --model.",
11222
11222
  "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
11223
11223
  "judge.json, else jev when TYPESAFE_API_KEY is set and no",
11224
11224
  "model is given, else llm; --model likewise falls back to",
@@ -13076,18 +13076,22 @@ const recognizeGtdUncheck = (repo, block) => {
13076
13076
  * definition) keeps the `@inmem` tier green.
13077
13077
  */
13078
13078
  const recognizeDidNotRunComment = (block) => block === "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn" ? { kind: "noop" } : void 0;
13079
- /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
13079
+ /** `combinedScript`'s optional half. Reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`. Exception: under `git: false` any unrecognized inner block fails the outer script (git or not), so the in-memory twin knowingly diverges from `sh` rather than run git unseen. */
13080
13080
  const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0].replace(/\\n$/, "")}\n`;
13081
13081
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`;
13082
13082
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`;
13083
- const recognizePresentationSubshell = (repo, commands, block) => {
13083
+ const recognizePresentationSubshell = (repo, commands, block, git) => {
13084
13084
  if (!block.startsWith(PRESENTATION_SUBSHELL_PREFIX) || !block.endsWith(PRESENTATION_SUBSHELL_SUFFIX)) return;
13085
13085
  const inner = block.slice(PRESENTATION_SUBSHELL_PREFIX.length, block.length - PRESENTATION_SUBSHELL_SUFFIX.length);
13086
- const result = applyEmittedScript(repo, commands, inner);
13086
+ const result = applyEmittedScript(repo, commands, inner, { git });
13087
13087
  if (result.ok) return {
13088
13088
  kind: "noop",
13089
13089
  output: result.output
13090
13090
  };
13091
+ if (!git && result.error?.startsWith(UNRECOGNIZED_PREFIX)) return {
13092
+ kind: "failed",
13093
+ error: `${result.output}${result.error}`
13094
+ };
13091
13095
  return {
13092
13096
  kind: "noop",
13093
13097
  output: `${result.output}${PRESENTATION_FAILURE_TEXT}`
@@ -13194,17 +13198,17 @@ const recognizeWriteLines = (repo, block) => {
13194
13198
  return { kind: "applied" };
13195
13199
  };
13196
13200
  /** Every recognizer `applyEmittedScript` tries, first match wins — `recognizeScriptedCommand` stays last since it's the exact-hit fallback for anything the rest don't claim. */
13197
- const recognizersFor = (repo, commands) => [
13201
+ const recognizersFor = (repo, commands, git) => [
13198
13202
  (block) => recognizeSetFlags(block),
13199
13203
  (block) => recognizeDidNotRunComment(block),
13200
13204
  (block) => recognizePrecondition(repo, block),
13201
13205
  (block) => recognizeFileExistsGuard(repo, block),
13202
13206
  (block) => recognizeBinaryGuard(commands, block),
13203
- (block) => recognizeGitBuilders(repo, block),
13207
+ (block) => git ? recognizeGitBuilders(repo, block) : void 0,
13204
13208
  (block) => recognizeDeferNotes(repo, block),
13205
13209
  (block) => recognizeWriteLines(repo, block),
13206
13210
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
13207
- (block) => recognizePresentationSubshell(repo, commands, block),
13211
+ (block) => recognizePresentationSubshell(repo, commands, block, git),
13208
13212
  (block) => recognizeModeContradictionSkipNotice(block),
13209
13213
  (block) => recognizeModeContradictionCheck(repo, commands, block),
13210
13214
  (block) => recognizeGtdCheck(repo, block),
@@ -13227,7 +13231,7 @@ const recognizeBlock = (recognizers, block) => {
13227
13231
  * non-zero scripted-command exit — exactly like `set -euo pipefail` stops a
13228
13232
  * real script. No block after the failing one is applied.
13229
13233
  */
13230
- const applyEmittedScript = (repo, commands, script) => {
13234
+ const applyEmittedScript = (repo, commands, script, { git = true } = {}) => {
13231
13235
  const flow = applyFlowScript(repo, commands, script);
13232
13236
  if (flow !== void 0) return flow.ok ? {
13233
13237
  ok: true,
@@ -13239,10 +13243,10 @@ const applyEmittedScript = (repo, commands, script) => {
13239
13243
  output: flow.output,
13240
13244
  error: flow.error ?? ""
13241
13245
  };
13242
- return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script);
13246
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script, git);
13243
13247
  };
13244
- const applyBlocks = (repo, commands, script) => {
13245
- const recognizers = recognizersFor(repo, commands);
13248
+ const applyBlocks = (repo, commands, script, git) => {
13249
+ const recognizers = recognizersFor(repo, commands, git);
13246
13250
  let output = "";
13247
13251
  for (const block of splitBlocks(script)) {
13248
13252
  const outcome = recognizeBlock(recognizers, block);
@@ -13266,11 +13270,12 @@ const failedAt = (outcome, output) => {
13266
13270
  error: outcome.error
13267
13271
  };
13268
13272
  };
13273
+ const UNRECOGNIZED_PREFIX = "unrecognized script block: ";
13269
13274
  const unrecognized = (block, output = "") => ({
13270
13275
  ok: false,
13271
13276
  status: 1,
13272
13277
  output,
13273
- error: `unrecognized script block: ${block.split("\n")[0]}`
13278
+ error: `${UNRECOGNIZED_PREFIX}${block.split("\n")[0]}`
13274
13279
  });
13275
13280
  //#endregion
13276
13281
  //#region src/testkit/TestRepo.ts
@@ -13322,10 +13327,17 @@ const LINES = [
13322
13327
  repo.writeFile(dest, content);
13323
13328
  }]
13324
13329
  ];
13325
- const runLine = (repo, line) => {
13326
- const known = LINES.find(([pattern]) => pattern.test(line));
13327
- if (known === void 0) throw new Error(`testkit: cannot run \`${line}\` in memory — give the step an actor instead`);
13328
- known[1](repo, words(line));
13330
+ const knownLine = (line) => LINES.find(([pattern]) => pattern.test(line));
13331
+ const cannotRun = (error) => /* @__PURE__ */ new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`);
13332
+ const runLines = (repo, script) => {
13333
+ const lines = script.split("\n").map((line) => line.trim());
13334
+ const known = lines.map(knownLine);
13335
+ if (known.every(Boolean)) {
13336
+ known.forEach((k, i) => k[1](repo, words(lines[i])));
13337
+ return;
13338
+ }
13339
+ const applied = applyEmittedScript(repo, /* @__PURE__ */ new Map(), script, { git: false });
13340
+ if (!applied.ok) throw cannotRun(applied.error ?? "");
13329
13341
  };
13330
13342
  const failed = (what, result) => /* @__PURE__ */ new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`);
13331
13343
  const testRepo = (options) => {
@@ -13407,7 +13419,7 @@ const testRepo = (options) => {
13407
13419
  show: (ref, path) => repo.fileAtRef(ref, path) ?? void 0,
13408
13420
  runScript: (script) => {
13409
13421
  const flow = applyFlowScript(repo, /* @__PURE__ */ new Map(), script);
13410
- if (flow === void 0) for (const line of script.split("\n")) runLine(repo, line.trim());
13422
+ if (flow === void 0) runLines(repo, script);
13411
13423
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`);
13412
13424
  },
13413
13425
  gtd,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "27.6.0",
3
+ "version": "27.7.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
package/src/cli/Cli.ts CHANGED
@@ -241,7 +241,7 @@ const FLAGS: readonly FlagRow[] = [
241
241
  help: [
242
242
  "(gtd land, with --cost) tag that cost's model",
243
243
  "(gtd judge run, llm answerer) the claude model; omitted:",
244
- "GTD_JUDGE_MODEL, then judge.json, then haiku",
244
+ "GTD_JUDGE_MODEL, then judge.json, then sonnet",
245
245
  ],
246
246
  },
247
247
  {
@@ -673,7 +673,7 @@ const COMMAND_ROWS: readonly CommandRow[] = [
673
673
  "--answers <path> or GTD_JUDGE_ANSWERS; questions the file",
674
674
  "does not cover are left out. --provider jev asks TypeSafe",
675
675
  "(TYPESAFE_API_KEY). --provider llm asks the `claude` CLI",
676
- "on PATH (Claude Code only), model haiku unless --model.",
676
+ "on PATH (Claude Code only), model sonnet unless --model.",
677
677
  "No --provider: GTD_JUDGE_PROVIDER, then ~/.config/gtd/",
678
678
  "judge.json, else jev when TYPESAFE_API_KEY is set and no",
679
679
  "model is given, else llm; --model likewise falls back to",
@@ -630,6 +630,18 @@ describe("applyEmittedScript — Emit.ts's combinedScript (the whole stdout of a
630
630
  expect(repo.lastCommitMessage()).toBe("gtd(human): idle")
631
631
  })
632
632
 
633
+ it("fails an unrecognized optional block under git: false", () => {
634
+ const repo = new InMemRepo()
635
+ const result = applyEmittedScript(
636
+ repo,
637
+ NO_COMMANDS,
638
+ combinedScript("set -eu", "totally-unrecognized-command"),
639
+ { git: false },
640
+ )
641
+ expect(result.ok).toBe(false)
642
+ expect(result.error).toContain("totally-unrecognized-command")
643
+ })
644
+
633
645
  it("still fails loudly on an unrecognized block outside the presentation-only subshell", () => {
634
646
  const repo = new InMemRepo()
635
647
  const script = `${combinedScript("totally-unrecognized-required-command", "")}`
@@ -691,3 +703,32 @@ describe("applyEmittedScript — commitClean", () => {
691
703
  expect(snapshot(repo)).toEqual(before)
692
704
  })
693
705
  })
706
+
707
+ describe("applyEmittedScript — git: false reaches through the presentation subshell", () => {
708
+ it("refuses a git builder wrapped in combinedScript and changes nothing", () => {
709
+ const repo = new InMemRepo()
710
+ repo.writeFile("a.txt", "1")
711
+ const before = snapshot(repo)
712
+ const result = applyEmittedScript(
713
+ repo,
714
+ NO_COMMANDS,
715
+ combinedScript("set -eu", commitAll("chore: sneaky")),
716
+ { git: false },
717
+ )
718
+ expect(result.ok).toBe(false)
719
+ expect(result.error).toMatch(/unrecognized script block/)
720
+ expect(snapshot(repo)).toEqual(before)
721
+ })
722
+
723
+ it("still applies a non-git write-lines optional half", () => {
724
+ const repo = new InMemRepo()
725
+ const result = applyEmittedScript(
726
+ repo,
727
+ NO_COMMANDS,
728
+ combinedScript("set -eu", "mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'"),
729
+ { git: false },
730
+ )
731
+ expect(result).toMatchObject({ ok: true })
732
+ expect(repo.readFile("d/a.txt")).toBe("x\n")
733
+ })
734
+ })
@@ -563,7 +563,7 @@ const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | und
563
563
  const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
564
564
  block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
565
565
 
566
- /** `combinedScript`'s optional half. ALWAYS reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`, where the subshell's exit status is swallowed and must never fail the outer script. */
566
+ /** `combinedScript`'s optional half. Reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`. Exception: under `git: false` any unrecognized inner block fails the outer script (git or not), so the in-memory twin knowingly diverges from `sh` rather than run git unseen. */
567
567
  const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0]!.replace(/\\n$/, "")}\n`
568
568
  const PRESENTATION_SUBSHELL_PREFIX = `${PRESENTATION_ONLY_COMMENT}\n(\n`
569
569
  const PRESENTATION_SUBSHELL_SUFFIX = `\n) || ${PRESENTATION_FAILURE_WARNING}`
@@ -572,6 +572,7 @@ const recognizePresentationSubshell = (
572
572
  repo: InMemRepo,
573
573
  commands: ReadonlyMap<string, ScriptedCommand>,
574
574
  block: string,
575
+ git: boolean,
575
576
  ): BlockOutcome | undefined => {
576
577
  if (
577
578
  !block.startsWith(PRESENTATION_SUBSHELL_PREFIX) ||
@@ -583,8 +584,11 @@ const recognizePresentationSubshell = (
583
584
  PRESENTATION_SUBSHELL_PREFIX.length,
584
585
  block.length - PRESENTATION_SUBSHELL_SUFFIX.length,
585
586
  )
586
- const result = applyEmittedScript(repo, commands, inner)
587
+ const result = applyEmittedScript(repo, commands, inner, { git })
587
588
  if (result.ok) return { kind: "noop", output: result.output }
589
+ if (!git && result.error?.startsWith(UNRECOGNIZED_PREFIX)) {
590
+ return { kind: "failed", error: `${result.output}${result.error}` }
591
+ }
588
592
  return { kind: "noop", output: `${result.output}${PRESENTATION_FAILURE_TEXT}` }
589
593
  }
590
594
 
@@ -703,17 +707,18 @@ const recognizeWriteLines = (repo: InMemRepo, block: string): BlockOutcome | und
703
707
  const recognizersFor = (
704
708
  repo: InMemRepo,
705
709
  commands: ReadonlyMap<string, ScriptedCommand>,
710
+ git: boolean,
706
711
  ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
707
712
  (block) => recognizeSetFlags(block),
708
713
  (block) => recognizeDidNotRunComment(block),
709
714
  (block) => recognizePrecondition(repo, block),
710
715
  (block) => recognizeFileExistsGuard(repo, block),
711
716
  (block) => recognizeBinaryGuard(commands, block),
712
- (block) => recognizeGitBuilders(repo, block),
717
+ (block) => (git ? recognizeGitBuilders(repo, block) : undefined),
713
718
  (block) => recognizeDeferNotes(repo, block),
714
719
  (block) => recognizeWriteLines(repo, block),
715
720
  (block) => recognizeFailurePromptWrapper(repo, commands, block),
716
- (block) => recognizePresentationSubshell(repo, commands, block),
721
+ (block) => recognizePresentationSubshell(repo, commands, block, git),
717
722
  (block) => recognizeModeContradictionSkipNotice(block),
718
723
  (block) => recognizeModeContradictionCheck(repo, commands, block),
719
724
  (block) => recognizeGtdCheck(repo, block),
@@ -746,6 +751,7 @@ export const applyEmittedScript = (
746
751
  repo: InMemRepo,
747
752
  commands: ReadonlyMap<string, ScriptedCommand>,
748
753
  script: string,
754
+ { git = true }: { readonly git?: boolean } = {},
749
755
  ): AppliedScriptResult => {
750
756
  const flow = applyFlowScript(repo, commands, script)
751
757
  if (flow !== undefined) {
@@ -753,15 +759,16 @@ export const applyEmittedScript = (
753
759
  ? { ok: true, status: 0, output: flow.output }
754
760
  : { ok: false, status: 1, output: flow.output, error: flow.error ?? "" }
755
761
  }
756
- return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script)
762
+ return script.startsWith("#!") ? unrecognized(script) : applyBlocks(repo, commands, script, git)
757
763
  }
758
764
 
759
765
  const applyBlocks = (
760
766
  repo: InMemRepo,
761
767
  commands: ReadonlyMap<string, ScriptedCommand>,
762
768
  script: string,
769
+ git: boolean,
763
770
  ): AppliedScriptResult => {
764
- const recognizers = recognizersFor(repo, commands)
771
+ const recognizers = recognizersFor(repo, commands, git)
765
772
  let output = ""
766
773
  for (const block of splitBlocks(script)) {
767
774
  const outcome = recognizeBlock(recognizers, block)
@@ -786,9 +793,11 @@ const failedAt = (
786
793
  }
787
794
  }
788
795
 
796
+ const UNRECOGNIZED_PREFIX = "unrecognized script block: "
797
+
789
798
  const unrecognized = (block: string, output = ""): AppliedScriptResult => ({
790
799
  ok: false,
791
800
  status: 1,
792
801
  output,
793
- error: `unrecognized script block: ${block.split("\n")[0]}`,
802
+ error: `${UNRECOGNIZED_PREFIX}${block.split("\n")[0]}`,
794
803
  })
@@ -1,4 +1,17 @@
1
1
  import { describe, expect, it } from "vitest"
2
+ import {
3
+ commitAll,
4
+ combinedScript,
5
+ commitAsIs,
6
+ commitClean,
7
+ deleteRef,
8
+ discardPending,
9
+ hardResetTo,
10
+ keepTurn,
11
+ mixedResetTo,
12
+ softResetTo,
13
+ updateRef,
14
+ } from "../emit/index.js"
2
15
  import { agent, human, moveScript, read, removeScript, run } from "../flows/index.js"
3
16
  import { runsItsScript, testRepo } from "./TestRepo.js"
4
17
 
@@ -93,7 +106,72 @@ describe("testRepo", () => {
93
106
 
94
107
  it("refuses to fake a script it cannot run", () => {
95
108
  const repo = testRepo({ workflow: { default: tiny } })
96
- expect(() => repo.runScript("npm test\n")).toThrow(/cannot run `npm test` in memory/)
109
+ expect(() => repo.runScript("npm test\n")).toThrow(/cannot run script in memory[\s\S]*npm test/)
110
+ })
111
+
112
+ it("carries the recognizer's error when a git-free fallback fails", () => {
113
+ const repo = testRepo({ workflow: { default: tiny } })
114
+ expect(() => repo.runScript("printf 'x' > 'y'\n")).toThrow(
115
+ "testkit: cannot run script in memory — give the step an actor instead\nunrecognized script block: printf 'x' > 'y'",
116
+ )
117
+ })
118
+
119
+ it("names the block that failed, not an earlier line that ran", () => {
120
+ const repo = testRepo({ workflow: { default: tiny } })
121
+ let message = ""
122
+ try {
123
+ repo.runScript("mkdir -p d\nprintf '%s\\n' 'x' > 'd/a.txt'\n\nnpm test\n")
124
+ } catch (e) {
125
+ message = (e as Error).message
126
+ }
127
+ expect(message).toContain("unrecognized script block: npm test")
128
+ expect(message).not.toContain("printf")
129
+ })
130
+
131
+ it.each([
132
+ ["commitAll", commitAll("chore: sneaky")],
133
+ ["commitClean", commitClean("chore: sneaky")],
134
+ ["commitAsIs", commitAsIs("chore: sneaky")],
135
+ ["keepTurn", keepTurn("refs/x", "chore: sneaky")],
136
+ ["discardPending", discardPending()],
137
+ ["softResetTo", softResetTo("HEAD~1")],
138
+ ["mixedResetTo", mixedResetTo("HEAD~1")],
139
+ ["hardResetTo", hardResetTo("HEAD~1")],
140
+ ["updateRef", updateRef("refs/x", "HEAD")],
141
+ ["deleteRef", deleteRef("refs/x")],
142
+ ])("refuses a %s script instead of mutating git in memory", (_, script) => {
143
+ const repo = testRepo({ workflow: { default: tiny } })
144
+ repo.write("a.txt", "dirty\n")
145
+ const before = repo.subjects()
146
+ expect(() => repo.runScript(script)).toThrow(/unrecognized script block/)
147
+ expect(repo.subjects()).toEqual(before)
148
+ })
149
+
150
+ it.each([
151
+ ["commitAll", commitAll("chore: sneaky")],
152
+ ["commitClean", commitClean("chore: sneaky")],
153
+ ["commitAsIs", commitAsIs("chore: sneaky")],
154
+ ["keepTurn", keepTurn("refs/x", "chore: sneaky")],
155
+ ["discardPending", discardPending()],
156
+ ["softResetTo", softResetTo("HEAD~1")],
157
+ ["mixedResetTo", mixedResetTo("HEAD~1")],
158
+ ["hardResetTo", hardResetTo("HEAD~1")],
159
+ ["updateRef", updateRef("refs/x", "HEAD")],
160
+ ["deleteRef", deleteRef("refs/x")],
161
+ ])("refuses a wrapped %s script instead of mutating git in memory", (_, script) => {
162
+ const repo = testRepo({ workflow: { default: tiny } })
163
+ repo.write("a.txt", "dirty\n")
164
+ const before = repo.subjects()
165
+ expect(() => repo.runScript(combinedScript("set -eu", script))).toThrow(
166
+ /cannot run script in memory[\s\S]*unrecognized script block/,
167
+ )
168
+ expect(repo.subjects()).toEqual(before)
169
+ })
170
+
171
+ it("applies a write block `LINES` lacks through the git-free recognizers", () => {
172
+ const repo = testRepo({ workflow: { default: tiny } })
173
+ repo.runScript("mkdir -p d\nprintf '%s\\n' 'echo $(git rev-parse HEAD)' > 'd/a.txt'\n")
174
+ expect(repo.read("d/a.txt")).toBe("echo $(git rev-parse HEAD)\n")
97
175
  })
98
176
 
99
177
  it("reads a file as committed", () => {
@@ -150,12 +150,23 @@ const LINES: ReadonlyArray<readonly [RegExp, (repo: InMemRepo, words: string[])
150
150
  ],
151
151
  ]
152
152
 
153
- const runLine = (repo: InMemRepo, line: string): void => {
154
- const known = LINES.find(([pattern]) => pattern.test(line))
155
- if (known === undefined) {
156
- throw new Error(`testkit: cannot run \`${line}\` in memory — give the step an actor instead`)
153
+ const knownLine = (line: string) => LINES.find(([pattern]) => pattern.test(line))
154
+
155
+ const cannotRun = (error: string): Error =>
156
+ new Error(`testkit: cannot run script in memory — give the step an actor instead\n${error}`)
157
+
158
+ // A script with a line `LINES` lacks goes to the contract-tested recognizers
159
+ // whole, minus the git builders: they would apply a commit/reset/update-ref in
160
+ // memory, so any git-mutating block is refused whatever its spelling.
161
+ const runLines = (repo: InMemRepo, script: string): void => {
162
+ const lines = script.split("\n").map((line) => line.trim())
163
+ const known = lines.map(knownLine)
164
+ if (known.every(Boolean)) {
165
+ known.forEach((k, i) => k![1](repo, words(lines[i]!)))
166
+ return
157
167
  }
158
- known[1](repo, words(line))
168
+ const applied = applyEmittedScript(repo, new Map(), script, { git: false })
169
+ if (!applied.ok) throw cannotRun(applied.error ?? "")
159
170
  }
160
171
 
161
172
  const failed = (what: string, result: CapturedCliResult): Error =>
@@ -251,7 +262,7 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
251
262
  show: (ref, path) => repo.fileAtRef(ref, path) ?? undefined,
252
263
  runScript: (script) => {
253
264
  const flow = applyFlowScript(repo, new Map(), script)
254
- if (flow === undefined) for (const line of script.split("\n")) runLine(repo, line.trim())
265
+ if (flow === undefined) runLines(repo, script)
255
266
  else if (!flow.ok) throw new Error(`testkit: ${flow.error ?? ""}`)
256
267
  },
257
268
  gtd,
@@ -70,7 +70,15 @@ export class TestkitWorld extends QuickPickleWorld {
70
70
  for (const action of actions) await action(beat, repo)
71
71
  }
72
72
  }
73
- for (const [step, verdict] of this.verdicts) actors[step] = () => verdict
73
+ for (const [step, verdict] of this.verdicts) {
74
+ // A verdict would overwrite the queued actions, silently skipping their checks.
75
+ if (this.actions.has(step)) {
76
+ throw new Error(
77
+ `step "${step}" has a judge verdict and queued actions; the actions would never run`,
78
+ )
79
+ }
80
+ actors[step] = () => verdict
81
+ }
74
82
  await this.repo().drive(actors)
75
83
  }
76
84
  }
@@ -120,6 +128,19 @@ Given(
120
128
  },
121
129
  )
122
130
 
131
+ // A standing assertion like any queued action: it applies on every later visit
132
+ // to the step until `from now on` clears the list.
133
+ Given(
134
+ "step {string} is prompted with {string}",
135
+ (world: TestkitWorld, step: string, text: string) => {
136
+ world.act(step, (beat) => {
137
+ if (!beat.content.includes(text)) {
138
+ throw new Error(`step "${step}" was not prompted with "${text}":\n${beat.content}`)
139
+ }
140
+ })
141
+ },
142
+ )
143
+
123
144
  Given("step {string} runs its script", (world: TestkitWorld, step: string) => {
124
145
  world.act(step, runsItsScript)
125
146
  })
@@ -207,6 +228,28 @@ Then("the commit subjects end with:", (world: TestkitWorld, list: string) => {
207
228
  assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
208
229
  })
209
230
 
231
+ const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
232
+ const result = await world.repo().gtd(...args(list))
233
+ assert.strictEqual(result.exitCode, 0, result.stderr)
234
+ return result.stdout
235
+ }
236
+
237
+ Then(
238
+ "the output of gtd {string} contains {string}",
239
+ async (world: TestkitWorld, list: string, text: string) => {
240
+ const out = await outputOf(world, list)
241
+ assert.ok(out.includes(text), out)
242
+ },
243
+ )
244
+
245
+ Then(
246
+ "the output of gtd {string} does not contain {string}",
247
+ async (world: TestkitWorld, list: string, text: string) => {
248
+ const out = await outputOf(world, list)
249
+ assert.ok(!out.includes(text), out)
250
+ },
251
+ )
252
+
210
253
  Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
211
254
  assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
212
255
  })
@@ -1,3 +1,4 @@
1
+ @inmem
1
2
  Feature: testkit drives any workflow against the real engine
2
3
 
3
4
  A workflow the scenario writes itself, never a shipped one: scripted actors
@@ -47,6 +48,8 @@ Feature: testkit drives any workflow against the real engine
47
48
  gtd(judge): ready → ship
48
49
  """
49
50
  And "PLAN.md" contains "add two numbers"
51
+ And the output of gtd "next" contains "ship"
52
+ And the output of gtd "next" does not contain "rework"
50
53
 
51
54
  Scenario: a red check sends the process to repair, and an unscripted step is where it rests
52
55
  Given step "plan" writes "PLAN.md" with:
@@ -63,6 +66,38 @@ Feature: testkit drives any workflow against the real engine
63
66
  And the resting step's content contains "Make the suite pass."
64
67
  And the last commit subject is "gtd(check): check → repair"
65
68
 
69
+ Scenario: a step's beat content is asserted as a queued actor
70
+ Given step "plan" is prompted with "Write PLAN.md."
71
+ And step "plan" writes "PLAN.md" with:
72
+ """
73
+ add two numbers
74
+ """
75
+ And step "check" changes nothing
76
+ And judge step "ready" answers:
77
+ """
78
+ [{ "id": "ready", "answer": true, "p": 0.9 }]
79
+ """
80
+ When gtd starts workflow "tiny"
81
+ And the process is driven
82
+ Then the process rests at "ship"
83
+ And "PLAN.md" contains "add two numbers"
84
+
85
+ Scenario: a judge step with queued actions refuses the drive
86
+ Given step "plan" changes nothing
87
+ And step "check" changes nothing
88
+ And step "ready" is prompted with "never seen"
89
+ And judge step "ready" answers:
90
+ """
91
+ [{ "id": "ready", "answer": true, "p": 0.9 }]
92
+ """
93
+ When gtd starts workflow "tiny"
94
+ Then driving the process is refused with "queued actions"
95
+
96
+ Scenario: a beat that lacks the expected text refuses the drive
97
+ Given step "plan" is prompted with "Write a poem"
98
+ When gtd starts workflow "tiny"
99
+ Then driving the process is refused with 'was not prompted with "Write a poem"'
100
+
66
101
  Scenario: a start the workflow refuses is reported with its message
67
102
  Then starting workflow "picky" with "--var mode=bad" is refused with "picky refuses every start"
68
103