@pmelab/gtd 59.0.0 → 59.0.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.
@@ -21,6 +21,10 @@ import {
21
21
  type TestRepo,
22
22
  type WorkflowModule,
23
23
  } from "../testkit/index.js"
24
+ import { assertContains, assertLacks, refusal, refusedWith, restDescription } from "./messages.js"
25
+
26
+ // Kept off the class so the exported world's type stays as it is.
27
+ const usedActors = new WeakMap<TestkitWorld, Set<string>>()
24
28
 
25
29
  export class TestkitWorld extends QuickPickleWorld {
26
30
  // Re-declared: under `nodenext` a subclass loses the base's members from TS's view.
@@ -88,6 +92,8 @@ export class TestkitWorld extends QuickPickleWorld {
88
92
 
89
93
  async drive(): Promise<void> {
90
94
  const actors: Record<string, Actor> = {}
95
+ const used = usedActors.get(this) ?? new Set<string>()
96
+ usedActors.set(this, used)
91
97
  for (const [step, actions] of this.actions) {
92
98
  actors[step] = async (beat, repo) => {
93
99
  for (const action of actions) await action(beat, repo)
@@ -115,7 +121,14 @@ export class TestkitWorld extends QuickPickleWorld {
115
121
  return hit[1]
116
122
  }
117
123
  }
118
- await this.repo().drive(actors)
124
+ const tracked: Record<string, Actor> = {}
125
+ for (const [step, actor] of Object.entries(actors)) {
126
+ tracked[step] = (beat, repo) => {
127
+ used.add(step)
128
+ return actor(beat, repo)
129
+ }
130
+ }
131
+ await this.repo().drive(tracked)
119
132
  }
120
133
  }
121
134
 
@@ -125,6 +138,29 @@ const withNewline = (content: string): string => (content.endsWith("\n") ? conte
125
138
 
126
139
  const args = (list: string): string[] => list.split(" ").filter((arg) => arg !== "")
127
140
 
141
+ const neverUsed = (world: TestkitWorld): string[] => {
142
+ const used = usedActors.get(world) ?? new Set<string>()
143
+ const scripted = new Set([
144
+ ...world.actions.keys(),
145
+ ...world.verdicts.keys(),
146
+ ...world.conditionalVerdicts.keys(),
147
+ ])
148
+ return [...scripted].filter((step) => !used.has(step))
149
+ }
150
+
151
+ /** An outcome step: a failure ends with where the process rests. */
152
+ const outcome =
153
+ <A extends unknown[]>(check: (world: TestkitWorld, ...rest: A) => unknown) =>
154
+ async (world: TestkitWorld, ...rest: A): Promise<void> => {
155
+ try {
156
+ await check(world, ...rest)
157
+ } catch (error) {
158
+ const failure = error instanceof Error ? error : new Error(String(error))
159
+ failure.message += `\n\n${await restDescription(() => world.repo(), neverUsed(world))}`
160
+ throw failure
161
+ }
162
+ }
163
+
128
164
  Given("a gtd config with:", (world: TestkitWorld, source: string) => {
129
165
  world.configure(withNewline(source))
130
166
  })
@@ -337,74 +373,86 @@ When("the process is driven", async (world: TestkitWorld) => {
337
373
 
338
374
  Then(
339
375
  "opening door {string} is refused with {string}",
340
- async (world: TestkitWorld, door: string, message: string) => {
341
- await assert.rejects(world.repo().door(door), (error: Error) => error.message.includes(message))
342
- },
376
+ outcome(async (world, door: string, message: string) => {
377
+ await refusedWith(world.repo().door(door), message)
378
+ }),
343
379
  )
344
380
 
345
381
  Then(
346
382
  "starting workflow {string} with {string} is refused with {string}",
347
- async (world: TestkitWorld, workflow: string, list: string, message: string) => {
348
- await assert.rejects(world.repo().start(workflow, ...args(list)), (error: Error) =>
349
- error.message.includes(message),
350
- )
351
- },
383
+ outcome(async (world, workflow: string, list: string, message: string) => {
384
+ await refusedWith(world.repo().start(workflow, ...args(list)), message)
385
+ }),
352
386
  )
353
387
 
354
- Then("landing is refused with {string}", async (world: TestkitWorld, message: string) => {
355
- await assert.rejects(world.repo().land(), (error: Error) => error.message.includes(message))
356
- })
388
+ Then(
389
+ "landing is refused with {string}",
390
+ outcome(async (world, message: string) => {
391
+ await refusedWith(world.repo().land(), message)
392
+ }),
393
+ )
357
394
 
358
395
  Then(
359
396
  "driving the process is refused with {string}",
360
- async (world: TestkitWorld, message: string) => {
361
- await assert.rejects(world.drive(), (error: Error) => error.message.includes(message))
362
- },
397
+ outcome(async (world, message: string) => {
398
+ await refusedWith(world.drive(), message)
399
+ }),
363
400
  )
364
401
 
365
402
  Then(
366
403
  "driving the process is refused without {string}",
367
- async (world: TestkitWorld, message: string) => {
368
- await assert.rejects(world.drive(), (error: Error) => !error.message.includes(message))
369
- },
404
+ outcome(async (world, message: string) => {
405
+ const got = await refusal(world.drive(), `without "${message}"`)
406
+ if (got.includes(message))
407
+ assert.fail(`expected a refusal without "${message}", but it was refused with:\n${got}`)
408
+ }),
370
409
  )
371
410
 
372
- Then("the process rests at {string}", async (world: TestkitWorld, step: string) => {
373
- assert.strictEqual((await world.repo().next()).state, step)
374
- })
411
+ Then(
412
+ "the process rests at {string}",
413
+ outcome(async (world, step: string) => {
414
+ assert.strictEqual((await world.repo().next()).state, step)
415
+ }),
416
+ )
375
417
 
376
- Then("the resting step's content contains {string}", async (world: TestkitWorld, text: string) => {
377
- const { content } = await world.repo().next()
378
- assert.ok(content.includes(text), content)
379
- })
418
+ Then(
419
+ "the resting step's content contains {string}",
420
+ outcome(async (world, text: string) => {
421
+ assertContains("the resting step's content", text, (await world.repo().next()).content)
422
+ }),
423
+ )
380
424
 
381
425
  Then(
382
426
  "the resting step's content contains the hash of the commit {string}",
383
- async (world: TestkitWorld, subject: string) => {
427
+ outcome(async (world, subject: string) => {
384
428
  const repo = world.repo()
385
429
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
386
430
  assert.ok(hash !== undefined, `no commit "${subject}"`)
387
- const { content } = await repo.next()
388
- assert.ok(content.includes(hash), content)
389
- },
431
+ assertContains("the resting step's content", hash, (await repo.next()).content)
432
+ }),
390
433
  )
391
434
 
392
435
  Then(
393
436
  "the resting step's content does not contain {string}",
394
- async (world: TestkitWorld, text: string) => {
395
- const { content } = await world.repo().next()
396
- assert.ok(!content.includes(text), content)
397
- },
437
+ outcome(async (world, text: string) => {
438
+ assertLacks("the resting step's content", text, (await world.repo().next()).content)
439
+ }),
398
440
  )
399
441
 
400
- Then("the last commit subject is {string}", (world: TestkitWorld, subject: string) => {
401
- assert.strictEqual(world.repo().subjects().at(-1), subject)
402
- })
442
+ Then(
443
+ "the last commit subject is {string}",
444
+ outcome((world, subject: string) => {
445
+ assert.strictEqual(world.repo().subjects().at(-1), subject)
446
+ }),
447
+ )
403
448
 
404
- Then("the commit subjects end with:", (world: TestkitWorld, list: string) => {
405
- const expected = list.split("\n").filter((line) => line.trim() !== "")
406
- assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
407
- })
449
+ Then(
450
+ "the commit subjects end with:",
451
+ outcome((world, list: string) => {
452
+ const expected = list.split("\n").filter((line) => line.trim() !== "")
453
+ assert.deepStrictEqual(world.repo().subjects().slice(-expected.length), expected)
454
+ }),
455
+ )
408
456
 
409
457
  const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
410
458
  const result = await world.repo().gtd(...args(list))
@@ -414,61 +462,77 @@ const outputOf = async (world: TestkitWorld, list: string): Promise<string> => {
414
462
 
415
463
  Then(
416
464
  "the output of gtd {string} contains {string}",
417
- async (world: TestkitWorld, list: string, text: string) => {
418
- const out = await outputOf(world, list)
419
- assert.ok(out.includes(text), out)
420
- },
465
+ outcome(async (world, list: string, text: string) => {
466
+ assertContains(`the output of gtd ${list}`, text, await outputOf(world, list))
467
+ }),
421
468
  )
422
469
 
423
470
  Then(
424
471
  "the output of gtd {string} does not contain {string}",
425
- async (world: TestkitWorld, list: string, text: string) => {
426
- const out = await outputOf(world, list)
427
- assert.ok(!out.includes(text), out)
428
- },
472
+ outcome(async (world, list: string, text: string) => {
473
+ assertLacks(`the output of gtd ${list}`, text, await outputOf(world, list))
474
+ }),
429
475
  )
430
476
 
431
477
  Then(
432
478
  "the output lines of gtd {string} start with:",
433
- async (world: TestkitWorld, list: string, prefixes: string) => {
479
+ outcome(async (world, list: string, prefixes: string) => {
434
480
  const out = (await outputOf(world, list)).split("\n").filter((line) => line !== "")
435
481
  const expected = prefixes.split("\n").filter((line) => line.trim() !== "")
436
- assert.strictEqual(out.length, expected.length, out.join("\n"))
437
- out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), out.join("\n")))
438
- },
482
+ const sides = `expected lines starting with:\n${expected.join("\n")}\ngot:\n${out.join("\n")}`
483
+ assert.strictEqual(out.length, expected.length, sides)
484
+ out.forEach((line, i) => assert.ok(line.startsWith(expected[i]!), sides))
485
+ }),
439
486
  )
440
487
 
441
- Then("{string} contains {string}", (world: TestkitWorld, path: string, text: string) => {
442
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
443
- })
488
+ Then(
489
+ "{string} contains {string}",
490
+ outcome((world, path: string, text: string) => {
491
+ assertContains(`"${path}"`, text, world.repo().read(path))
492
+ }),
493
+ )
444
494
 
445
- Then("{string} contains:", (world: TestkitWorld, path: string, text: string) => {
446
- assert.ok(world.repo().read(path)?.includes(text), `${path}: ${world.repo().read(path)}`)
447
- })
495
+ Then(
496
+ "{string} contains:",
497
+ outcome((world, path: string, text: string) => {
498
+ assertContains(`"${path}"`, text, world.repo().read(path))
499
+ }),
500
+ )
448
501
 
449
502
  Then(
450
503
  "{string} contains the short hash of the commit {string}",
451
- (world: TestkitWorld, path: string, subject: string) => {
504
+ outcome((world, path: string, subject: string) => {
452
505
  const repo = world.repo()
453
506
  const hash = repo.hashes()[repo.subjects().lastIndexOf(subject)]
454
507
  assert.ok(hash !== undefined, `no commit "${subject}"`)
455
- assert.ok(repo.read(path)?.includes(hash.slice(0, 7)), `${path}: ${repo.read(path)}`)
456
- },
508
+ assertContains(`"${path}"`, hash.slice(0, 7), repo.read(path))
509
+ }),
457
510
  )
458
511
 
459
512
  Then(
460
513
  "{string} contains {string} exactly once",
461
- (world: TestkitWorld, path: string, text: string) => {
514
+ outcome((world, path: string, text: string) => {
462
515
  const content = world.repo().read(path) ?? ""
463
- assert.strictEqual(content.split(text).length - 1, 1, `${path}: ${content}`)
464
- },
516
+ const times = content.split(text).length - 1
517
+ assert.strictEqual(
518
+ times,
519
+ 1,
520
+ `"${path}" contains "${text}" ${times} times; it holds:\n${content}`,
521
+ )
522
+ }),
465
523
  )
466
524
 
467
- Then("{string} does not contain {string}", (world: TestkitWorld, path: string, text: string) => {
468
- const content = world.repo().read(path)
469
- assert.ok(content !== undefined && !content.includes(text), `${path}: ${content}`)
470
- })
525
+ Then(
526
+ "{string} does not contain {string}",
527
+ outcome((world, path: string, text: string) => {
528
+ assertLacks(`"${path}"`, text, world.repo().read(path))
529
+ }),
530
+ )
471
531
 
472
- Then("{string} does not exist", (world: TestkitWorld, path: string) => {
473
- assert.strictEqual(world.repo().read(path), undefined)
474
- })
532
+ Then(
533
+ "{string} does not exist",
534
+ outcome((world, path: string) => {
535
+ const content = world.repo().read(path)
536
+ assert.strictEqual(content, undefined, `"${path}" exists; it holds:\n${content}`)
537
+ }),
538
+ )
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { agent, human } from "../flows/index.js"
3
+ import { testRepo } from "../testkit/index.js"
4
+ import { assertContains, assertLacks, refusedWith, restDescription } from "./messages.js"
5
+
6
+ const flow = async (): Promise<void> => {
7
+ await agent("plan", "Write PLAN.md.\nKeep it short.\n3\n4\n5\n6\n7")
8
+ await human("sign-off")
9
+ }
10
+
11
+ const thrown = async (fn: () => unknown): Promise<string> => {
12
+ try {
13
+ await fn()
14
+ } catch (error) {
15
+ return (error as Error).message
16
+ }
17
+ throw new Error("did not throw")
18
+ }
19
+
20
+ describe("restDescription", () => {
21
+ it("names the step, its kind and actor, the content's head, the last commits and the unused actors", async () => {
22
+ const repo = testRepo({ workflow: { default: flow } })
23
+ repo.write("go", "\n")
24
+ await repo.drive({ plan: (_, r) => r.write("PLAN.md", "p\n") })
25
+ expect(await restDescription(() => repo, ["repair", "ready"])).toBe(
26
+ [
27
+ "where the process rests:",
28
+ ' step "sign-off" (message, human)',
29
+ " content:",
30
+ " | sign-off",
31
+ " last commits:",
32
+ " chore: initial commit",
33
+ " gtd(agent): plan → sign-off",
34
+ ' actors never used: "repair", "ready"',
35
+ ].join("\n"),
36
+ )
37
+ })
38
+
39
+ it("cuts long content to its first lines", async () => {
40
+ const repo = testRepo({ workflow: { default: flow } })
41
+ repo.write("go", "\n")
42
+ const description = await restDescription(() => repo, [])
43
+ expect(description).toContain('step "plan" (prompt, agent)')
44
+ expect(description).toContain(" | 5\n | … 2 more lines")
45
+ expect(description).not.toContain("actors never used")
46
+ })
47
+
48
+ it("says why the rest is unknown instead of throwing", async () => {
49
+ const description = await restDescription(() => {
50
+ throw new Error("no gtd config")
51
+ }, [])
52
+ expect(description).toBe("where the process rests:\n unknown — no gtd config")
53
+ })
54
+ })
55
+
56
+ describe("refusedWith", () => {
57
+ it("says nothing was refused when the attempt succeeds", async () => {
58
+ expect(await thrown(() => refusedWith(Promise.resolve(), "nope"))).toBe(
59
+ 'expected a refusal with "nope", but nothing was refused',
60
+ )
61
+ })
62
+
63
+ it("prints the refusal it got when the text is missing", async () => {
64
+ expect(await thrown(() => refusedWith(Promise.reject(new Error("other")), "nope"))).toBe(
65
+ 'expected a refusal with "nope", but it was refused with:\nother',
66
+ )
67
+ })
68
+
69
+ it("passes on a refusal carrying the text", async () => {
70
+ await refusedWith(Promise.reject(new Error("a nope b")), "nope")
71
+ })
72
+ })
73
+
74
+ describe("contains", () => {
75
+ it("prints the text looked for and what was there", async () => {
76
+ expect(await thrown(() => assertContains('"a.md"', "x", "y\n"))).toBe(
77
+ '"a.md" does not contain "x"; it holds:\ny\n',
78
+ )
79
+ expect(await thrown(() => assertLacks('"a.md"', "y", "y\n"))).toBe(
80
+ '"a.md" contains "y"; it holds:\ny\n',
81
+ )
82
+ })
83
+
84
+ it("says a missing file does not exist", async () => {
85
+ expect(await thrown(() => assertContains('"a.md"', "x", undefined))).toBe(
86
+ '"a.md" does not exist, so it cannot contain "x"',
87
+ )
88
+ expect(await thrown(() => assertLacks('"a.md"', "x", undefined))).toBe('"a.md" does not exist')
89
+ })
90
+ })
@@ -0,0 +1,84 @@
1
+ import assert from "node:assert"
2
+ import type { TestRepo } from "../testkit/index.js"
3
+
4
+ const CONTENT_LINES = 5
5
+ const SUBJECTS = 5
6
+
7
+ const reason = (error: unknown): string => (error instanceof Error ? error.message : String(error))
8
+
9
+ const beatLines = async (repo: TestRepo): Promise<string[]> => {
10
+ try {
11
+ const beat = await repo.next()
12
+ const content = beat.content.trimEnd().split("\n")
13
+ const more = content.length - CONTENT_LINES
14
+ return [
15
+ `step "${beat.state}" (${beat.kind}, ${beat.actor}${beat.idle ? ", idle" : ""})`,
16
+ "content:",
17
+ ...content.slice(0, CONTENT_LINES).map((line) => ` | ${line}`),
18
+ ...(more > 0 ? [` | … ${more} more lines`] : []),
19
+ ]
20
+ } catch (error) {
21
+ return [`unknown — gtd next failed: ${reason(error)}`]
22
+ }
23
+ }
24
+
25
+ /** Where the process rests, for a failed outcome to end with. Never throws: it describes a failure. */
26
+ export const restDescription = async (
27
+ repo: () => TestRepo,
28
+ neverUsed: readonly string[],
29
+ ): Promise<string> => {
30
+ let lines: string[]
31
+ try {
32
+ const r = repo()
33
+ lines = [
34
+ ...(await beatLines(r)),
35
+ "last commits:",
36
+ ...r
37
+ .subjects()
38
+ .slice(-SUBJECTS)
39
+ .map((subject) => ` ${subject}`),
40
+ ]
41
+ } catch (error) {
42
+ lines = [`unknown — ${reason(error)}`]
43
+ }
44
+ if (neverUsed.length > 0)
45
+ lines.push(`actors never used: ${neverUsed.map((step) => `"${step}"`).join(", ")}`)
46
+ return ["where the process rests:", ...lines.map((line) => ` ${line}`)].join("\n")
47
+ }
48
+
49
+ /** Both sides of a failed `contains`: the text looked for, and what was there. */
50
+ const containsFailure = (
51
+ where: string,
52
+ text: string,
53
+ content: string | undefined,
54
+ expected: boolean,
55
+ ): string =>
56
+ content === undefined
57
+ ? `${where} does not exist${expected ? `, so it cannot contain "${text}"` : ""}`
58
+ : `${where} ${expected ? "does not contain" : "contains"} "${text}"; it holds:\n${content}`
59
+
60
+ /** What `attempt` was refused with; fails when it was not refused at all. */
61
+ export const refusal = async (attempt: Promise<unknown>, expectation: string): Promise<string> => {
62
+ try {
63
+ await attempt
64
+ } catch (error) {
65
+ return error instanceof Error ? error.message : String(error)
66
+ }
67
+ assert.fail(`expected a refusal ${expectation}, but nothing was refused`)
68
+ }
69
+
70
+ export const refusedWith = async (attempt: Promise<unknown>, message: string): Promise<void> => {
71
+ const got = await refusal(attempt, `with "${message}"`)
72
+ if (!got.includes(message))
73
+ assert.fail(`expected a refusal with "${message}", but it was refused with:\n${got}`)
74
+ }
75
+
76
+ export const assertContains = (where: string, text: string, content: string | undefined): void => {
77
+ if (content === undefined || !content.includes(text))
78
+ assert.fail(containsFailure(where, text, content, true))
79
+ }
80
+
81
+ export const assertLacks = (where: string, text: string, content: string | undefined): void => {
82
+ if (content === undefined || content.includes(text))
83
+ assert.fail(containsFailure(where, text, content, false))
84
+ }
@@ -16,13 +16,14 @@ import {
16
16
  agent,
17
17
  check,
18
18
  human,
19
+ judge,
19
20
  moveScript,
20
21
  read,
21
22
  removeScript,
22
23
  run,
23
24
  showScript,
24
25
  } from "../flows/index.js"
25
- import { runsItsScript, testRepo } from "./TestRepo.js"
26
+ import { runsItsScript, testRepo, type Actor } from "./TestRepo.js"
26
27
 
27
28
  const tiny = async (): Promise<void> => {
28
29
  await agent("plan", "Write PLAN.md.")
@@ -201,7 +202,51 @@ describe("testRepo", () => {
201
202
  let n = 0
202
203
  await expect(
203
204
  repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
204
- ).rejects.toThrow(/did not rest within 5 beats/)
205
+ ).rejects.toThrow(
206
+ "did not rest within 5 beats; it passed through:\nspin → spin → spin → spin → spin",
207
+ )
208
+ })
209
+
210
+ describe("judge verdicts", () => {
211
+ const judged = async (): Promise<void> => {
212
+ await agent("plan", "Plan.")
213
+ const { answers } = await judge("ready", {
214
+ questions: [{ id: "ready", primitive: "noul", instructions: "Ready?", criteria: "" }],
215
+ evidence: { plan: "p" },
216
+ })
217
+ await human(answers.ready?.answer === "yes" ? "ship" : "rework")
218
+ }
219
+ const plan: Actor = (_, r) => r.write("PLAN.md", "p\n")
220
+ const template =
221
+ 'expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:\n[{ "id": "ready", "answer": true, "p": 0.9 }]'
222
+
223
+ it("echoes a refused verdict and the shape it should have", async () => {
224
+ const repo = testRepo({ workflow: { default: judged } })
225
+ repo.write("go", "\n")
226
+ const error = await repo
227
+ .drive({ plan, ready: () => '{"ready": true}' })
228
+ .catch((e: Error) => e)
229
+ expect((error as Error).message).toContain(
230
+ `the actor for judge step "ready" sent a verdict gtd refused:\n{"ready": true}\n${template}\ngtd judge answer exited 2\n`,
231
+ )
232
+ })
233
+
234
+ it("says an actor returned an object rather than no verdict", async () => {
235
+ const repo = testRepo({ workflow: { default: judged } })
236
+ repo.write("go", "\n")
237
+ const actor = (() => [{ id: "ready", answer: true, p: 0.9 }]) as unknown as Actor
238
+ await expect(repo.drive({ plan, ready: actor })).rejects.toThrow(
239
+ `the actor for judge step "ready" returned an array, not a JSON string — JSON.stringify it\n${template}`,
240
+ )
241
+ })
242
+
243
+ it("says an actor returned nothing", async () => {
244
+ const repo = testRepo({ workflow: { default: judged } })
245
+ repo.write("go", "\n")
246
+ await expect(repo.drive({ plan, ready: () => undefined })).rejects.toThrow(
247
+ `the actor for judge step "ready" returned no verdict\n${template}`,
248
+ )
249
+ })
205
250
  })
206
251
 
207
252
  describe("canned commands", () => {
@@ -204,6 +204,30 @@ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void
204
204
  const failed = (what: string, result: CapturedCliResult): Error =>
205
205
  new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`)
206
206
 
207
+ const ANSWER: Readonly<Record<string, string>> = {
208
+ noul: "true",
209
+ choice: '"<option>"',
210
+ score: "0.5",
211
+ }
212
+
213
+ /** The verdict a judge beat's questions take, as a template to copy. */
214
+ const expectedVerdict = (doc: BeatDocument): string => {
215
+ let questions: readonly { id: string; primitive: string }[] = []
216
+ try {
217
+ questions = (JSON.parse(doc.judge ?? "") as { questions: typeof questions }).questions
218
+ } catch {
219
+ // An unreadable judge document leaves the template without entries.
220
+ }
221
+ const entries = questions.map(
222
+ ({ id, primitive }) =>
223
+ `{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? '"<answer>"'}, "p": 0.9 }`,
224
+ )
225
+ return [
226
+ "expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:",
227
+ `[${entries.join(", ")}]`,
228
+ ].join("\n")
229
+ }
230
+
207
231
  export const testRepo = (options: TestRepoOptions): TestRepo => {
208
232
  const repo = new InMemRepo()
209
233
  const env = { ...options.env }
@@ -261,11 +285,15 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
261
285
  }
262
286
 
263
287
  /** Runs a write command, then the script its `--json=script` twin prints. */
264
- const runWrite = async (args: readonly string[], stdin?: string): Promise<CapturedCliResult> => {
288
+ const runWrite = async (
289
+ args: readonly string[],
290
+ stdin?: string,
291
+ refused: (error: Error) => Error = (error) => error,
292
+ ): Promise<CapturedCliResult> => {
265
293
  const call = (...extra: string[]) =>
266
294
  stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
267
295
  const reported = await call()
268
- if (reported.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, reported)
296
+ if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
269
297
  const script = await call("--json=script")
270
298
  apply(`gtd ${args.join(" ")}`, script.stdout)
271
299
  return reported
@@ -286,16 +314,29 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
286
314
  }
287
315
 
288
316
  /** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
289
- const act = async (doc: BeatDocument, verdict: void | string): Promise<void> => {
317
+ const act = async (doc: BeatDocument, verdict: unknown): Promise<void> => {
290
318
  if (doc.actor !== "judge") {
291
319
  if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
292
320
  await runWrite(["land"])
293
321
  return
294
322
  }
323
+ const actor = `the actor for judge step "${doc.state}"`
324
+ if (verdict === undefined) {
325
+ throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
326
+ }
295
327
  if (typeof verdict !== "string") {
296
- throw new Error(`the actor for judge step "${doc.state}" returned no verdict`)
328
+ throw new Error(
329
+ `${actor} returned ${Array.isArray(verdict) ? "an array" : typeof verdict === "object" ? "an object" : `a ${typeof verdict}`}, not a JSON string — JSON.stringify it\n${expectedVerdict(doc)}`,
330
+ )
297
331
  }
298
- await runWrite(["judge", "answer"], verdict)
332
+ await runWrite(
333
+ ["judge", "answer"],
334
+ verdict,
335
+ (error) =>
336
+ new Error(
337
+ `${actor} sent a verdict gtd refused:\n${verdict}\n${expectedVerdict(doc)}\n${error.message}`,
338
+ ),
339
+ )
299
340
  }
300
341
 
301
342
  const self: TestRepo = {
@@ -327,8 +368,10 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
327
368
  land: () => runWrite(["land"]),
328
369
  next,
329
370
  drive: async (actors = {}, { maxBeats = 50 } = {}) => {
371
+ const passed: string[] = []
330
372
  for (let beat = 0; beat < maxBeats; beat++) {
331
373
  const doc = await next()
374
+ passed.push(doc.state)
332
375
  if (doc.kind === "stalled") return doc
333
376
  if (doc.kind === "capture") {
334
377
  await runWrite(["land"])
@@ -338,7 +381,9 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
338
381
  if (actor === undefined) return doc
339
382
  await act(doc, await actor(doc, self))
340
383
  }
341
- throw new Error(`the process did not rest within ${maxBeats} beats`)
384
+ throw new Error(
385
+ `the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
386
+ )
342
387
  },
343
388
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
344
389
  messages: () => repo.commitHistory().map(({ message }) => message),