@pmelab/gtd 58.2.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.
Files changed (150) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +120 -395
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
  4. package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
  5. package/dist/flows.mjs +1 -1
  6. package/dist/gtd.bundle.mjs +484 -497
  7. package/dist/testkit/gherkin.mjs +145 -65
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +1 -1
  10. package/package.json +3 -42
  11. package/skills/authoring/SKILL.md +34 -27
  12. package/src/cli/Cli.test.ts +148 -71
  13. package/src/cli/Cli.ts +384 -463
  14. package/src/cli/Help.test.ts +47 -0
  15. package/src/cli/Help.ts +70 -0
  16. package/src/cli/Program.test.ts +127 -33
  17. package/src/cli/Program.ts +70 -25
  18. package/src/edge/Edge.ts +4 -4
  19. package/src/emit/Emit.test.ts +1 -1
  20. package/src/emit/Emit.ts +1 -1
  21. package/src/flags/index.test.ts +0 -13
  22. package/src/flags/index.ts +1 -3
  23. package/src/flows/factory.ts +3 -1
  24. package/src/gherkin/index.ts +135 -71
  25. package/src/gherkin/messages.test.ts +90 -0
  26. package/src/gherkin/messages.ts +84 -0
  27. package/src/install/Install.test.ts +14 -11
  28. package/src/install/Install.ts +38 -15
  29. package/src/install/index.ts +1 -2
  30. package/src/replay/Replay.test.ts +1 -1
  31. package/src/replay/index.ts +1 -1
  32. package/src/step/planStart.ts +6 -1
  33. package/src/testkit/TestRepo.test.ts +47 -2
  34. package/src/testkit/TestRepo.ts +51 -6
  35. package/src/twins/EmittedScriptRecognizer.ts +5 -8
  36. package/src/twins/FlowScripts.ts +1 -1
  37. package/src/wire/Land.ts +1 -1
  38. package/src/workflow/index.ts +1 -0
  39. package/src/workflow/load.test.ts +138 -113
  40. package/src/workflow/load.ts +7 -7
  41. package/src/workflow/skills.test.ts +5 -5
  42. package/src/workflow/skills.ts +1 -1
  43. package/src/workflow/vars.test.ts +30 -3
  44. package/src/workflow/vars.ts +34 -2
  45. package/src/workflow-kit/index.ts +1 -1
  46. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  47. package/dist/chunks/review-Bfaror38.mjs +0 -427
  48. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  49. package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
  50. package/dist/workflows/cleanup.mjs +0 -36
  51. package/dist/workflows/feature.mjs +0 -1018
  52. package/dist/workflows/fix.mjs +0 -41
  53. package/dist/workflows/review.mjs +0 -45
  54. package/dist/workflows/rollout.mjs +0 -48
  55. package/dist/workflows.mjs +0 -40651
  56. package/src/install/ConfigScaffold.ts +0 -22
  57. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  58. package/src/workflows/cleanup/cleanup.feature +0 -315
  59. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  60. package/src/workflows/cleanup/cleanup.ts +0 -52
  61. package/src/workflows/cleanup/index.ts +0 -6
  62. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  63. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  64. package/src/workflows/feature/criteria-coverage.feature +0 -102
  65. package/src/workflows/feature/criteria.test.ts +0 -147
  66. package/src/workflows/feature/criteria.ts +0 -267
  67. package/src/workflows/feature/fast-path.feature +0 -93
  68. package/src/workflows/feature/feature.test.ts +0 -338
  69. package/src/workflows/feature/feature.ts +0 -156
  70. package/src/workflows/feature/green.ts +0 -19
  71. package/src/workflows/feature/index.ts +0 -18
  72. package/src/workflows/feature/lens-models.feature +0 -69
  73. package/src/workflows/feature/lens-review.feature +0 -566
  74. package/src/workflows/feature/naive-acceptance.feature +0 -147
  75. package/src/workflows/feature/naive-test.feature +0 -263
  76. package/src/workflows/feature/one-step-land.feature +0 -93
  77. package/src/workflows/feature/planning.ts +0 -41
  78. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  79. package/src/workflows/feature/refactor-first.feature +0 -158
  80. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  81. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  82. package/src/workflows/feature/refactor-review.feature +0 -1528
  83. package/src/workflows/feature/refactor.test.ts +0 -1220
  84. package/src/workflows/feature/refactor.ts +0 -368
  85. package/src/workflows/feature/release-flag.feature +0 -167
  86. package/src/workflows/feature/release-flag.test.ts +0 -49
  87. package/src/workflows/feature/release-flag.ts +0 -46
  88. package/src/workflows/feature/review-gate.feature +0 -241
  89. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  90. package/src/workflows/feature/risk-tier.feature +0 -351
  91. package/src/workflows/feature/seam-rejection.feature +0 -146
  92. package/src/workflows/feature/seam.feature +0 -297
  93. package/src/workflows/feature/seam.test.ts +0 -527
  94. package/src/workflows/feature/seam.ts +0 -426
  95. package/src/workflows/feature/standards.feature +0 -354
  96. package/src/workflows/feature/steps.test.ts +0 -65
  97. package/src/workflows/feature/steps.ts +0 -45
  98. package/src/workflows/feature/tier-pin.feature +0 -66
  99. package/src/workflows/feature/tier.test.ts +0 -166
  100. package/src/workflows/feature/tier.ts +0 -56
  101. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  102. package/src/workflows/fix/fix-quality.feature +0 -212
  103. package/src/workflows/fix/fix.feature +0 -79
  104. package/src/workflows/fix/fix.test.ts +0 -74
  105. package/src/workflows/fix/fix.ts +0 -52
  106. package/src/workflows/fix/index.ts +0 -6
  107. package/src/workflows/index.ts +0 -47
  108. package/src/workflows/review/index.ts +0 -6
  109. package/src/workflows/review/review-door.feature +0 -199
  110. package/src/workflows/review/review.test.ts +0 -27
  111. package/src/workflows/review/review.ts +0 -60
  112. package/src/workflows/rollout/index.ts +0 -6
  113. package/src/workflows/rollout/rollout.feature +0 -510
  114. package/src/workflows/rollout/rollout.test.ts +0 -263
  115. package/src/workflows/rollout/rollout.ts +0 -82
  116. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  117. package/src/workflows/shared/access.test.ts +0 -62
  118. package/src/workflows/shared/access.ts +0 -24
  119. package/src/workflows/shared/config.fixture.ts +0 -12
  120. package/src/workflows/shared/fences.ts +0 -24
  121. package/src/workflows/shared/health.test.ts +0 -148
  122. package/src/workflows/shared/health.ts +0 -191
  123. package/src/workflows/shared/learn.test.ts +0 -140
  124. package/src/workflows/shared/learn.ts +0 -201
  125. package/src/workflows/shared/lenses.test.ts +0 -39
  126. package/src/workflows/shared/lenses.ts +0 -294
  127. package/src/workflows/shared/options.test.ts +0 -157
  128. package/src/workflows/shared/options.ts +0 -92
  129. package/src/workflows/shared/progress.feature +0 -124
  130. package/src/workflows/shared/prose.ts +0 -263
  131. package/src/workflows/shared/recheck.feature +0 -60
  132. package/src/workflows/shared/registry.test.ts +0 -345
  133. package/src/workflows/shared/registry.ts +0 -183
  134. package/src/workflows/shared/reserved.ts +0 -11
  135. package/src/workflows/shared/review.test.ts +0 -1062
  136. package/src/workflows/shared/review.ts +0 -446
  137. package/src/workflows/shared/skills.test.ts +0 -121
  138. package/src/workflows/shared/skills.ts +0 -49
  139. package/src/workflows/shared/standards.test.ts +0 -90
  140. package/src/workflows/shared/standards.ts +0 -156
  141. package/src/workflows/shared/steps.test.ts +0 -219
  142. package/src/workflows/shared/steps.ts +0 -201
  143. package/src/workflows/shared/tail.test.ts +0 -454
  144. package/src/workflows/shared/tail.ts +0 -264
  145. package/src/workflows/shared/text.fixture.ts +0 -128
  146. package/src/workflows/shared/text.test.ts +0 -1430
  147. package/src/workflows/shared/text.ts +0 -1700
  148. package/src/workflows/shared/threads.test.ts +0 -129
  149. package/src/workflows/shared/vars.ts +0 -47
  150. package/src/workflows/shared/workflows.steps.ts +0 -54
