@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
@@ -16,13 +16,14 @@ import {
16
16
  agent,
17
17
  check,
18
18
  human,
19
+ judge,
19
20
  moveScript,
20
21
  read,
21
22
  removeScript,
22
23
  run,
23
24
  showScript,
24
25
  } from "../flows/index.js"
25
- import { runsItsScript, testRepo } from "./TestRepo.js"
26
+ import { runsItsScript, testRepo, type Actor } from "./TestRepo.js"
26
27
 
27
28
  const tiny = async (): Promise<void> => {
28
29
  await agent("plan", "Write PLAN.md.")
@@ -201,7 +202,51 @@ describe("testRepo", () => {
201
202
  let n = 0
202
203
  await expect(
203
204
  repo.drive({ spin: (_, r) => r.write("n", `${++n}\n`) }, { maxBeats: 5 }),
204
- ).rejects.toThrow(/did not rest within 5 beats/)
205
+ ).rejects.toThrow(
206
+ "did not rest within 5 beats; it passed through:\nspin → spin → spin → spin → spin",
207
+ )
208
+ })
209
+
210
+ describe("judge verdicts", () => {
211
+ const judged = async (): Promise<void> => {
212
+ await agent("plan", "Plan.")
213
+ const { answers } = await judge("ready", {
214
+ questions: [{ id: "ready", primitive: "noul", instructions: "Ready?", criteria: "" }],
215
+ evidence: { plan: "p" },
216
+ })
217
+ await human(answers.ready?.answer === "yes" ? "ship" : "rework")
218
+ }
219
+ const plan: Actor = (_, r) => r.write("PLAN.md", "p\n")
220
+ const template =
221
+ 'expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:\n[{ "id": "ready", "answer": true, "p": 0.9 }]'
222
+
223
+ it("echoes a refused verdict and the shape it should have", async () => {
224
+ const repo = testRepo({ workflow: { default: judged } })
225
+ repo.write("go", "\n")
226
+ const error = await repo
227
+ .drive({ plan, ready: () => '{"ready": true}' })
228
+ .catch((e: Error) => e)
229
+ expect((error as Error).message).toContain(
230
+ `the actor for judge step "ready" sent a verdict gtd refused:\n{"ready": true}\n${template}\ngtd judge answer exited 2\n`,
231
+ )
232
+ })
233
+
234
+ it("says an actor returned an object rather than no verdict", async () => {
235
+ const repo = testRepo({ workflow: { default: judged } })
236
+ repo.write("go", "\n")
237
+ const actor = (() => [{ id: "ready", answer: true, p: 0.9 }]) as unknown as Actor
238
+ await expect(repo.drive({ plan, ready: actor })).rejects.toThrow(
239
+ `the actor for judge step "ready" returned an array, not a JSON string — JSON.stringify it\n${template}`,
240
+ )
241
+ })
242
+
243
+ it("says an actor returned nothing", async () => {
244
+ const repo = testRepo({ workflow: { default: judged } })
245
+ repo.write("go", "\n")
246
+ await expect(repo.drive({ plan, ready: () => undefined })).rejects.toThrow(
247
+ `the actor for judge step "ready" returned no verdict\n${template}`,
248
+ )
249
+ })
205
250
  })
206
251
 
