@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
@@ -0,0 +1,47 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { closest, definition, wrap } from "./Help.js"
3
+
4
+ describe("wrap", () => {
5
+ it("fills lines up to the width, never past it", () => {
6
+ expect(wrap("aaa bbb ccc ddd", 7)).toEqual(["aaa bbb", "ccc ddd"])
7
+ })
8
+
9
+ it("keeps a code span on one line", () => {
10
+ expect(wrap("run `gtd help land` now", 12)).toEqual(["run", "`gtd help land`", "now"])
11
+ })
12
+
13
+ it("keeps an indented example paragraph verbatim", () => {
14
+ expect(wrap('text\n\n s=$(x) && sh -c "$s"', 4)).toEqual([
15
+ "text",
16
+ "",
17
+ ' s=$(x) && sh -c "$s"',
18
+ ])
19
+ })
20
+ })
21
+
22
+ describe("definition", () => {
23
+ it("puts a short term beside its description", () => {
24
+ expect(definition("next", "Print it", 10)).toEqual([" next Print it"])
25
+ })
26
+
27
+ it("gives a term too wide for the column a line of its own", () => {
28
+ expect(definition("a-very-long-term", "Text", 10)).toEqual([
29
+ " a-very-long-term",
30
+ " Text",
31
+ ])
32
+ })
33
+ })
34
+
35
+ describe("closest", () => {
36
+ const commands = ["next", "land", "history", "help"]
37
+
38
+ it("finds a dropped letter and a swapped pair", () => {
39
+ expect(closest("nxt", commands)).toBe("next")
40
+ expect(closest("lnad", commands)).toBe("land")
41
+ expect(closest("histroy", commands)).toBe("history")
42
+ })
43
+
44
+ it("suggests nothing for a word unlike every candidate", () => {
45
+ expect(closest("frobnicate", commands)).toBeUndefined()
46
+ })
47
+ })
@@ -0,0 +1,70 @@
1
+ const HELP_WIDTH = 80
2
+
3
+ /**
4
+ * Word-wraps prose to `width` columns, every line prefixed by `indent`.
5
+ * Paragraphs are separated by a blank line; a paragraph whose lines start
6
+ * with two spaces is an example and is kept verbatim (indented, not wrapped).
7
+ */
8
+ const wrapParagraph = (paragraph: string, width: number, indent: string): string[] => {
9
+ const lines: string[] = []
10
+ let line = ""
11
+ // A `code span` is one word: a command split across lines can't be copied.
12
+ for (const word of paragraph.match(/\S*`[^`]*`\S*|\S+/g) ?? []) {
13
+ if (line !== "" && indent.length + line.length + 1 + word.length > width) {
14
+ lines.push(indent + line)
15
+ line = word
16
+ } else {
17
+ line = line === "" ? word : `${line} ${word}`
18
+ }
19
+ }
20
+ if (line !== "") lines.push(indent + line)
21
+ return lines
22
+ }
23
+
24
+ export const wrap = (text: string, width = HELP_WIDTH, indent = ""): string[] =>
25
+ text
26
+ .split("\n\n")
27
+ .flatMap((paragraph, i) => [
28
+ ...(i > 0 ? [""] : []),
29
+ ...(paragraph.startsWith(" ")
30
+ ? paragraph.split("\n").map((l) => indent + l)
31
+ : wrapParagraph(paragraph, width, indent)),
32
+ ])
33
+
34
+ /** `term` and `description` side by side, the description wrapped into the column after `column`; a term too wide for it gets a line of its own. */
35
+ export const definition = (term: string, description: string, column: number): string[] => {
36
+ const cell = ` ${term}`
37
+ const body = wrap(description, HELP_WIDTH, " ".repeat(column))
38
+ if (cell.length + 1 >= column) return [cell, ...body]
39
+ const [first = "", ...rest] = body
40
+ return [cell.padEnd(column) + first.trimStart(), ...rest]
41
+ }
42
+
43
+ /** Optimal-string-alignment distance: Levenshtein plus adjacent transpositions, so `nxet` is one edit from `next`. */
44
+ const distance = (a: string, b: string): number => {
45
+ const d: number[][] = Array.from({ length: a.length + 1 }, (_, i) =>
46
+ Array.from({ length: b.length + 1 }, (_, j) => (i === 0 ? j : j === 0 ? i : 0)),
47
+ )
48
+ for (let i = 1; i <= a.length; i++) {
49
+ for (let j = 1; j <= b.length; j++) {
50
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1
51
+ let best = Math.min(d[i - 1]![j]! + 1, d[i]![j - 1]! + 1, d[i - 1]![j - 1]! + cost)
52
+ if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) {
53
+ best = Math.min(best, d[i - 2]![j - 2]! + 1)
54
+ }
55
+ d[i]![j] = best
56
+ }
57
+ }
58
+ return d[a.length]![b.length]!
59
+ }
60
+
61
+ /** The candidate closest to `word`, when it is a plausible typo of it: at most one edit per three characters, and at least one. */
62
+ export const closest = (word: string, candidates: readonly string[]): string | undefined => {
63
+ const limit = Math.max(1, Math.floor(word.length / 3))
64
+ let best: { readonly name: string; readonly d: number } | undefined
65
+ for (const name of candidates) {
66
+ const d = distance(word, name)
67
+ if (d <= limit && (best === undefined || d < best.d)) best = { name, d }
68
+ }
69
+ return best?.name
70
+ }
@@ -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,9 +26,9 @@ import {
26
26
  noteOutcome,
27
27
  restoredOutcome,
28
28
  } from "../emit/index.js"
29
- import { HISTORY_REF } from "../platform/index.js"
30
- import { CONFIG_SCAFFOLD } from "../install/index.js"
29
+ import { GitService, HISTORY_REF, Host } from "../platform/index.js"
31
30
  import { contentHash } from "../workflow/index.js"
31
+ import { WORKFLOWS_REPO } from "../install/index.js"
32
32
 
33
33
  const run = async (
34
34
  repo: InMemRepo,
@@ -64,15 +64,6 @@ const seed = (source: string, extra: Record<string, string> = {}): InMemRepo =>
64
64
  return repo
65
65
  }
66
66
 
67
- /** A repo `gtd install` set up: its config runs the shipped workflows. */
68
- const seedBundled = (): InMemRepo => {
69
- const repo = new InMemRepo()
70
- repo.writeFile("README.md", "# test project\n")
71
- repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
72
- repo.seedCommit("chore: init")
73
- return repo
74
- }
75
-
76
67
  const IDLE_THEN_WORKING = `import { agent, human } from "@pmelab/gtd/flows"
77
68
 
78
69
  export default async () => {
@@ -81,6 +72,15 @@ export default async () => {
81
72
  }
82
73
  `
83
74
 
75
+ /** A repo at rest: its config runs {@link IDLE_THEN_WORKING}. */
76
+ const seedIdle = (): InMemRepo => {
77
+ const repo = new InMemRepo()
78
+ repo.writeFile("README.md", "# test project\n")
79
+ repo.writeFile("gtd.config.ts", IDLE_THEN_WORKING)
80
+ repo.seedCommit("chore: init")
81
+ return repo
82
+ }
83
+
84
84
  describe("gtd --workflow <name> — a custom workflow's named workflow", () => {
85
85
  const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
86
86
 
@@ -119,6 +119,19 @@ export const defaults = { greeting: "hello" }
119
119
  expect(stderr).toContain("startable: hotfix")
120
120
  })
121
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, "--workflow", "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)
133
+ })
134
+
122
135
  it("`default` is not startable by name", async () => {
123
136
  const repo = seed(WORKFLOW)
124
137
  const { exitCode, stderr } = await run(repo, "--workflow", "default")
@@ -194,9 +207,50 @@ export const defaults = { greeting: "hello" }
194
207
  })
195
208
  })
196
209
 
197
- describe("gtd --workflow <name> — the shipped workflows", () => {
210
+ describe("gtd install", () => {
211
+ it("where no gtd.config.ts governs, prints the briefing and warns where to get a workflow — writing nothing", async () => {
212
+ const repo = new InMemRepo()
213
+ repo.writeFile("README.md", "# test project\n")
214
+ repo.seedCommit("chore: init")
215
+ const before = repo.snapshot()
216
+ const { stdout, stderr, exitCode } = await run(repo, "install")
217
+ expect(exitCode).toBe(0)
218
+ expect(stdout).toMatch(/Make sure a .gtd\.config\.ts. governs the repository/)
219
+ expect(stderr).toContain(
220
+ "gtd install: no gtd.config.ts governs here, and gtd ships no workflow",
221
+ )
222
+ expect(stderr).toContain(WORKFLOWS_REPO)
223
+ expect(repo.hasPath("gtd.config.ts")).toBe(false)
224
+ expect(repo.snapshot()).toBe(before)
225
+ })
226
+
227
+ it("where a gtd.config.ts governs, prints the briefing alone", async () => {
228
+ const repo = seedIdle()
229
+ const { stdout, stderr, exitCode } = await run(repo, "install")
230
+ expect(exitCode).toBe(0)
231
+ expect(stdout).toMatch(/Make sure a .gtd\.config\.ts. governs the repository/)
232
+ expect(stderr).not.toContain(WORKFLOWS_REPO)
233
+ })
234
+ })
235
+
236
+ describe("gtd --workflow <name> — a workflow that fixes its diff base", () => {
237
+ const WORKFLOW = `import { human } from "@pmelab/gtd/flows"
238
+
239
+ export default async () => {
240
+ await human("idle", { message: "hi" })
241
+ }
242
+
243
+ export const review = async () => {
244
+ await human("checking", { message: "check" })
245
+ }
246
+
247
+ export const defaults = { reviewBase: "" }
248
+
249
+ export const base = (workflow, vars) => (workflow === "review" ? vars.reviewBase : undefined)
250
+ `
251
+
198
252
  it("--workflow review --var reviewBase=<base> emits a script anchoring the process to that base", async () => {
199
- const repo = seedBundled()
253
+ const repo = seed(WORKFLOW)
200
254
  const base = repo.commitHistory().at(-1)!.hash
201
255
  repo.writeFile("scratch.txt", "more work\n")
202
256
  repo.seedCommit("chore: more work")
@@ -209,26 +263,16 @@ describe("gtd --workflow <name> — the shipped workflows", () => {
209
263
  `reviewBase=${base}`,
210
264
  )
211
265
  expect(exitCode).toBe(0)
212
- expect(stdout).toContain("gtd(human): review-gate.check")
266
+ expect(stdout).toContain("gtd(human): checking")
213
267
  expect(stdout).toContain("Gtd-Workflow: review")
214
268
  expect(stdout).toContain(`Gtd-Review-Base: ${base}`)
215
269
  expect(repo.commitHistory()).toHaveLength(before)
216
270
  })
217
-
218
- it("--workflow fix emits a script that would start a fix process", async () => {
219
- const repo = seedBundled()
220
- const before = repo.commitHistory().length
221
- const { stdout, exitCode } = await run(repo, "--workflow", "fix")
222
- expect(exitCode).toBe(0)
223
- expect(stdout).toContain("gtd(human): fix-precheck")
224
- expect(stdout).toContain("Gtd-Workflow: fix")
225
- expect(repo.commitHistory()).toHaveLength(before)
226
- })
227
271
  })
228
272
 
229
273
  describe("config loading", () => {
230
274
  it('gtd next --verbose narrates "config: module" exactly once — the extra load that surfaces warnings is silent', async () => {
231
- const repo = seedBundled()
275
+ const repo = seedIdle()
232
276
  const { stderr, exitCode } = await run(repo, "next", "--verbose")
233
277
  expect(exitCode).toBe(0)
234
278
  expect(stderr.match(/config: module /g)).toHaveLength(1)
@@ -287,7 +331,7 @@ describe("gtd next --json — log path emission", () => {
287
331
  const seededRepo = (): InMemRepo => {
288
332
  const repo = new InMemRepo()
289
333
  repo.writeFile("NOTE.md", "a note\n")
290
- repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
334
+ repo.writeFile("gtd.config.ts", IDLE_THEN_WORKING)
291
335
  repo.seedCommit("gtd: init")
292
336
  return repo
293
337
  }
@@ -1702,7 +1746,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1702
1746
  )
1703
1747
 
1704
1748
  const NO_COMMITS_MESSAGE =
1705
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again"
1749
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again"
1706
1750
 
1707
1751
  it("derives exactly the eighteen non-standalone kinds — a canary for the table-driven cases below", () => {
1708
1752
  expect(stateKinds.sort()).toEqual(
@@ -1752,7 +1796,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1752
1796
  )
1753
1797
 
1754
1798
  it("a repository with a commit passes the guard and dispatches normally", async () => {
1755
- const repo = seedBundled()
1799
+ const repo = seedIdle()
1756
1800
  const written: string[] = []
1757
1801
  const out = { write: (chunk: string) => written.push(chunk), flush: () => {} }
1758
1802
 
@@ -1765,7 +1809,7 @@ describe("runCommand — refuses in a repository with no commits", () => {
1765
1809
  })
1766
1810
 
1767
1811
  it("gtd ui in a repository WITH commits reaches its own dispatch, past the guard", async () => {
1768
- const repo = seedBundled()
1812
+ const repo = seedIdle()
1769
1813
  const out = { write: () => {}, flush: () => {} }
1770
1814
  const requests: unknown[] = []
1771
1815
  const ui = (request: unknown) => Effect.sync(() => void requests.push(request))
@@ -1950,7 +1994,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
1950
1994
  const seededRepo = (): InMemRepo => {
1951
1995
  const repo = new InMemRepo()
1952
1996
  repo.writeFile("NOTE.md", "a note\n")
1953
- repo.writeFile("gtd.config.ts", CONFIG_SCAFFOLD)
1997
+ repo.writeFile("gtd.config.ts", IDLE_THEN_WORKING)
1954
1998
  repo.seedCommit("gtd: init")
1955
1999
  return repo
1956
2000
  }
@@ -1987,7 +2031,7 @@ describe("gtd next/land --json=<path> — the select branch", () => {
1987
2031
 
1988
2032
  it("an absent selector writes zero bytes to stdout and exits EXIT_OK", async () => {
1989
2033
  const repo = seededRepo()
1990
- // The built-in workflow's initial `idle` state declares no `mode:` —
2034
+ // The workflow's initial `idle` step declares no `mode:` —
1991
2035
  // `mode` is a real, present-but-`undefined` field on the beat document
1992
2036
  // there (see `src/wire/BeatStatus.ts`'s `statusOf`), not merely an
1993
2037
  // unknown path.
@@ -2263,3 +2307,53 @@ export const defaults = ${vars}
2263
2307
  expect(repo.lastCommitMessage()).not.toContain("Gtd-Payload:")
2264
2308
  })
2265
2309
  })
2310
+
2311
+ describe("runCommand — the repository-root guard's messages", () => {
2312
+ const guarded = async (
2313
+ override: (
2314
+ git: Context.Tag.Service<GitService>,
2315
+ host: Context.Tag.Service<Host>,
2316
+ ) => Effect.Effect<unknown, Error, CommandRequirements>,
2317
+ ): Promise<string> => {
2318
+ const exit = await Effect.runPromiseExit(
2319
+ Effect.gen(function* () {
2320
+ return yield* override(yield* GitService, yield* Host)
2321
+ }).pipe(Effect.provide(testLayers(seedIdle()))),
2322
+ )
2323
+ return Exit.isFailure(exit) ? (Cause.squash(exit.cause) as Error).message : ""
2324
+ }
2325
+ const out = { write: () => {}, flush: () => {} }
2326
+ const next = runCommand({ kind: "next" }, { kind: "off" }, out)
2327
+
2328
+ it("outside a git repository says so instead of quoting git", async () => {
2329
+ const message = await guarded((git) =>
2330
+ next.pipe(
2331
+ Effect.provideService(GitService, {
2332
+ ...git,
2333
+ topLevel: () =>
2334
+ Effect.fail(
2335
+ new Error(
2336
+ "git rev-parse --show-toplevel failed (exit 128): fatal: not a git repository (or any of the parent directories): .git",
2337
+ ),
2338
+ ),
2339
+ }),
2340
+ ),
2341
+ )
2342
+ expect(message).toBe("gtd: not a git repository — run gtd inside one (`git init` makes one)")
2343
+ })
2344
+
2345
+ it("from a subdirectory names the root to cd to", async () => {
2346
+ const message = await guarded((_git, host) =>
2347
+ next.pipe(
2348
+ Effect.provideService(Host, {
2349
+ ...host,
2350
+ root: "/repo/my sub",
2351
+ realPath: (path: string) => Effect.succeed(path),
2352
+ }),
2353
+ ),
2354
+ )
2355
+ expect(message).toBe(
2356
+ "gtd: run gtd from the repository root — cd /repo (the current directory is /repo/my sub)",
2357
+ )
2358
+ })
2359
+ })
@@ -33,6 +33,7 @@ import {
33
33
  landFields,
34
34
  noopText,
35
35
  renderBeatJson,
36
+ DEMAND_BRIEFING,
36
37
  renderBeatPlain,
37
38
  renderLandJson,
38
39
  renderLandPlain,
@@ -87,7 +88,7 @@ import {
87
88
  abandonNoopOutcome,
88
89
  restoredOutcome,
89
90
  } from "../emit/index.js"
90
- import { CONFIG_SCAFFOLD, renderBriefing } from "../install/index.js"
91
+ import { renderBriefing, WORKFLOWS_REPO } from "../install/index.js"
91
92
  import { ToolCommands, type SelfGtd } from "./ToolCommands.js"
92
93
  import {
93
94
  SelectorUsageError,
@@ -143,7 +144,7 @@ export type Command =
143
144
  readonly vars: Readonly<Record<string, string>>
144
145
  readonly label: string
145
146
  }
146
- | { readonly kind: "door"; readonly name: string; readonly args: readonly string[] }
147
+ | { readonly kind: "door"; readonly name?: string; readonly args: readonly string[] }
147
148
  | { readonly kind: "doors" }
148
149
  | { readonly kind: "abandon" }
149
150
  | { readonly kind: "restore" }
@@ -207,13 +208,12 @@ const runLspCommand = (): Effect.Effect<void, Error, ToolCommands> =>
207
208
 
208
209
  /**
209
210
  * Prints the briefing, emitted by the binary itself so it's always current.
210
- * At a repository root no `gtd.config.ts` governs yet, it also writes one
211
- * importing gtd's shipped workflows, left uncommitted.
212
- * Anywhere else it writes nothing.
211
+ * gtd ships no workflow, so where no `gtd.config.ts` governs it says where to
212
+ * get one; it writes nothing.
213
213
  */
214
214
  const runInstallCommand = (
215
215
  out: ArtifactOut,
216
- ): Effect.Effect<void, Error, GitService | Workspace | Host | ConfigDiscovery | Narrator> =>
216
+ ): Effect.Effect<void, Error, GitService | Host | ConfigDiscovery | Narrator> =>
217
217
  Effect.gen(function* () {
218
218
  const briefing = renderBriefing()
219
219
  out.write(briefing.endsWith("\n") ? briefing : briefing + "\n")
@@ -222,9 +222,8 @@ const runInstallCommand = (
222
222
  if (topLevel._tag === "Left") return
223
223
  if ((yield* host.realPath(topLevel.right)) !== (yield* host.realPath(host.root))) return
224
224
  if ((yield* (yield* ConfigDiscovery).workflowModule(host.root, host.home)) !== undefined) return
225
- yield* (yield* Workspace).write(WORKFLOW_MODULE, CONFIG_SCAFFOLD)
226
225
  yield* (yield* Narrator).warn(
227
- `gtd install: wrote ${WORKFLOW_MODULE}, importing gtd's shipped workflows — commit it before your first \`gtd land\``,
226
+ `gtd install: no ${WORKFLOW_MODULE} governs here, and gtd ships no workflow — get one from ${WORKFLOWS_REPO} or write your own (step 2 of the briefing above)`,
228
227
  )
229
228
  })