@@ -52,7 +52,9 @@ export const defineWorkflow = <S extends StandardSchemaV1>(
52
52
  // A factory call used to be a flow; awaiting one in a composed flow must fail, not no-op.
53
53
  // oxlint-disable-next-line unicorn/no-thenable -- awaiting a call must fail
54
54
  then: () => {
55
- throw new Error("a configured workflow is not a flow — compose fixFlow/reviewFlow instead")
55
+ throw new Error(
56
+ "a configured workflow is not a flow — await the flow functions it is built from instead",
57
+ )
56
58
  },
57
59
  })
58
60
  return Object.assign(factory, { [FACTORY]: internals }) as never
@@ -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
+ }
@@ -9,8 +9,13 @@ import { readFileSync } from "node:fs"
9
9
  import { resolve } from "node:path"
10
10
  import { createRequire } from "node:module"
11
11
  import { describe, expect, it } from "vitest"
12
- import { EDIT_COMMAND, DOOR_COMMAND, MINIMAL_DRIVER, renderBriefing } from "./Install.js"
13
- import { CONFIG_SCAFFOLD } from "./index.js"
12
+ import {
13
+ EDIT_COMMAND,
14
+ DOOR_COMMAND,
15
+ MINIMAL_DRIVER,
16
+ renderBriefing,
17
+ WORKFLOWS_REPO,
18
+ } from "./Install.js"
14
19
 
