@pmelab/gtd 59.0.0 → 60.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -38
  3. package/claude/hooks/doors.ts +8 -4
  4. package/dist/chunks/flows-DENt0uWV.mjs +707 -0
  5. package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
  6. package/dist/chunks/{testkit-CMBAAyRK.mjs → testkit-BvecVjQ0.mjs} +937 -772
  7. package/dist/chunks/{wire-C12dahrH.mjs → wire-DZ8R1RU9.mjs} +9 -2
  8. package/dist/flows.mjs +2 -676
  9. package/dist/gtd.bundle.mjs +1101 -1025
  10. package/dist/steering.mjs +2 -2
  11. package/dist/testkit/gherkin.mjs +371 -82
  12. package/dist/testkit.mjs +3 -2
  13. package/dist/web/main.js +234 -232
  14. package/dist/wire.mjs +2 -2
  15. package/dist/workflow-kit.mjs +3 -3
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +68 -61
  18. package/src/cli/Cli.test.ts +311 -229
  19. package/src/cli/Cli.ts +486 -585
  20. package/src/cli/Help.test.ts +47 -0
  21. package/src/cli/Help.ts +70 -0
  22. package/src/cli/History.ts +9 -4
  23. package/src/cli/Judges.ts +1 -1
  24. package/src/cli/Output.test.ts +35 -0
  25. package/src/cli/Output.ts +31 -1
  26. package/src/cli/Program.test.ts +131 -82
  27. package/src/cli/Program.ts +197 -111
  28. package/src/edge/Edge.test.ts +64 -56
  29. package/src/edge/Edge.ts +4 -4
  30. package/src/emit/Emit.test.ts +1 -1
  31. package/src/emit/Emit.ts +1 -1
  32. package/src/flows/factory.test.ts +14 -0
  33. package/src/flows/factory.ts +25 -6
  34. package/src/flows/helpers.test.ts +35 -0
  35. package/src/flows/helpers.ts +19 -10
  36. package/src/flows/index.test.ts +66 -0
  37. package/src/flows/index.ts +109 -5
  38. package/src/flows/internal.ts +7 -0
  39. package/src/flows/runtime.test.ts +192 -1
  40. package/src/flows/runtime.ts +188 -54
  41. package/src/gherkin/git-control.feature +6 -6
  42. package/src/gherkin/index.test.ts +127 -0
  43. package/src/gherkin/index.ts +481 -98
  44. package/src/gherkin/messages.test.ts +124 -0
  45. package/src/gherkin/messages.ts +101 -0
  46. package/src/gherkin/more-steps.feature +170 -0
  47. package/src/gherkin/scenario-end.feature +101 -0
  48. package/src/gherkin/testkit.feature +83 -7
  49. package/src/install/Install.test.ts +7 -7
  50. package/src/install/Install.ts +8 -8
  51. package/src/replay/Replay.test.ts +13 -2
  52. package/src/replay/Replay.ts +19 -30
  53. package/src/replay/judgeDiffShape.test.ts +1 -1
  54. package/src/steering/Footnotes.test.ts +1 -1
  55. package/src/steering/SteeringFormats.test.ts +1 -1
  56. package/src/steering/freeform.test.ts +1 -1
  57. package/src/steering/index.test.ts +27 -1
  58. package/src/steering/index.ts +6 -104
  59. package/src/steering/internal.ts +11 -0
  60. package/src/steering/qa.test.ts +7 -2
  61. package/src/steering/reads.test.ts +42 -0
  62. package/src/steering/reads.ts +43 -0
  63. package/src/steering/registry.ts +93 -0
  64. package/src/steering/review.test.ts +2 -2
  65. package/src/step/planStart.test.ts +18 -16
  66. package/src/step/planStart.ts +9 -9
  67. package/src/testkit/TestRepo.test.ts +289 -6
  68. package/src/testkit/TestRepo.ts +317 -41
  69. package/src/testkit/imports.test.ts +58 -0
  70. package/src/testkit/imports.ts +102 -0
  71. package/src/testkit/index.test.ts +28 -0
  72. package/src/testkit/index.ts +14 -2
  73. package/src/twins/EmittedScriptRecognizer.ts +7 -14
  74. package/src/twins/EmittedScripts.test.ts +4 -2
  75. package/src/twins/InMemRepo.ts +1 -0
  76. package/src/wire/Land.ts +1 -1
  77. package/src/wire/Script.ts +11 -0
  78. package/src/wire/WireSchema.test.ts +3 -0
  79. package/src/wire/golden/1/script.json +4 -0
  80. package/src/wire/index.ts +1 -0
  81. package/src/workflow/SteeringFormats.test.ts +1 -1
  82. package/src/workflow/compile.ts +2 -2
  83. package/src/workflow/discovery.test.ts +3 -3
  84. package/src/workflow/load.test.ts +256 -116
  85. package/src/workflow/load.ts +175 -152
  86. package/src/workflow/skills.test.ts +5 -5
  87. package/src/workflow/skills.ts +1 -1
  88. package/src/workflow-kit/index.test.ts +25 -0
  89. package/src/workflow-kit/index.ts +37 -4
  90. package/src/workflow-kit/review.ts +2 -2