230
229
 
@@ -568,10 +567,12 @@ const runStartCommand = (
568
567
  const config = yield* (yield* ConfigService).load
569
568
  const resolved = config.workflowNamed(workflow)
570
569
  if (resolved === undefined) {
570
+ const startable =
571
+ config.workflowNames.length > 0
572
+ ? `startable: ${config.workflowNames.join(", ")}`
573
+ : "this config exports no workflow besides its default, which an ordinary start runs"
571
574
  return yield* Effect.fail(
572
- new GtdUsageError(
573
- `gtd --workflow: unknown workflow "${workflow}" — startable: ${config.workflowNames.join(", ")}`,
574
- ),
575
+ new GtdUsageError(`gtd --workflow: unknown workflow "${workflow}" — ${startable}`),
575
576
  )
576
577
  }
577
578
  const rest = yield* restAt(undefined, varOverrides)
@@ -593,35 +594,57 @@ const runStartCommand = (
593
594
  if (plan.kind === "refusal") {
594
595
  return yield* Effect.fail(new Error(plan.message))
595
596
  }
596
- out.write(landingScript(ScriptSurface.render(plan.steps)))
597
+ out.write(listingCommitted(landingScript(ScriptSurface.render(plan.steps)), rest.changes))
597
598
  })
598
599
 