207
252
  describe("canned commands", () => {
@@ -204,6 +204,30 @@ const runLines = (repo: InMemRepo, commands: CommandTable, script: string): void
204
204
  const failed = (what: string, result: CapturedCliResult): Error =>
205
205
  new Error(`${what} exited ${result.exitCode}\n${result.stderr}${result.stdout}`)
206
206
 
207
+ const ANSWER: Readonly<Record<string, string>> = {
208
+ noul: "true",
209
+ choice: '"<option>"',
210
+ score: "0.5",
211
+ }
212
+
213
+ /** The verdict a judge beat's questions take, as a template to copy. */
214
+ const expectedVerdict = (doc: BeatDocument): string => {
215
+ let questions: readonly { id: string; primitive: string }[] = []
216
+ try {
217
+ questions = (JSON.parse(doc.judge ?? "") as { questions: typeof questions }).questions
218
+ } catch {
219
+ // An unreadable judge document leaves the template without entries.
220
+ }
221
+ const entries = questions.map(
222
+ ({ id, primitive }) =>
223
+ `{ "id": ${JSON.stringify(id)}, "answer": ${ANSWER[primitive] ?? '"<answer>"'}, "p": 0.9 }`,
224
+ )
225
+ return [
226
+ "expected a JSON array, one entry per question — answer a boolean for a noul, a string for a choice, a number for a score; p between 0 and 1:",
227
+ `[${entries.join(", ")}]`,
228
+ ].join("\n")
229
+ }
230
+
207
231
  export const testRepo = (options: TestRepoOptions): TestRepo => {
208
232
  const repo = new InMemRepo()
209
233
  const env = { ...options.env }
@@ -261,11 +285,15 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
261
285
  }
262
286
 
263
287
  /** Runs a write command, then the script its `--json=script` twin prints. */
264
- const runWrite = async (args: readonly string[], stdin?: string): Promise<CapturedCliResult> => {
288
+ const runWrite = async (
289
+ args: readonly string[],
290
+ stdin?: string,
291
+ refused: (error: Error) => Error = (error) => error,
292
+ ): Promise<CapturedCliResult> => {
265
293
  const call = (...extra: string[]) =>
266
294
  stdin === undefined ? gtd(...args, ...extra) : withStdin(stdin, () => gtd(...args, ...extra))
267
295
  const reported = await call()
268
- if (reported.exitCode !== EXIT_OK) throw failed(`gtd ${args.join(" ")}`, reported)
296
+ if (reported.exitCode !== EXIT_OK) throw refused(failed(`gtd ${args.join(" ")}`, reported))
269
297
  const script = await call("--json=script")
270
298
  apply(`gtd ${args.join(" ")}`, script.stdout)
271
299
  return reported
@@ -286,16 +314,29 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
286
314
  }
287
315
 
288
316
  /** Lands what an actor did: a judge's verdict answered, anything else validated and landed. */
289
- const act = async (doc: BeatDocument, verdict: void | string): Promise<void> => {
317
+ const act = async (doc: BeatDocument, verdict: unknown): Promise<void> => {
290
318
  if (doc.actor !== "judge") {
291
319
  if (doc.validate !== undefined) apply(`validating ${doc.file ?? doc.state}`, doc.validate)
292
320
  await runWrite(["land"])
293
321
  return
294
322
  }
323
+ const actor = `the actor for judge step "${doc.state}"`
324
+ if (verdict === undefined) {
325
+ throw new Error(`${actor} returned no verdict\n${expectedVerdict(doc)}`)
326
+ }
295
327
  if (typeof verdict !== "string") {
296
- throw new Error(`the actor for judge step "${doc.state}" returned no verdict`)
328
+ throw new Error(
329
+ `${actor} returned ${Array.isArray(verdict) ? "an array" : typeof verdict === "object" ? "an object" : `a ${typeof verdict}`}, not a JSON string — JSON.stringify it\n${expectedVerdict(doc)}`,
330
+ )
297
331
  }
298
- await runWrite(["judge", "answer"], verdict)
332
+ await runWrite(
333
+ ["judge", "answer"],
334
+ verdict,
335
+ (error) =>
336
+ new Error(
337
+ `${actor} sent a verdict gtd refused:\n${verdict}\n${expectedVerdict(doc)}\n${error.message}`,
338
+ ),
339
+ )
299
340
  }
300
341
 
301
342
  const self: TestRepo = {
@@ -327,8 +368,10 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
327
368
  land: () => runWrite(["land"]),
328
369
  next,
329
370
  drive: async (actors = {}, { maxBeats = 50 } = {}) => {
371
+ const passed: string[] = []
330
372
  for (let beat = 0; beat < maxBeats; beat++) {
331
373
  const doc = await next()
374
+ passed.push(doc.state)
332
375
  if (doc.kind === "stalled") return doc
333
376
  if (doc.kind === "capture") {
334
377
  await runWrite(["land"])
@@ -338,7 +381,9 @@ export const testRepo = (options: TestRepoOptions): TestRepo => {
338
381
  if (actor === undefined) return doc
339
382
  await act(doc, await actor(doc, self))
340
383
  }
341
- throw new Error(`the process did not rest within ${maxBeats} beats`)
384
+ throw new Error(
385
+ `the process did not rest within ${maxBeats} beats; it passed through:\n${passed.join(" → ")}`,
386
+ )
342
387
  },
343
388
  subjects: () => repo.commitHistory().map(({ message }) => message.split("\n")[0] ?? ""),
344
389
  messages: () => repo.commitHistory().map(({ message }) => message),
@@ -16,7 +16,6 @@ import {
16
16
  softResetTo,
17
17
  updateRef,
18
18
  binaryGuard,
19
- DID_NOT_RUN_COMMENT,
20
19
  failurePromptWrapper,
21
20
  fileExistsGuard,
22
21
  PRESENTATION_FAILURE_WARNING,
@@ -552,13 +551,11 @@ const recognizeGtdUncheck = (repo: InMemRepo, block: string): BlockOutcome | und
552
551
  }
553
552
 
554
553
  /**
555
- * `Emit.ts`'s `combinedScript` leading comment — `gtd land`/`gtd --workflow`'s
556
- * whole plain-text artifact opens with this line ahead of the required
557
- * script, so recognizing it as a no-op (like the retry-helper function
558
- * definition) keeps the `@inmem` tier green.
554
+ * A block of nothing but comments is a no-op in `sh` too — `combinedScript`'s
555
+ * leading `DID_NOT_RUN_COMMENT`, and the list of paths a start commits.
559
556
  */
560
- const recognizeDidNotRunComment = (block: string): BlockOutcome | undefined =>
561
- block === DID_NOT_RUN_COMMENT ? { kind: "noop" } : undefined
557
+ const recognizeCommentBlock = (block: string): BlockOutcome | undefined =>
558
+ block.split("\n").every((line) => line.startsWith("#")) ? { kind: "noop" } : undefined
562
559
 
563
560
  /** `combinedScript`'s optional half. Reports `noop` whatever the inner result, mirroring bash's `( … ) || <warning>`. Exception: under `git: false` any unrecognized inner block fails the outer script (git or not), so the in-memory twin knowingly diverges from `sh` rather than run git unseen. */
564
561
  const PRESENTATION_FAILURE_TEXT = `${extractQuotedTokens(PRESENTATION_FAILURE_WARNING)[0]!.replace(/\\n$/, "")}\n`
@@ -695,7 +692,7 @@ const recognizersFor = (
695
692
  git: boolean,
696
693
  ): ReadonlyArray<(block: string) => BlockOutcome | undefined> => [
697
694
  (block) => recognizeSetFlags(block),
698
- (block) => recognizeDidNotRunComment(block),
695
+ (block) => recognizeCommentBlock(block),
699
696
  (block) => recognizePrecondition(repo, block),
700
697
  (block) => recognizeFileExistsGuard(repo, block),
701
698
  (block) => recognizeBinaryGuard(commands, block),
@@ -224,7 +224,7 @@ const recognizePatch = (repo: InMemRepo, script: string): FlowScriptResult | und
224
224
 
225
225
  type PreambleOutcome = { readonly exit: boolean; readonly output: string }
226
226
 
227
- /** One preamble line — the closed vocabulary the bundled workflows write there. */
227
+ /** One preamble line — the closed vocabulary workflows write there. */
228
228
  const runPreambleLine = (repo: InMemRepo, line: string): PreambleOutcome => {
229
229
  const quoted = words(line)
230
230
  if (line === "exit 0") return { exit: true, output: "" }
package/src/wire/Land.ts CHANGED
@@ -71,5 +71,5 @@ export const landProseText = (subject: string): string => renderFormat(FMT_LAND_
71
71
  */
72
72
  export const renderLandPlain = (fields: LandFields, command = "land"): string =>
73
73
  fields.subject !== null
74
- ? `${landProseText(fields.subject).trimEnd()}\n(run \`gtd ${command} --json=script | sh\` to get the landing script)\n`
74
+ ? `${landProseText(fields.subject).trimEnd()}\n(to land it: s=$(gtd ${command} --json=script) && sh -c "$s")\n`
75
75
  : noopText(fields.state)
@@ -15,6 +15,7 @@ export {
15
15
  export { formatDiagnostic } from "./Diagnostic.js"
16
16
  export {
17
17
  resolveVars,
18
+ ownFlagRefusal,
18
19
  resolveStartVars,
19
20
  StartFlags,
20
21
  multilineSetting,
@@ -15,7 +15,6 @@ import {
15
15
  resolveVars,
16
16
  seededValidateCommand,
17
17
  } from "./index.js"
18
- import { CONFIG_SCAFFOLD } from "../install/index.js"
19
18
 
20
19
  // Every real port `ConfigService.Live` needs, scoped to `dir` — `home` is a
21
20
  // fresh directory above the project, so the walk stops there as it would in a
@@ -71,12 +70,109 @@ const loadAndResolve = (dir: string = projectDir, vars: Env = {}) =>
71
70
  let homeDir: string
72
71
  let projectDir: string
73
72
 
74
- // Every test starts where `gtd install` leaves a repository.
73
+ // A config whose default workflow rests at `idle`, declaring a process
74
+ // setting, an environment setting and the scopes its turns run under.
75
+ const BASE_CONFIG = `import { human } from "@pmelab/gtd/flows"
76
+
77
+ export default async () => {
78
+ await human("idle", { message: "hi" })
79
+ }
80
+
81
+ export const defaults = { qualityReviews: "" }
82
+ export const envDefaults = { testCommand: "npm test" }
83
+
84
+ export const skills = (vars) => ({
85
+ build: [],
86
+ "build.fix.quality": [],
87
+ "build.review": [],
88
+ ...Object.fromEntries(
89
+ (vars.qualityReviews ?? "")
90
+ .split(",")
91
+ .map((lens) => lens.trim())
92
+ .filter((lens) => lens !== "")
93
+ .map((lens) => [\`build.quality.\${lens}\`, []]),
94
+ ),
95
+ })
96
+ `
97
+
98
+ // Workflow factories a config imports from a sibling module: string options
99
+ // with defaults, an unknown or non-string option being an issue.
100
+ const FACTORIES = `import { defineWorkflow, human } from "@pmelab/gtd/flows"
101
+
102
+ const settings = (known) => ({
103
+ "~standard": {
104
+ version: 1,
105
+ vendor: "test",
106
+ validate: (value) => {
107
+ const issues = Object.entries(value).flatMap(([key, given]) =>
108
+ !Object.hasOwn(known, key)
109
+ ? [{ path: [key], message: \`is not an option — options: \${Object.keys(known).join(", ")}\` }]
110
+ : typeof given === "string"
111
+ ? []
112
+ : [{ path: [key], message: "must be a string" }],
113
+ )
114
+ return issues.length > 0 ? { issues } : { value: { ...known, ...value } }
115
+ },
116
+ },
117
+ })
118
+
119
+ export const feature = defineWorkflow(
120
+ settings({ qualityReviews: "", reviewNoteActionable: "0.7", testCommand: "npm test" }),
121
+ ({ testCommand, ...defaults }) => ({
122
+ flow: async () => {
123
+ await human("idle")
124
+ },
125
+ defaults,
126
+ envDefaults: { testCommand },
127
+ }),
128
+ )
129
+
130
+ export const review = defineWorkflow(settings({ reviewNoteActionable: "0.7" }), (defaults) => ({
131
+ flow: async () => {
132
+ await human("review-gate")
133
+ },
134
+ defaults: { ...defaults, reviewBase: "" },
135
+ doors: {
136
+ review: {
137
+ args: [{ name: "base", optional: true }],
138
+ vars: ({ base }) => (base === undefined ? {} : { reviewBase: base }),
139
+ },
140
+ },
141
+ base: (_workflow, vars) => vars.reviewBase || "",
142
+ }))
143
+
144
+ export const fix = defineWorkflow(settings({ judgeBudgetBytes: "65536" }), (defaults) => ({
145
+ flow: async () => {
146
+ await human("fix-precheck")
147
+ },
148
+ defaults,
149
+ doors: { fix: {} },
150
+ }))
151
+
152
+ export const rollout = defineWorkflow(settings({ flagRegistry: "flags.ts" }), (defaults) => ({
153
+ flow: async () => {
154
+ await human("flipping")
155
+ },
156
+ defaults: { ...defaults, flag: "" },
157
+ doors: { rollout: { args: [{ name: "flag" }], vars: ({ flag }) => ({ flag: flag ?? "" }) } },
158
+ }))
159
+
160
+ export const unwind = async () => {
161
+ await human("unwinding")
162
+ }
163
+ `
164
+
165
+ /** Writes `config` as gtd.config.ts beside {@link FACTORIES} as `workflows.ts`. */
166
+ const withFactories = (config: string): void => {
167
+ writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
168
+ writeFileSync(join(projectDir, "gtd.config.ts"), config)
169
+ }
170
+
75
171
  beforeEach(() => {
76
172
  homeDir = mkdtempSync(join(tmpdir(), "gtd-config-"))
77
173
  projectDir = join(homeDir, "project")
78
174
  mkdirSync(projectDir)
79
- writeFileSync(join(projectDir, "gtd.config.ts"), CONFIG_SCAFFOLD)
175
+ writeFileSync(join(projectDir, "gtd.config.ts"), BASE_CONFIG)
80
176
  })
81
177
 
82
178
  afterEach(() => {
@@ -97,11 +193,11 @@ const minimalWorkflow = (first: string) =>
97
193
  const withConfig = (config: string) =>
98
194
  writeFileSync(
99
195
  join(projectDir, "gtd.config.ts"),
100
- `${CONFIG_SCAFFOLD}\nexport const config = ${config}\n`,
196
+ `${BASE_CONFIG}\nexport const config = ${config}\n`,
101
197
  )
102
198
 
103
199
  describe("ConfigService", () => {
104
- it("the scaffolded config runs the shipped feature workflow by default", async () => {
200
+ it("a config runs its default workflow, its envDefaults as environment settings", async () => {
105
201
  const cfg = await getConfig()
106
202
 
107
203
  expect(cfg.workflow.initial).toBe("idle")
@@ -109,11 +205,11 @@ describe("ConfigService", () => {
109
205
  expect(cfg.defaultWorkflow.vars["testCommand"]).toBeUndefined()
110
206
  })
111
207
 
112
- it("with no gtd.config.ts anywhere: fails, saying to run gtd install", async () => {
208
+ it("with no gtd.config.ts anywhere: fails, pointing at gtd install", async () => {
113
209
  rmSync(join(projectDir, "gtd.config.ts"))
114
210
 
115
211
  await expect(getConfig()).rejects.toThrow(
116
- /no workflows configured: no gtd\.config\.ts here or above — run `gtd install`/,
212
+ /no workflows configured: no gtd\.config\.ts here or above — gtd ships no workflow; `gtd install` says where to get one/,
117
213
  )
118
214
  })
119
215
 
@@ -149,7 +245,7 @@ describe("ConfigService", () => {
149
245
  const others = Layer.succeed(ConfigDiscovery, {
150
246
  legacyRcFiles: () => Effect.succeed([{ path: "/shared/.gtdrc", owned: false }]),
151
247
  workflowModule: () =>
152
- Effect.succeed({ filepath: join(projectDir, "gtd.config.ts"), source: CONFIG_SCAFFOLD }),
248
+ Effect.succeed({ filepath: join(projectDir, "gtd.config.ts"), source: BASE_CONFIG }),
153
249
  })
154
250
  const cfg = await Effect.runPromise(
155
251
  Effect.flatMap(ConfigService, (c) => c.load).pipe(
@@ -172,7 +268,7 @@ describe("ConfigService", () => {
172
268
  it("refuses a user-level rc file in home, even when the walk never reaches home", async () => {
173
269
  writeFileSync(join(homeDir, ".gtdrc.json"), `{ "$schema": "x" }\n`)
174
270
  const outside = mkdtempSync(join(tmpdir(), "gtd-outside-"))
175
- writeFileSync(join(outside, "gtd.config.ts"), CONFIG_SCAFFOLD)
271
+ writeFileSync(join(outside, "gtd.config.ts"), BASE_CONFIG)
176
272
 
177
273
  try {
178
274
  await expect(getConfig()).rejects.toThrow(`${join(homeDir, ".gtdrc.json")}: no longer read`)
@@ -233,7 +329,7 @@ describe("ConfigService", () => {
233
329
  join(projectDir, "gtd.config.ts"),
234
330
  [
235
331
  `import { defineConfig } from "@pmelab/gtd/flows"`,
236
- CONFIG_SCAFFOLD,
332
+ BASE_CONFIG,
237
333
  `export const config = defineConfig({ ui: { port: 4173 } })`,
238
334
  ``,
239
335
  ].join("\n"),
@@ -247,7 +343,7 @@ describe("ConfigService", () => {
247
343
  join(projectDir, "gtd.config.ts"),
248
344
  [
249
345
  `import { defineConfig } from "@pmelab/gtd/flows"`,
250
- CONFIG_SCAFFOLD,
346
+ BASE_CONFIG,
251
347
  `export const config = defineConfig({ env: { testCommand: "x" } })`,
252
348
  ``,
253
349
  ].join("\n"),
@@ -318,7 +414,7 @@ describe("ConfigService", () => {
318
414
  expect(Object.keys(cfg.fingerprint).sort()).toEqual(["gtd.config.ts", "steps.ts"])
319
415
  })
320
416
 
321
- it("the fingerprint covers gtd.config.ts but no shipped module it imports", async () => {
417
+ it("the fingerprint covers gtd.config.ts but no gtd module it imports", async () => {
322
418
  const cfg = await getConfig()
323
419
 
324
420
  expect(Object.keys(cfg.fingerprint)).toEqual(["gtd.config.ts"])
@@ -465,7 +561,8 @@ describe("ConfigService", () => {
465
561
  const flow = (first: string) => `async () => {\n await human("${first}")\n}`
466
562
  const header = `import { human } from "@pmelab/gtd/flows"\n`
467
563
 
468
- it("the scaffolded config: feature, review and fix are startable, default is not", async () => {
564
+ it("re-exported factories are startable under their names, default is not", async () => {
565
+ withFactories(`export { feature as default, feature, review, fix } from "./workflows"\n`)
469
566
  const cfg = await getConfig()
470
567
  expect(cfg.workflowNames).toEqual(["feature", "fix", "review"])
471
568
  expect(cfg.defaultWorkflow.name).toBe("feature")
@@ -512,38 +609,12 @@ describe("ConfigService", () => {
512
609
  expect((await getConfig()).defaultWorkflow.name).toBe("hotfix")
513
610
  })
514
611
 
515
- it("a helper re-exported from the shipped module becomes a startable workflow", async () => {
516
- writeFileSync(
517
- join(projectDir, "gtd.config.ts"),
518
- [`export { feature as default, unwind } from "@pmelab/gtd/workflows"`, ``].join("\n"),
519
- )
612
+ it("a helper re-exported from a workflow module becomes a startable workflow", async () => {
613
+ withFactories(`export { feature as default, unwind } from "./workflows"\n`)
520
614
  expect((await getConfig()).workflowNames).toContain("unwind")
521
615
  })
522
616
 
523
- it("the old @pmelab/gtd/workflow subpath serves the same module", async () => {
524
- writeFileSync(
525
- join(projectDir, "gtd.config.ts"),
526
- [`export { feature as default, feature } from "@pmelab/gtd/workflow"`, ``].join("\n"),
527
- )
528
- expect((await getConfig()).defaultWorkflow.name).toBe("feature")
529
- })
530
-
531
- it("each shipped workflow has its own subpath", async () => {
532
- writeFileSync(
533
- join(projectDir, "gtd.config.ts"),
534
- [
535
- `export { feature as default, feature } from "@pmelab/gtd/workflows/feature"`,
536
- `export { review } from "@pmelab/gtd/workflows/review"`,
537
- `export { fix } from "@pmelab/gtd/workflows/fix"`,
538
- ``,
539
- ].join("\n"),
540
- )
541
- const config = await getConfig()
542
- expect(config.defaultWorkflow.name).toBe("feature")
543
- expect(config.workflowNames).toEqual(["feature", "fix", "review"])
544
- })
545
-
546
- it("a shipped subpath only a helper file imports resolves", async () => {
617
+ it("a gtd subpath only a helper file imports resolves", async () => {
547
618
  writeFileSync(
548
619
  join(projectDir, "kit.ts"),
549
620
  [
@@ -598,7 +669,8 @@ describe("ConfigService", () => {
598
669
  ].join("\n"),
599
670
  )
600
671
 
601
- it("the scaffolded config: the fix and review doors, review taking an optional base", async () => {
672
+ it("exported factories bring their doors, review taking an optional base", async () => {
673
+ withFactories(`export { feature as default, review, fix } from "./workflows"\n`)
602
674
  const { doors } = await getConfig()
603
675
  expect([...doors.keys()].sort()).toEqual(["fix", "review"])
604
676
  expect(doors.get("fix")?.workflow).toBe("fix")
@@ -606,44 +678,33 @@ describe("ConfigService", () => {
606
678
  })
607
679
 
608
680
  it("a config exporting only fix gets only fix's door", async () => {
609
- writeFileSync(
610
- join(projectDir, "gtd.config.ts"),
611
- [
612
- header,
613
- `export default ${flow("main")}`,
614
- `export { fix } from "@pmelab/gtd/workflows"`,
615
- ``,
616
- ].join("\n"),
681
+ withFactories(
682
+ [header, `export default ${flow("main")}`, `export { fix } from "./workflows"`, ``].join(
683
+ "\n",
684
+ ),
617
685
  )
618
686
  const { doors } = await getConfig()
619
687
  expect([...doors.keys()]).toEqual(["fix"])
620
688
  })
621
689
 
622
690
  it("a door follows the name its workflow is exported under", async () => {
623
- writeFileSync(
624
- join(projectDir, "gtd.config.ts"),
625
- `export { feature as default, review as audit } from "@pmelab/gtd/workflows"\n`,
626
- )
691
+ withFactories(`export { feature as default, review as audit } from "./workflows"\n`)
627
692
  const door = (await getConfig()).doors.get("review")
628
693
  expect(door?.workflow).toBe("audit")
629
694
  expect(door?.vars?.({ base: "main" })).toEqual({ reviewBase: "main" })
630
695
  })
631
696
 
632
697
  it("a review factory exported as audit reviews the default branch, or an explicit base", async () => {
633
- writeFileSync(
634
- join(projectDir, "gtd.config.ts"),
635
- `export { feature as default, review as audit } from "@pmelab/gtd/workflows"\n`,
636
- )
698
+ withFactories(`export { feature as default, review as audit } from "./workflows"\n`)
637
699
  const audit = (await getConfig()).workflowNamed("audit")
638
700
  expect(audit?.def.base?.("audit", {})).toBe("")
639
701
  expect(audit?.def.base?.("audit", { reviewBase: "v1" })).toBe("v1")
640
702
  })
641
703
 
642
704
  it("a config's own base export wins over the review factory's", async () => {
643
- writeFileSync(
644
- join(projectDir, "gtd.config.ts"),
705
+ withFactories(
645
706
  [
646
- `export { feature as default, review } from "@pmelab/gtd/workflows"`,
707
+ `export { feature as default, review } from "./workflows"`,
647
708
  `export const base = (w: string) => (w === "review" ? "develop" : undefined)`,
648
709
  ``,
649
710
  ].join("\n"),
@@ -652,8 +713,8 @@ describe("ConfigService", () => {
652
713
  })
653
714
 
654
715
  it("a door several workflows build gets no door when none is exported under its name, until the doors export declares it", async () => {
655
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "@pmelab/gtd/workflows"`
656
- writeFileSync(join(projectDir, "gtd.config.ts"), `${both}\n`)
716
+ const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
717
+ withFactories(`${both}\n`)
657
718
  expect((await getConfig()).doors.get("fix")).toBeUndefined()
658
719
  writeFileSync(
659
720
  join(projectDir, "gtd.config.ts"),
@@ -663,9 +724,8 @@ describe("ConfigService", () => {
663
724
  })
664
725
 
665
726
  it("a door clash warns once at doors.<name>, builders sorted", async () => {
666
- writeFileSync(
667
- join(projectDir, "gtd.config.ts"),
668
- `export { feature as default, fix as fix3, fix as fix4, fix as fix2 } from "@pmelab/gtd/workflows"\n`,
727
+ withFactories(
728
+ `export { feature as default, fix as fix3, fix as fix4, fix as fix2 } from "./workflows"\n`,
669
729
  )
670
730
  const cfg = await getConfig()
671
731
  const clash = cfg.warnings.filter((w) => w.path[0] === "doors")
@@ -680,70 +740,38 @@ describe("ConfigService", () => {
680
740
  })
681
741
 
682
742
  it("no clash warning when an export is named after the door, one workflow builds it, or doors declares it", async () => {
683
- const both = `export { feature as default, fix as fix2, fix as fix3 } from "@pmelab/gtd/workflows"`
743
+ const both = `export { feature as default, fix as fix2, fix as fix3 } from "./workflows"`
684
744
  for (const body of [
685
- `export { feature as default, fix as fix2, fix } from "@pmelab/gtd/workflows"\n`,
686
- `export { feature as default, fix } from "@pmelab/gtd/workflows"\n`,
745
+ `export { feature as default, fix as fix2, fix } from "./workflows"\n`,
746
+ `export { feature as default, fix } from "./workflows"\n`,
687
747
  `${both}\nexport const doors = { fix: { workflow: "fix3" } }\n`,
688
748
  ]) {
689
- writeFileSync(join(projectDir, "gtd.config.ts"), body)
749
+ withFactories(body)
690
750
  expect((await getConfig()).warnings.filter((w) => w.path[0] === "doors")).toEqual([])
691
751
  }
692
752
  })
693
753
 
694
754
  it("door ownership does not depend on export order", async () => {
695
- writeFileSync(
696
- join(projectDir, "gtd.config.ts"),
697
- `export { feature as default, fix as fix2, fix } from "@pmelab/gtd/workflows"\n`,
698
- )
755
+ withFactories(`export { feature as default, fix as fix2, fix } from "./workflows"\n`)
699
756
  expect((await getConfig()).doors.get("fix")?.workflow).toBe("fix")
700
757
  })
701
758
 
702
759
  it("rollout and a second instance of it load, door rollout running workflow rollout whichever export comes first", async () => {
703
760
  for (const body of [
704
- `export { rollout } from "@pmelab/gtd/workflows"\nimport { rollout } from "@pmelab/gtd/workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })`,
705
- `import { rollout } from "@pmelab/gtd/workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })\nexport { rollout }`,
761
+ `export { rollout } from "./workflows"\nimport { rollout } from "./workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })`,
762
+ `import { rollout } from "./workflows"\nexport const elsewhere = rollout({ flagRegistry: "x.ts" })\nexport { rollout }`,
706
763
  ]) {
707
- writeFileSync(
708
- join(projectDir, "gtd.config.ts"),
709
- `export { feature as default } from "@pmelab/gtd/workflows"\n${body}\n`,
710
- )
764
+ withFactories(`export { feature as default } from "./workflows"\n${body}\n`)
711
765
  expect((await getConfig()).doors.get("rollout")?.workflow).toBe("rollout")
712
766
  }
713
767
  })
714
768
 
715
- it("shipped rollout and cleanup list their doors beside fix and review", async () => {
716
- writeFileSync(
717
- join(projectDir, "gtd.config.ts"),
718
- `export { feature as default, fix, review, rollout, cleanup } from "@pmelab/gtd/workflows"\n`,
719
- )
720
- const { doors } = await getConfig()
721
- expect([...doors.keys()]).toEqual(["cleanup", "fix", "review", "rollout"])
722
- expect(doors.get("rollout")?.args).toEqual([{ name: "flag" }])
723
- expect(doors.get("cleanup")?.args).toEqual([{ name: "flag" }])
724
- })
725
-
726
- it("rollout and cleanup load from their own subpaths", async () => {
727
- writeFileSync(
728
- join(projectDir, "gtd.config.ts"),
729
- [
730
- `export { feature as default, fix, review } from "@pmelab/gtd/workflows"`,
731
- `export { rollout } from "@pmelab/gtd/workflows/rollout"`,
732
- `export { cleanup } from "@pmelab/gtd/workflows/cleanup"`,
733
- ``,
734
- ].join("\n"),
735
- )
736
- const { doors } = await getConfig()
737
- expect([...doors.keys()]).toEqual(["cleanup", "fix", "review", "rollout"])
738
- expect(doors.get("rollout")?.args).toEqual([{ name: "flag" }])
739
- expect(doors.get("cleanup")?.args).toEqual([{ name: "flag" }])
740
- })
741
-
742
769
  it("a config's doors merge derived doors (exported workflows) with declared doors (doors export)", async () => {
743
770
  withDoors(
744
771
  `{ hot: { workflow: "hotfix", args: [{ name: "ticket" }] } }`,
745
- 'export { fix } from "@pmelab/gtd/workflows"',
772
+ 'export { fix } from "./workflows"',
746
773
  )
774
+ writeFileSync(join(projectDir, "workflows.ts"), FACTORIES)
747
775
  const { doors } = await getConfig()
748
776
  expect([...doors.keys()].sort()).toEqual(["fix", "hot"])
749
777
  expect(doors.get("fix")).toBeDefined()
@@ -939,10 +967,7 @@ describe("ConfigService", () => {
939
967
 
940
968
  describe("ConfigService — workflow factory options", () => {
941
969
  const configWith = (body: string) =>
942
- writeFileSync(
943
- join(projectDir, "gtd.config.ts"),
944
- [`import { feature, fix, review } from "@pmelab/gtd/workflows"`, body, ``].join("\n"),
945
- )
970
+ withFactories([`import { feature, fix, review } from "./workflows"`, body, ``].join("\n"))
946
971
 
947
972
  it("an option given in gtd.config.ts becomes the workflow's setting of its kind", async () => {
948
973
  configWith(