@@ -4,7 +4,7 @@
4
4
  * e2e tier observes. Argv parsing is pinned in `src/cli/Cli.test.ts`.
5
5
  */
6
6
 
7
- import { Cause, Effect, Exit } from "effect"
7
+ import { Cause, Context, Effect, Exit } from "effect"
8
8
  import { PassThrough } from "node:stream"
9
9
  import { afterEach, describe, expect, it, vi } from "vitest"
10
10
 
@@ -17,7 +17,7 @@ vi.mock("node:os", async (importOriginal) => ({
17
17
 
18
18
  import { runCli, type Command } from "./index.js"
19
19
  import { EXIT_USAGE_ERROR, stallDiagnosis, noopText, WIRE_SCHEMA } from "../wire/index.js"
20
- import { needsOf, runCommand, SelectorUsageError } from "./Program.js"
20
+ import { needsOf, runCommand, SelectorUsageError, type CommandRequirements } from "./Program.js"
21
21
  import { InMemRepo, makeCapturingCliIo, testLayers, applyEmittedScript } from "../twins/index.js"
22
22
  import {
23
23
  commitAll,
@@ -26,7 +26,7 @@ import {
26
26
  noteOutcome,
27
27
  restoredOutcome,
28
28
  } from "../emit/index.js"
29
- import { HISTORY_REF } from "../platform/index.js"
29
+ import { GitService, HISTORY_REF, Host } from "../platform/index.js"
30
30
  import { contentHash } from "../workflow/index.js"
31
31
  import { WORKFLOWS_REPO } from "../install/index.js"
32
32
 
@@ -81,25 +81,26 @@ const seedIdle = (): InMemRepo => {
81
81
  return repo
82
82
  }
83
83
 
84
- describe("gtd --workflow <name> — a custom workflow's named workflow", () => {
85
- const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
84
+ describe("gtd start <name> — a custom workflow's named workflow", () => {
85
+ const WORKFLOW = `import { agent, human, workflow } from "@pmelab/gtd/flows"
86
86
 
87
87
  export default async () => {
88
88
  await human("idle", { message: "hi" })
89
89
  await agent("working", "go")
90
90
  }
91
91
 
92
- export const hotfix = async () => {
93
- await agent("working", "go")
94
- }
95
-
96
- export const defaults = { greeting: "hello" }
92
+ export const hotfix = workflow(
93
+ async () => {
94
+ await agent("working", "go")
95
+ },
96
+ { defaults: { greeting: "hello" } },
97
+ )
97
98
  `
98
99
 
99
100
  it("the happy path emits the opening commit's script — gtd itself writes nothing", async () => {
100
101
  const repo = seed(WORKFLOW)
101
102
  const before = repo.commitHistory().length
102
- const { stdout, exitCode } = await run(repo, "--workflow", "hotfix")
103
+ const { stdout, exitCode } = await run(repo, "start", "hotfix")
103
104
  expect(exitCode).toBe(0)
104
105
  expect(stdout).toContain(
105
106
  commitAll(
@@ -109,21 +110,33 @@ export const defaults = { greeting: "hello" }
109
110
  expect(repo.commitHistory()).toHaveLength(before)
110
111
  })
111
112
 
112
- it("an unknown workflow is a usage error (exit 2) listing every startable name", async () => {
113
+ it("an unknown name is a usage error (exit 2) pointing at the list", async () => {
113
114
  const repo = seed(WORKFLOW)
114
115
  const before = repo.commitHistory().length
115
- const { exitCode, stderr } = await run(repo, "--workflow", "bogus")
116
+ const { exitCode, stderr } = await run(repo, "start", "bogus")
116
117
  expect(exitCode).toBe(2)
117
118
  expect(repo.commitHistory()).toHaveLength(before)
118
- expect(stderr).toContain('unknown workflow "bogus"')
119
- expect(stderr).toContain("startable: hotfix")
119
+ expect(stderr).toContain('no workflow or door is named "bogus" — run `gtd start` to list them')
120
+ })
121
+
122
+ it("lists the paths its script commits as comments a path cannot break out of", async () => {
123
+ const repo = seed(WORKFLOW)
124
+ repo.writeFile("NOTE.md", "a note\n")
125
+ repo.writeFile("evil\nrm -rf x", "")
126
+ const { stdout, exitCode } = await run(repo, "start", "hotfix")
127
+ expect(exitCode).toBe(0)
128
+ expect(stdout).toContain("# commits every change in the working tree (git add -A):\n")
129
+ expect(stdout).toContain("# A NOTE.md\n")
130
+ expect(stdout).toContain("evil?rm -rf x")
131
+ expect(stdout).not.toMatch(/^rm -rf x/m)
132
+ expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
120
133
  })
121
134
 
122
135
  it("`default` is not startable by name", async () => {
123
136
  const repo = seed(WORKFLOW)
124
- const { exitCode, stderr } = await run(repo, "--workflow", "default")
137
+ const { exitCode, stderr } = await run(repo, "start", "default")
125
138
  expect(exitCode).toBe(2)
126
- expect(stderr).toContain('unknown workflow "default"')
139
+ expect(stderr).toContain('no workflow or door is named "default"')
127
140
  })
128
141
 
129
142
  it("--entry is an ordinary unknown flag", async () => {
@@ -136,17 +149,18 @@ export const defaults = { greeting: "hello" }
136
149
  const repo = seed(WORKFLOW)
137
150
  await landTurn(repo, { ".gtd/TODO.md": "sketch\n" })
138
151
  const before = repo.commitHistory().length
139
- const { exitCode, stderr } = await run(repo, "--workflow", "hotfix")
152
+ const { exitCode, stderr } = await run(repo, "start", "hotfix")
140
153
  expect(exitCode).toBe(1)
141
154
  expect(repo.commitHistory()).toHaveLength(before)
142
155
  expect(stderr).toContain("already underway")
143
156
  })
144
157
 
145
- it("an undeclared --var name refuses, listing the declared names", async () => {
158
+ it("an undeclared --var name is a usage error (exit 2), listing the declared names", async () => {
146
159
  const repo = seed(WORKFLOW)
147
160
  const before = repo.commitHistory().length
148
- const { exitCode, stderr } = await run(repo, "--workflow", "hotfix", "--var", "bogus=1")
149
- expect(exitCode).toBe(1)
161
+ const { exitCode, stderr, stdout } = await run(repo, "start", "hotfix", "--var", "bogus=1")
162
+ expect(exitCode).toBe(2)
163
+ expect(stdout).toBe("")
150
164
  expect(repo.commitHistory()).toHaveLength(before)
151
165
  expect(stderr).toContain("bogus")
152
166
  expect(stderr).toContain("greeting")
@@ -155,7 +169,7 @@ export const defaults = { greeting: "hello" }
155
169
  it("a declared --var override renders as a Gtd-Var trailer in the emitted commit script", async () => {
156
170
  const repo = seed(WORKFLOW)
157
171
  const before = repo.commitHistory().length
158
- const { exitCode, stdout } = await run(repo, "--workflow", "hotfix", "--var", "greeting=world")
172
+ const { exitCode, stdout } = await run(repo, "start", "hotfix", "--var", "greeting=world")
159
173
  expect(exitCode).toBe(0)
160
174
  expect(stdout).toContain("Gtd-Var: greeting=world")
161
175
  expect(repo.commitHistory()).toHaveLength(before)
@@ -165,7 +179,7 @@ export const defaults = { greeting: "hello" }
165
179
  const repo = seed(WORKFLOW)
166
180
  repo.writeFile("scratch.txt", "uncommitted\n")
167
181
  const before = repo.commitHistory().length
168
- const { exitCode, stdout } = await run(repo, "--workflow", "hotfix")
182
+ const { exitCode, stdout } = await run(repo, "start", "hotfix")
169
183
  expect(exitCode).toBe(0)
170
184
  expect(stdout.length).toBeGreaterThan(0)
171
185
  expect(repo.commitHistory()).toHaveLength(before)
@@ -173,7 +187,7 @@ export const defaults = { greeting: "hello" }
173
187
 
174
188
  it("applying the start script opens the workflow's episode at its first step", async () => {
175
189
  const repo = seed(WORKFLOW)
176
- const { stdout } = await run(repo, "--workflow", "hotfix")
190
+ const { stdout } = await run(repo, "start", "hotfix")
177
191
  expect(applyEmittedScript(repo, new Map(), stdout).ok).toBe(true)
178
192
  expect(repo.lastCommitSubject()).toBe("gtd(human): working")
179
193
  const next = JSON.parse((await run(repo, "next", "--json")).stdout) as Record<string, unknown>
@@ -184,7 +198,7 @@ export const defaults = { greeting: "hello" }
184
198
 
185
199
  it("a pinned workflow the config no longer defines is refused with the abandon advice", async () => {
186
200
  const repo = seed(WORKFLOW)
187
- const { stdout } = await run(repo, "--workflow", "hotfix")
201
+ const { stdout } = await run(repo, "start", "hotfix")
188
202
  applyEmittedScript(repo, new Map(), stdout)
189
203
  repo.writeFile("gtd.config.ts", WORKFLOW.replace("export const hotfix", "const hotfix"))
190
204
  const { exitCode, stderr } = await run(repo, "next")
@@ -194,18 +208,16 @@ export const defaults = { greeting: "hello" }
194
208
  })
195
209
  })
196
210
 
197
- describe("gtd install", () => {
211
+ describe("gtd guide", () => {
198
212
  it("where no gtd.config.ts governs, prints the briefing and warns where to get a workflow — writing nothing", async () => {
199
213
  const repo = new InMemRepo()
200
214
  repo.writeFile("README.md", "# test project\n")
201
215
  repo.seedCommit("chore: init")
202
216
  const before = repo.snapshot()
203
- const { stdout, stderr, exitCode } = await run(repo, "install")
217
+ const { stdout, stderr, exitCode } = await run(repo, "guide")
204
218
  expect(exitCode).toBe(0)
205
219
  expect(stdout).toMatch(/Make sure a .gtd\.config\.ts. governs the repository/)
206
- expect(stderr).toContain(
207
- "gtd install: no gtd.config.ts governs here, and gtd ships no workflow",
208
- )
220
+ expect(stderr).toContain("gtd guide: no gtd.config.ts governs here, and gtd ships no workflow")
209
221
  expect(stderr).toContain(WORKFLOWS_REPO)
210
222
  expect(repo.hasPath("gtd.config.ts")).toBe(false)
211
223
  expect(repo.snapshot()).toBe(before)
@@ -213,42 +225,35 @@ describe("gtd install", () => {
213
225
 
214
226
  it("where a gtd.config.ts governs, prints the briefing alone", async () => {
215
227
  const repo = seedIdle()
216
- const { stdout, stderr, exitCode } = await run(repo, "install")
228
+ const { stdout, stderr, exitCode } = await run(repo, "guide")
217
229
  expect(exitCode).toBe(0)
218
230
  expect(stdout).toMatch(/Make sure a .gtd\.config\.ts. governs the repository/)
219
231
  expect(stderr).not.toContain(WORKFLOWS_REPO)
220
232
  })
221
233
  })
222
234
 
223
- describe("gtd --workflow <name> — a workflow that fixes its diff base", () => {
224
- const WORKFLOW = `import { human } from "@pmelab/gtd/flows"
235
+ describe("gtd start <name> — a workflow that fixes its diff base", () => {
236
+ const WORKFLOW = `import { human, workflow } from "@pmelab/gtd/flows"
225
237
 
226
238
  export default async () => {
227
239
  await human("idle", { message: "hi" })
228
240
  }
229
241
 
230
- export const review = async () => {
231
- await human("checking", { message: "check" })
232
- }
233
-
234
- export const defaults = { reviewBase: "" }
235
-
236
- export const base = (workflow, vars) => (workflow === "review" ? vars.reviewBase : undefined)
242
+ export const review = workflow(
243
+ async () => {
244
+ await human("checking", { message: "check" })
245
+ },
246
+ { defaults: { reviewBase: "" }, base: (_workflow, vars) => vars.reviewBase },
247
+ )
237
248
  `
238
249
 
239
- it("--workflow review --var reviewBase=<base> emits a script anchoring the process to that base", async () => {
250
+ it("start review --var reviewBase=<base> emits a script anchoring the process to that base", async () => {
240
251
  const repo = seed(WORKFLOW)
241
252
  const base = repo.commitHistory().at(-1)!.hash
242
253
  repo.writeFile("scratch.txt", "more work\n")
243
254
  repo.seedCommit("chore: more work")
244
255
  const before = repo.commitHistory().length
245
- const { stdout, exitCode } = await run(
246
- repo,
247
- "--workflow",
248
- "review",
249
- "--var",
250
- `reviewBase=${base}`,
251
- )
256
+ const { stdout, exitCode } = await run(repo, "start", "review", "--var", `reviewBase=${base}`)
252
257
  expect(exitCode).toBe(0)
253
258
  expect(stdout).toContain("gtd(human): checking")
254
259
  expect(stdout).toContain("Gtd-Workflow: review")
@@ -440,7 +445,7 @@ export default async () => {
440
445
  })
441
446
 
442
447
  describe("gtd next --json — stall detection (attempt commits)", () => {
443
- const WORKFLOW = `import { agent, human, run } from "@pmelab/gtd/flows"
448
+ const WORKFLOW = `import { agent, human, run, workflow } from "@pmelab/gtd/flows"
444
449
 
445
450
  const work = async () => {
446
451
  await agent("working", "do the work described in NOTE.md")
@@ -452,7 +457,7 @@ export default async () => {
452
457
  await work()
453
458
  }
454
459
 
455
- export const resume = work
460
+ export const resume = workflow(work)
456
461
  `
457
462
 
458
463
  const atWorking = async (): Promise<InMemRepo> => {
@@ -489,7 +494,7 @@ export const resume = work
489
494
 
490
495
  it("a clean-tree entry commit resting at a prompt step is NOT a stall — the actor differs (human entered, agent acts)", async () => {
491
496
  const repo = seed(WORKFLOW)
492
- const { stdout: entry } = await run(repo, "--workflow", "resume")
497
+ const { stdout: entry } = await run(repo, "start", "resume")
493
498
  expect(applyEmittedScript(repo, new Map(), entry).ok).toBe(true)
494
499
  const { stdout, exitCode } = await run(repo, "next", "--json")
495
500
  expect(exitCode).toBe(0)
@@ -988,18 +993,19 @@ describe("gtd check <mode> <file>", () => {
988
993
  )
989
994
  })
990
995
 
991
- it("an absent file exits 0 with no output", async () => {
996
+ it("an absent file is a refusal (exit 1) naming it", async () => {
992
997
  const repo = bareRepo()
993
- const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
994
- expect(exitCode).toBe(0)
998
+ const { stdout, stderr, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
999
+ expect(exitCode).toBe(1)
995
1000
  expect(stdout).toBe("")
1001
+ expect(stderr).toContain("gtd check: no such file: .gtd/TODO.md")
996
1002
  })
997
1003
 
998
- it("an unknown mode is a usage-style error naming the known modes", async () => {
1004
+ it("an unknown mode is a usage error (exit 2) naming the known modes", async () => {
999
1005
  const repo = bareRepo()
1000
1006
  repo.writeFile("TODO.md", validQaDoc)
1001
1007
  const { stderr, exitCode } = await run(repo, "check", "bogus", "TODO.md")
1002
- expect(exitCode).toBe(1)
1008
+ expect(exitCode).toBe(2)
1003
1009
  expect(stderr).toContain('unknown mode "bogus"')
1004
1010
  expect(stderr).toContain("qa")
1005
1011
  expect(stderr).toContain("review")
@@ -1231,13 +1237,6 @@ describe("gtd check <mode> <file> --open-questions", () => {
1231
1237
  expect(stderr).toContain(".gtd/TODO.md")
1232
1238
  })
1233
1239
 
1234
- it("check without the flag is unchanged: an absent file still exits 0 with no output", async () => {
1235
- const repo = bareRepo()
1236
- const { stdout, exitCode } = await run(repo, "check", "qa", ".gtd/TODO.md")
1237
- expect(exitCode).toBe(0)
1238
- expect(stdout).toBe("")
1239
- })
1240
-
1241
1240
  it("--json is out of scope for check --open-questions too — a usage error, never a JSON envelope", async () => {
1242
1241
  const repo = bareRepo()
1243
1242
  repo.writeFile(".gtd/TODO.md", docWithUnanswered)
@@ -1264,7 +1263,7 @@ describe("gtd check <mode> <file> --open-questions", () => {
1264
1263
  "--open-questions",
1265
1264
  ".gtd/TODO.md",
1266
1265
  )
1267
- expect(exitCode).not.toBe(0)
1266
+ expect(exitCode).toBe(2)
1268
1267
  expect(stderr).toContain('unknown mode "bogus"')
1269
1268
  expect(stderr).toContain("qa")
1270
1269
  expect(stderr).toContain("review")
@@ -1705,16 +1704,14 @@ describe("runCommand — refuses in a repository with no commits", () => {
1705
1704
  lsp: { kind: "lsp" },
1706
1705
  ui: { kind: "ui", selfSigned: false, dev: false },
1707
1706
  land: { kind: "land" },
1708
- start: { kind: "start", actor: "human", workflow: "feature", vars: {}, label: "" },
1709
- door: { kind: "door", name: "fix", args: [] },
1710
- doors: { kind: "doors" },
1707
+ start: { kind: "start", name: "fix", args: [], vars: {} },
1711
1708
  abandon: { kind: "abandon" },
1712
1709
  restore: { kind: "restore" },
1713
1710
  next: { kind: "next" },
1714
1711
  validate: { kind: "validate" },
1715
1712
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1716
1713
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1717
- install: { kind: "install" },
1714
+ guide: { kind: "guide" },
1718
1715
  summary: { kind: "summary" },
1719
1716
  base: { kind: "base" },
1720
1717
  judge: { kind: "judge" },
@@ -1733,15 +1730,13 @@ describe("runCommand — refuses in a repository with no commits", () => {
1733
1730
  )
1734
1731
 
1735
1732
  const NO_COMMITS_MESSAGE =
1736
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1733
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1737
1734
 
1738
- it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1735
+ it("derives exactly the sixteen non-standalone kinds — a canary for the table-driven cases below", () => {
1739
1736
  expect(stateKinds.sort()).toEqual(
1740
1737
  [
1741
1738
  "abandon",
1742
1739
  "base",
1743
- "door",
1744
- "doors",
1745
1740
  "start",
1746
1741
  "land",
1747
1742
  "next",
@@ -1817,16 +1812,19 @@ describe("runCommand — refuses in a repository with no commits", () => {
1817
1812
  })
1818
1813
 
1819
1814
  describe("gtd summary", () => {
1820
- const SUMMARY_WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
1815
+ const SUMMARY_WORKFLOW = `import { agent, human, workflow } from "@pmelab/gtd/flows"
1821
1816
 
1822
- export default async () => {
1817
+ export default workflow(
1818
+ async () => {
1823
1819
  await human("idle", { message: "hi" })
1824
1820
  await agent("working", "go")
1825
1821
  await human("reviewing", { message: "check it" })
1826
1822
  await agent("polishing", "polish")
1827
- }
1828
-
1829
- export const summary = (c) => \`entry=\${c.entryCommit} tip=\${c.processTip} humans=\${c.humanCommits.length}\`
1823
+ },
1824
+ {
1825
+ summary: (c) => \`entry=\${c.entryCommit} tip=\${c.processTip} humans=\${c.humanCommits.length}\`,
1826
+ },
1827
+ )
1830
1828
  `
1831
1829
 
1832
1830
  const reviewed = async (): Promise<InMemRepo> => {
@@ -2118,14 +2116,15 @@ describe("gtd judge / gtd judge answer", () => {
2118
2116
  const QUESTION = `{ id: "q1", primitive: "noul", instructions: "i", criteria: "c" }`
2119
2117
 
2120
2118
  const judgeWorkflow = (evidence: string, then: string, vars = "{}"): string =>
2121
- `import { agent, human, judge, read } from "@pmelab/gtd/flows"
2119
+ `import { agent, human, judge, read, workflow } from "@pmelab/gtd/flows"
2122
2120
 
2123
- export default async () => {
2121
+ export default workflow(
2122
+ async () => {
2124
2123
  await judge("idle", { questions: [${QUESTION}], evidence: ${evidence} })
2125
2124
  await ${then}
2126
- }
2127
-
2128
- export const defaults = ${vars}
2125
+ },
2126
+ { defaults: ${vars} },
2127
+ )
2129
2128
  `
2130
2129
 
2131
2130
  const seededRepo = (): InMemRepo =>
@@ -2294,3 +2293,53 @@ export const defaults = ${vars}
2294
2293
  expect(repo.lastCommitMessage()).not.toContain("Gtd-Payload:")
2295
2294
  })
2296
2295
  })
2296
+
2297
+ describe("runCommand — the repository-root guard's messages", () => {
2298
+ const guarded = async (
2299
+ override: (
2300
+ git: Context.Tag.Service<GitService>,
2301
+ host: Context.Tag.Service<Host>,
2302
+ ) => Effect.Effect<unknown, Error, CommandRequirements>,
2303
+ ): Promise<string> => {
2304
+ const exit = await Effect.runPromiseExit(
2305
+ Effect.gen(function* () {
2306
+ return yield* override(yield* GitService, yield* Host)
2307
+ }).pipe(Effect.provide(testLayers(seedIdle()))),
2308
+ )
2309
+ return Exit.isFailure(exit) ? (Cause.squash(exit.cause) as Error).message : ""
2310
+ }
2311
+ const out = { write: () => {}, flush: () => {} }
2312
+ const next = runCommand({ kind: "next" }, { kind: "off" }, out)
2313
+
2314
+ it("outside a git repository says so instead of quoting git", async () => {
2315
+ const message = await guarded((git) =>
2316
+ next.pipe(
2317
+ Effect.provideService(GitService, {
2318
+ ...git,
2319
+ topLevel: () =>
2320
+ Effect.fail(
2321
+ new Error(
2322
+ "git rev-parse --show-toplevel failed (exit 128): fatal: not a git repository (or any of the parent directories): .git",
2323
+ ),
2324
+ ),
2325
+ }),
2326
+ ),
2327
+ )
2328
+ expect(message).toBe("gtd: not a git repository — run gtd inside one (`git init` makes one)")
2329
+ })
2330
+
2331
+ it("from a subdirectory names the root to cd to", async () => {
2332
+ const message = await guarded((_git, host) =>
2333
+ next.pipe(
2334
+ Effect.provideService(Host, {
2335
+ ...host,
2336
+ root: "/repo/my sub",
2337
+ realPath: (path: string) => Effect.succeed(path),
2338
+ }),
2339
+ ),
2340
+ )
2341
+ expect(message).toBe(
2342
+ "gtd: run gtd from the repository root — cd /repo (the current directory is /repo/my sub)",
2343
+ )
2344
+ })
2345
+ })