600
+ /**
601
+ * A start commits the whole tree (`git add -A`), so its script says up front
602
+ * which paths that is. Comment lines only: control characters are masked so a
603
+ * path can never end the comment and become a command.
604
+ */
605
+ const listingCommitted = (script: string, changes: readonly PendingChange[]): string => {
606
+ if (changes.length === 0) return script
607
+ const listing = [
608
+ "# commits every change in the working tree (git add -A):",
609
+ ...changes.map((change) => `# ${change.status} ${change.path.replace(/\p{Cc}/gu, "?")}`),
610
+ ].join("\n")
611
+ const at = script.indexOf("\n\n")
612
+ return at === -1 ? script : `${script.slice(0, at)}\n\n${listing}${script.slice(at)}`
613
+ }
614
+
599
615
  const doorSynopsis = (name: string, door: Door): string =>
600
616
  [
601
617
  name,
602
618
  ...(door.args ?? []).map((arg) => (arg.optional === true ? `[${arg.name}]` : `<${arg.name}>`)),
603
619
  ].join(" ")
604
620
 
621
+ const noSuchDoor = (name: string | undefined, doors: ReadonlyMap<string, Door>): GtdUsageError => {
622
+ const listing =
623
+ doors.size > 0
624
+ ? `doors: ${[...doors].map(([n, d]) => doorSynopsis(n, d)).join(", ")}`
625
+ : "this config has no doors"
626
+ return new GtdUsageError(
627
+ name === undefined
628
+ ? `gtd door: missing door name — ${listing}`
629
+ : `gtd door: unknown door "${name}" — ${listing}`,
630
+ )
631
+ }
632
+
605
633
  /**
606
634
  * `gtd door <name> [args…]`: `gtd --workflow` with the door's workflow and the
607
635
  * process settings its `vars` derive from the positional args. A wrong name or
608
636
  * arity is a usage error naming what would have worked.
609
637
  */
