@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +5 -5
- package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-07Z_ENch.mjs} +431 -446
- package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
- package/dist/flows.mjs +1 -1
- package/dist/gtd.bundle.mjs +410 -440
- package/dist/testkit/gherkin.mjs +145 -65
- package/dist/testkit.mjs +1 -1
- package/dist/wire.mjs +1 -1
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -2
- package/src/cli/Cli.test.ts +133 -52
- package/src/cli/Cli.ts +384 -461
- package/src/cli/Help.test.ts +47 -0
- package/src/cli/Help.ts +70 -0
- package/src/cli/Program.test.ts +67 -4
- package/src/cli/Program.ts +65 -18
- package/src/edge/Edge.ts +1 -1
- package/src/emit/Emit.test.ts +1 -1
- package/src/emit/Emit.ts +1 -1
- package/src/flows/factory.ts +3 -1
- package/src/gherkin/index.ts +135 -71
- package/src/gherkin/messages.test.ts +90 -0
- package/src/gherkin/messages.ts +84 -0
- package/src/testkit/TestRepo.test.ts +47 -2
- package/src/testkit/TestRepo.ts +51 -6
- package/src/twins/EmittedScriptRecognizer.ts +5 -8
- package/src/wire/Land.ts +1 -1
- package/src/workflow/load.ts +6 -6
- package/src/workflow/skills.test.ts +5 -5
- package/src/workflow/skills.ts +1 -1
package/src/gherkin/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
341
|
-
await
|
|
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
|
|
348
|
-
await
|
|
349
|
-
|
|
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(
|
|
355
|
-
|
|
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
|
|
361
|
-
await
|
|
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
|
|
368
|
-
await
|
|
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(
|
|
373
|
-
|
|
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(
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
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
|
-
|
|
388
|
-
|
|
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
|
|
395
|
-
|
|
396
|
-
|
|
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(
|
|
401
|
-
|
|
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(
|
|
405
|
-
|
|
406
|
-
|
|
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
|
|
418
|
-
|
|
419
|
-
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
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
|
|
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
|
-
|
|
437
|
-
out.
|
|
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(
|
|
442
|
-
|
|
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(
|
|
446
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
514
|
+
outcome((world, path: string, text: string) => {
|
|
462
515
|
const content = world.repo().read(path) ?? ""
|
|
463
|
-
|
|
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(
|
|
468
|
-
|
|
469
|
-
|
|
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(
|
|
473
|
-
|
|
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(
|
|
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", () => {
|
package/src/testkit/TestRepo.ts
CHANGED
|
@@ -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 (
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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),
|