15
20
  const SUITE_PATHS = ["gtd-build", "gtd-edit", "gtd-door"]
16
21
  const SUPERSEDED_PATHS = ["gtd-fix", "gtd-review"]
@@ -110,6 +115,13 @@ describe("renderBriefing", () => {
110
115
  expect(briefing).not.toContain(".gtdrc")
111
116
  })
112
117
 
118
+ it("says gtd ships no workflow, offering gtd-workflows or the user's own", () => {
119
+ const briefing = renderBriefing()
120
+ expect(briefing).toContain("gtd ships no")
121
+ expect(briefing).toContain(`git clone ${WORKFLOWS_REPO}`)
122
+ expect(briefing).toMatch(/\*\*Their own workflow\.\*\*/)
123
+ })
124
+
113
125
  it("instructs committing the config before the first drive", () => {
114
126
  expect(renderBriefing()).toMatch(/commit (it|the config).{0,40}before.{0,20}drive/is)
115
127
  })
@@ -425,12 +437,3 @@ describe("DOOR_COMMAND", () => {
425
437
  expect(DOOR_COMMAND).toMatch(/usage: gtd-door <name> \[args\.\.\.\]" >&2\n\s*exit 2/)
426
438
  })
427
439
  })
428
-
429
- describe("bundled door lists", () => {
430
- it("the scaffold's exports stay feature, review, fix, never rollout or cleanup", () => {
431
- const code = CONFIG_SCAFFOLD.split("\n")
432
- .filter((l) => !l.startsWith("//"))
433
- .join("\n")
434
- expect(code).not.toMatch(/rollout|cleanup/)
435
- })
436
- })
@@ -1,6 +1,8 @@
1
1
  // `MINIMAL_DRIVER` is pinned equal to https://github.com/pmelab/gtd/blob/main/docs/driver.md's "A complete minimal driver" fenced bash block by `Install.test.ts`, so the two can never drift.
2
2
  import { GTD_VERSION } from "./Version.js"