610
638
  const runDoorCommand = (
611
- name: string,
639
+ name: string | undefined,
612
640
  args: readonly string[],
613
641
  out: ArtifactOut,
614
642
  ): Effect.Effect<void, Error, CommandRequirements> =>
615
643
  Effect.gen(function* () {
616
644
  const config = yield* (yield* ConfigService).load
617
- const door = config.doors.get(name)
618
- if (door === undefined) {
619
- const known = [...config.doors]
620
- return yield* Effect.fail(
621
- new GtdUsageError(
622
- `gtd door: unknown door "${name}" — doors: ${known.map(([n, d]) => doorSynopsis(n, d)).join(", ")}`,
623
- ),
624
- )
645
+ const door = name === undefined ? undefined : config.doors.get(name)
646
+ if (name === undefined || door === undefined) {
647
+ return yield* Effect.fail(noSuchDoor(name, config.doors))
625
648
  }
626
649
  const declared = door.args ?? []
627
650
  const required = declared.filter((arg) => arg.optional !== true).length
@@ -652,6 +675,11 @@ const runDoorsCommand = (
652
675
  const config = yield* (yield* ConfigService).load
653
676
  const doors = [...config.doors]
654
677
  if (json.kind === "off") {
678
+ if (doors.length === 0) {
679
+ yield* (yield* Narrator).warn(
680
+ "gtd doors: this config has no doors — a workflow factory's `doors` option or the config's `doors` export adds one",
681
+ )
682
+ }
655
683
  out.write(doors.map(([n, d]) => `${doorSynopsis(n, d)} → ${d.workflow}\n`).join(""))
656
684
  return
657
685
  }
@@ -851,6 +879,12 @@ const runNextCommand = (
851
879
  const validateCommand = emitsValidatablePrompt(rendered)
852
880
  ? selfValidateCommand(rest.def, rest.state, rendered.mode!, rendered.file!)
853
881
  : undefined
882
+ if (DEMAND_BRIEFING[fields.kind].suppressHeader) {
883
+ // A prompt's stdout is the agent's input, so its header goes to stderr;
884
+ // rendered as a header-showing kind to reuse the one header layout.
885
+ const shown = renderBeatPlain({ ...fields, kind: "message" })
886
+ yield* narrator.warn(shown.slice(0, shown.indexOf("\n\n")))
887
+ }
854
888
  out.write(renderBeatPlain(fields, validateCommand))
855
889
  }
856
890
  })
@@ -1127,19 +1161,30 @@ const runUiCliCommand = (
1127
1161
  */
1128
1162
  const assertRunningFromRepoRoot = (git: GitOperations, host: HostOps): Effect.Effect<void, Error> =>
1129
1163
  Effect.gen(function* () {
1130
- const topLevel = yield* git.topLevel()
1164
+ const topLevel = yield* git
1165
+ .topLevel()
1166
+ .pipe(
1167
+ Effect.mapError((e) =>
1168
+ /not a git repository/.test(e.message)
1169
+ ? new Error("gtd: not a git repository — run gtd inside one (`git init` makes one)")
1170
+ : e,
1171
+ ),
1172
+ )
1131
1173
  const topReal = yield* host.realPath(topLevel)
1132
1174
  const cwdReal = yield* host.realPath(host.root)
1133
1175
  if (topReal !== cwdReal) {
1134
1176
  return yield* Effect.fail(
1135
1177
  new Error(
1136
- `gtd must be run from the repository root (${topLevel}); ` +
1137
- `the current directory is ${host.root}`,
1178
+ `gtd: run gtd from the repository root — cd ${shellWord(topLevel)} ` +
1179
+ `(the current directory is ${host.root})`,
1138
1180
  ),
1139
1181
  )
1140
1182
  }
1141
1183
  })
1142
1184
 
1185
+ const shellWord = (path: string): string =>
1186
+ /^[\w@%+=:,./-]+$/.test(path) ? path : `'${path.replace(/'/g, "'\\''")}'`
1187
+
1143
1188
  /** A command that derives workflow state needs at least one commit too — a commitless repository has no HEAD to resolve a `Rest` at. */
1144
1189
  const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Error> =>
1145
1190
  Effect.gen(function* () {
@@ -1147,7 +1192,7 @@ const assertRepositoryHasCommits = (git: GitOperations): Effect.Effect<void, Err
1147
1192
  if (!hasCommits) {
1148
1193
  return yield* Effect.fail(
1149
1194
  new Error(
1150
- "gtd requires a repository with at least one commit — make an initial commit, then run gtd again",
1195
+ "gtd: the repository has no commits yet — make an initial commit, then run gtd again",
1151
1196
  ),
1152
1197
  )
1153
1198
  }
package/src/edge/Edge.ts CHANGED
@@ -995,7 +995,7 @@ export const restAt = (
995
995
  if (stepDef.mode !== undefined && def.modes[stepDef.mode] === undefined) {
996
996
  return yield* Effect.fail(
997
997
  new Error(
998
- `gtd config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
998
+ `gtd: config: step "${step.name}": mode "${stepDef.mode}" is not a mode this workflow knows (${knownModes(def).join(", ")})`,
999
999
  ),
1000
1000
  )
1001
1001
  }
@@ -1476,12 +1476,12 @@ const historySteps = (run: ProcessRun): readonly HistoryStep[] => {
1476
1476
  const versionWarningFor = (recorded: string | undefined): string | undefined =>
1477
1477
  recorded === GTD_VERSION
1478
1478
  ? undefined
1479
- : `gtd: warning: this history was recorded by ${recorded === undefined ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — a workflow imported from @pmelab/gtd/workflows may have changed since`
1479
+ : `gtd: warning: this history was recorded by ${recorded === undefined ? "an unknown gtd version" : `gtd ${recorded}`} and is replayed by gtd ${GTD_VERSION} — the engine the workflow runs on may have changed since`
1480
1480
 
1481
1481
  /**
1482
1482
  * Replays the closed run at `tip` under today's config. The workflow is not
1483
- * fingerprinted for a version change: one imported from `@pmelab/gtd/workflows`
1484
- * moves with every upgrade, and the version warning is the only signal.
1483
+ * fingerprinted for a version change: the engine under it moves with every
1484
+ * upgrade, and the version warning is the only signal.
1485
1485
  */
1486
1486
  export const historyTrail = (tip: string): Effect.Effect<HistoryTrail, Error, RestRequirements> =>
1487
1487
  Effect.gen(function* () {
@@ -359,7 +359,7 @@ describe("real repo — an assembled script end to end", () => {
359
359
  describe("combinedScript — the plain-text write commands' single pasteable script", () => {
360
360
  it("DID_NOT_RUN_COMMENT points the reader at sh, not bash", () => {
361
361
  expect(DID_NOT_RUN_COMMENT).toBe(
362
- "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn",
362
+ "# gtd emitted this and did NOT run it — run it with `sh` to apply it",
363
363
  )
364
364
  expect(DID_NOT_RUN_COMMENT).not.toContain("bash")
365
365
  })
package/src/emit/Emit.ts CHANGED
@@ -112,7 +112,7 @@ export const emitScripts = (
112
112
 
113
113
  /** Exported so `src/twins/EmittedScriptRecognizer.ts` can recognize these by exact string comparison. `printf`, not `echo`, avoids shell-specific backslash-escape quirks. */
114
114
  export const DID_NOT_RUN_COMMENT =
115
- "# gtd emitted this and did NOT run it — pipe it into `sh` to land the turn"
115
+ "# gtd emitted this and did NOT run it — run it with `sh` to apply it"
116
116
 
117
117
  export const PRESENTATION_ONLY_COMMENT = "# presentation only — safe to skip"
118
118
 
@@ -102,16 +102,3 @@ describe("flagSettings", () => {
102
102
  })
103
103
  })
104
104
  })
105
-
106
- describe("risk_tier", () => {
107
- it("is a release flag, off by default, resolved from GTD_FLAG_RISK_TIER", () => {
108
- expect(FLAGS.find((f) => f.name === "risk_tier")).toMatchObject({ kind: "release" })
109
- expect(resolveFlags({}).risk_tier).toBe(false)
110
- expect(resolveFlags({ GTD_FLAG_RISK_TIER: "1" }).risk_tier).toBe(true)
111
- })
112
-
113
- it("is pinned as flag_risk_tier", () => {
114
- expect(flagSettings(resolveFlags({ GTD_FLAG_RISK_TIER: "1" })).flag_risk_tier).toBe("1")
115
- expect(flagSettings(resolveFlags({})).flag_risk_tier).toBe("0")
116
- })
117
- })
@@ -13,9 +13,7 @@ export interface FlagDeclaration {
13
13
  readonly defaultOn?: boolean
14
14
  }
15
15
 
16
- export const FLAGS = [
17
- { name: "risk_tier", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
18
- ] as const satisfies readonly FlagDeclaration[]
16
+ export const FLAGS = [] as const satisfies readonly FlagDeclaration[]
19
17
 
20
18
  /** A misspelt name is a compile error. */
21
19
  export type Flags<R extends readonly FlagDeclaration[] = typeof FLAGS> = {