3
3
 
4
+ export const WORKFLOWS_REPO = "https://github.com/pmelab/gtd-workflows"
5
+
4
6
  export const MINIMAL_DRIVER = `#!/usr/bin/env sh
5
7
  set -eu
6
8
 
@@ -82,7 +84,7 @@ while :; do
82
84
  system="$(gtd next --json=system)"
83
85
  validate="$(gtd next --json=validate)"
84
86
  log="$(gtd next --json=log)"
85
- # \`model\` is a hint, not a model name — the bundled workflow ships
87
+ # \`model\` is a hint, not a model name — gtd-workflows' default ships
86
88
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
87
89
  # from your environment; unset, the hint is dropped and the agent runs
88
90
  # its own default. Any other value is a real model name.
@@ -420,14 +422,37 @@ follow in the same numbered list.
420
422
  existing config, the project's own README/docs) and ask the user what
421
423
  they're trying to accomplish with gtd here — don't assume a fresh
422
424
  install or silently start a process on their behalf.
423
- 2. **Make sure a \`gtd.config.ts\` governs the repository.** Run at the
424
- repository root, \`gtd install\` writes one importing gtd's shipped
425
- workflows when none exists yet (its own message says so). Repository
426
- settings — steering-file modes, \`gtd ui\`, per-scope skills and access —
427
- go in its \`config\` export; workflow settings are options of the
428
- workflow factory it exports.
429
- 3. **Commit the config before the first drive.** \`gtd install\` leaves the
430
- file it writes uncommitted on purpose — an uncommitted config is a
425
+ 2. **Make sure a \`gtd.config.ts\` governs the repository.** gtd ships no
426
+ workflow: the config imports the workflows it runs. Ask the user which
427
+ way they want:
428
+ - **A workflow from ${WORKFLOWS_REPO}.** Its \`default\` is a feature →
429
+ review → fix workflow. It is used from a checkout pinned on this
430
+ machine:
431
+
432
+ \`\`\`sh
433
+ git clone ${WORKFLOWS_REPO} ~/.local/share/gtd/workflows
434
+ ~/.local/share/gtd/workflows/bin/gtd-wf pin origin/main # or a tag
435
+ ~/.local/share/gtd/workflows/bin/gtd-wf config default > gtd.config.ts
436
+ \`\`\`
437
+
438
+ \`pin\` checks the ref out and bundles each workflow; \`config\` prints a
439
+ config importing the bundle by its ABSOLUTE path. Tell the user what
440
+ that means: the committed config works only where the checkout sits at
441
+ that same path, so a teammate or CI needs their own checkout there (or
442
+ their own config). If a checkout exists already, \`gtd-wf status\`
443
+ shows its pin — reuse it rather than cloning again. Read the
444
+ repository's README for the workflows it offers and their options.
445
+ - **Their own workflow.** A workflow is TypeScript the config exports,
446
+ written against \`@pmelab/gtd/flows\` — see
447
+ https://github.com/pmelab/gtd/blob/main/docs/configuration.md, and
448
+ https://github.com/pmelab/gtd/blob/main/docs/testing.md to test it. A
449
+ copy of a gtd-workflows workflow (\`gtd-wf new <name> --from default\`
450
+ in a clone of it) is a good start.
451
+
452
+ Repository settings — steering-file modes, \`gtd ui\`, per-scope skills
453
+ and access — go in the config's \`config\` export; workflow settings are
454
+ options of the workflow factory it exports.
455
+ 3. **Commit the config before the first drive.** An uncommitted config is a
431
456
  pending change, so starting a process without committing it first turns
432
457
  the config into the very first thing the process plans against. Commit
433
458
  it, THEN start driving.
@@ -453,7 +478,8 @@ follow in the same numbered list.
453
478
  from every prompt line and writing no model exports, so every turn runs
454
479
  on the chosen CLI's own default tier.
455
480
  6. **If model hints are honored, resolve them to real model identifiers.**
456
- The bundled workflow ships \`plannerModel: smart\` and \`coderModel: base\`.
481
+ A workflow may declare model hints — gtd-workflows' \`default\` ships
482
+ \`plannerModel: smart\` and \`coderModel: base\`.
457
483
  **Those are opaque hints, not model names** — gtd never interprets them,
458
484
  and passing \`--model smart\` to a real CLI fails on the first prompt
459
485
  beat. Derive the chosen CLI's own available models at install time — its
@@ -550,11 +576,8 @@ hands off to \`gtd-build\`, which halts at the blocked gate and prints it,
550
576
  rather than starting the review; \`gtd-door fix\` on a GREEN suite is a no-op
551
577
  straight back to \`idle\` — the exec'd \`gtd-build\` exits immediately on it.
552
578
  \`gtd doors\` lists the doors a repository offers (those of the workflows its
553
- config exports — the bundled \`fix\` and \`review [base]\`, plus the opt-in
554
- \`rollout <flag>\` (turn a release flag default-on) and \`cleanup <flag>\`
555
- (remove it), which a config adds by exporting \`rollout\` from
556
- \`@pmelab/gtd/workflows/rollout\` and \`cleanup\` from
557
- \`@pmelab/gtd/workflows/cleanup\` — plus any it declares). Refuses with
579
+ config exports — gtd-workflows' \`default\` brings \`fix\` and
580
+ \`review [base]\` — plus any the config declares). Refuses with
558
581
  \`usage: gtd-door <name> [args...]\` on stderr and exit \`2\` when the name is
559
582
  missing — nothing was even attempted. Default path: \`~/.local/bin/gtd-door\`.
560
583
 
@@ -1,3 +1,2 @@
1
- export { renderBriefing } from "./Install.js"
2
- export { CONFIG_SCAFFOLD } from "./ConfigScaffold.js"
1
+ export { renderBriefing, WORKFLOWS_REPO } from "./Install.js"
3
2
  export { GTD_VERSION } from "./Version.js"
@@ -175,7 +175,7 @@ const restName = (outcome: ReplayOutcome): string => {
175
175
  }
176
176
 
177
177
  // Plain loops, a counter cap, and helper-driven branches: the shape the
178
- // bundled workflow has.
178
+ // workflow might have.
179
179
  const fixLoop = async () => {
180
180
  await human("idle", { file: "TODO.md" })
181
181
  for (let attempt = 1; ; attempt++) {
@@ -15,6 +15,6 @@ export {
15
15
  type CommitSpec,
16
16
  type JudgeVerdict,
17
17
  } from "./Trailers.js"
18
- export { accessShapeFault, foldAccess } from "./Access.js"
18
+ export { accessShapeFault } from "./Access.js"
19
19
  export { formatFault } from "./Format.js"
20
20
  export { refNameFault } from "./RefName.js"
@@ -4,6 +4,7 @@ import {
4
4
  ConfigDiscovery,
5
5
  ConfigService,
6
6
  multilineSetting,
7
+ ownFlagRefusal,
7
8
  resolveStartVars,
8
9
  StartFlags,
9
10
  type ConfigFingerprint,
@@ -110,7 +111,11 @@ export const planStart = (
110
111
  if (overrideRefusal !== undefined) return overrideRefusal
111
112
 
112
113
  const baseOf = workflow.def.base
113
- const vars = resolveStartVars(workflow.vars, yield* StartFlags, varOverrides, (yield* Host).env)
114
+ const flags = yield* StartFlags
115
+ const hostEnv = (yield* Host).env
116
+ const badFlag = ownFlagRefusal(workflow.vars, flags, hostEnv)
117
+ if (badFlag !== undefined) return refusal(`${commandLabel}: ${badFlag}`)
118
+ const vars = resolveStartVars(workflow.vars, flags, varOverrides, hostEnv)
114
119
  const multiline = multilineSetting(vars)
115
120
  if (multiline !== undefined) {
116
121
  return refusal(