@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
@@ -0,0 +1,7 @@
1
+ // gtd's own imports of the flows facade: everything public, plus what only the
2
+ // engine uses.
3
+ export * from "./index.js"
4
+ export { installContext, protocolMismatch, type FlowContext, type StepRequest } from "./runtime.js"
5
+ export { markedOptions } from "./runtime.js"
6
+ export { configure, isFactoryWorkflow, type OptionIssue } from "./factory.js"
7
+ export { configProblems } from "./config.js"
@@ -1,13 +1,29 @@
1
- import { afterEach, describe, expect, it } from "vitest"
1
+ import { afterEach, describe, expect, expectTypeOf, it } from "vitest"
2
2
  import {
3
3
  FLOWS_PROTOCOL,
4
4
  FlowsProtocolError,
5
+ envSettings,
6
+ envSettingsOf,
5
7
  head,
8
+ human,
6
9
  installContext,
10
+ markedOptions,
11
+ judge,
7
12
  kept,
8
13
  mark,
9
14
  protocolMismatch,
15
+ settings,
16
+ settingsOf,
17
+ workflow,
10
18
  type FlowContext,
19
+ type AgentOptions,
20
+ type HumanOptions,
21
+ type Judgment,
22
+ type ModeName,
23
+ type RunOptions,
24
+ type Settings,
25
+ type SteeringOptions,
26
+ type StepRequest,
11
27
  } from "./runtime.js"
12
28
 
13
29
  afterEach(() => installContext(undefined))
@@ -127,3 +143,178 @@ describe("kept()", () => {
127
143
  expect(FLOWS_PROTOCOL).toBe(1)
128
144
  })
129
145
  })
146
+
147
+ /** A context that records every step request and resolves it with `reply`. */
148
+ const recording = (reply: unknown = undefined) => {
149
+ const requests: StepRequest[] = []
150
+ installContext({
151
+ step: async (request: StepRequest) => {
152
+ requests.push(request)
153
+ return reply
154
+ },
155
+ vars: { greeting: "hi" },
156
+ env: { testCommand: "npm test" },
157
+ } as unknown as FlowContext)
158
+ return requests
159
+ }
160
+
161
+ describe("human()", () => {
162
+ it("takes its message positionally, the rest as options", async () => {
163
+ const requests = recording()
164
+ await human("gate", "Sign off.", { acceptClean: true })
165
+ await human("plain", { message: "As before." })
166
+ await human("bare")
167
+ expect(requests.map((r) => (r.kind === "human" ? r.options : undefined))).toEqual([
168
+ { acceptClean: true, message: "Sign off." },
169
+ { message: "As before." },
170
+ {},
171
+ ])
172
+ })
173
+
174
+ it("types the positional form without a message option", () => {
175
+ expectTypeOf(human).toBeCallableWith("gate", "Sign off.")
176
+ expectTypeOf(human).toBeCallableWith("gate", { message: "m" } satisfies HumanOptions)
177
+ // @ts-expect-error -- the message is positional in this form
178
+ void (() => human("gate", "m", { message: "twice" }))
179
+ })
180
+ })
181
+
182
+ describe("judge()", () => {
183
+ const question = { id: "ok", instructions: "Is it?", criteria: "yes or no" } as const
184
+
185
+ it("hands a yesno question on as noul, and passes file, mode and base through", async () => {
186
+ const requests = recording({ answers: {}, truncated: [] })
187
+ await judge("judging", {
188
+ questions: [{ ...question, primitive: "yesno" }],
189
+ evidence: { a: "b" },
190
+ file: ".gtd/J.md",
191
+ mode: "qa",
192
+ base: "abc",
193
+ label: "Judging",
194
+ })
195
+ expect(requests).toEqual([
196
+ {
197
+ kind: "judge",
198
+ name: "judging",
199
+ questions: [{ ...question, primitive: "noul" }],
200
+ evidence: { a: "b" },
201
+ options: { file: ".gtd/J.md", mode: "qa", base: "abc", label: "Judging" },
202
+ },
203
+ ])
204
+ })
205
+
206
+ it("keys the answers by the question ids: a literal id allows only itself, a computed one any key", () => {
207
+ const judged = () =>
208
+ judge("j", {
209
+ questions: [
210
+ { ...question, primitive: "noul" },
211
+ { ...question, id: "shape", primitive: "choice" },
212
+ ],
213
+ evidence: {},
214
+ })
215
+ expectTypeOf(judged).returns.resolves.toEqualTypeOf<Judgment<"ok" | "shape">>()
216
+ expectTypeOf<Judgment<"ok">["answers"]>().toHaveProperty("ok")
217
+ // @ts-expect-error -- "nope" is no question's id
218
+ void ((j: Judgment<"ok">) => j.answers.nope)
219
+ const computed = (j: Judgment, id: string) => j.answers[id]
220
+ expectTypeOf(computed).returns.toEqualTypeOf<
221
+ { readonly answer: string; readonly p: number } | undefined
222
+ >()
223
+ const ids = ["a", "b"].map(
224
+ (i) => ({ ...question, id: `standard-${i}`, primitive: "noul" }) as const,
225
+ )
226
+ const judgedComputed = () => judge("k", { questions: ids, evidence: {} })
227
+ expectTypeOf(judgedComputed).returns.resolves.toEqualTypeOf<Judgment<`standard-${string}`>>()
228
+ expectTypeOf<Judgment<"ok">>().toMatchTypeOf<Judgment>()
229
+ })
230
+ })
231
+
232
+ describe("settings", () => {
233
+ it("read the replay's process and environment settings", () => {
234
+ recording()
235
+ expect(settings.greeting).toBe("hi")
236
+ expect(envSettings.testCommand).toBe("npm test")
237
+ })
238
+
239
+ it("settingsOf() types the declared names, reading the same settings", () => {
240
+ const defaults = { greeting: "hello" }
241
+ const envDefaults = { testCommand: "npm test" }
242
+ const typed = settingsOf(defaults)
243
+ const typedEnv = envSettingsOf(envDefaults)
244
+ recording()
245
+ expect(typed.greeting).toBe("hi")
246
+ expect(typedEnv.testCommand).toBe("npm test")
247
+ expectTypeOf(typed.greeting).toEqualTypeOf<string>()
248
+ expectTypeOf(typed).toEqualTypeOf<Settings<"greeting">>()
249
+ // @ts-expect-error -- not a declared name
250
+ void typed.greting
251
+ })
252
+
253
+ it("can be taken outside a replay, read inside one", () => {
254
+ expect(() => settingsOf({ a: "" })).not.toThrow()
255
+ })
256
+ })
257
+
258
+ describe("workflow()", () => {
259
+ it("returns a new marked flow that runs the one it is given, which stays unmarked", async () => {
260
+ let ran = 0
261
+ const flow = async function feature(): Promise<void> {
262
+ ran++
263
+ }
264
+ const marked = workflow(flow, { defaults: { a: "1" } })
265
+ expect(marked).not.toBe(flow)
266
+ expect(marked.name).toBe("feature")
267
+ expect(markedOptions(flow)).toBeUndefined()
268
+ expect(markedOptions(marked)).toEqual({ defaults: { a: "1" } })
269
+ await marked()
270
+ expect(ran).toBe(1)
271
+ expect(markedOptions(workflow(async () => {}))).toEqual({})
272
+ expect(markedOptions({})).toBeUndefined()
273
+ })
274
+
275
+ it("re-wraps a marked flow with the options it is given, the old ones not inherited", () => {
276
+ const library = workflow(async () => {}, { defaults: { a: "1" }, summary: () => "s" })
277
+ const mine = workflow(library, { envDefaults: { b: "2" } })
278
+ expect(markedOptions(mine)).toEqual({ envDefaults: { b: "2" } })
279
+ expect(markedOptions(library)).toEqual({ defaults: { a: "1" }, summary: expect.any(Function) })
280
+ })
281
+
282
+ it("takes a factory build's options, its flow aside", () => {
283
+ expectTypeOf(workflow).toBeCallableWith(async () => {}, {
284
+ defaults: { a: "1" },
285
+ envDefaults: { b: "2" },
286
+ steering: { ".gtd/A.md": "qa" },
287
+ skills: { build: ["x"] },
288
+ access: () => ({ build: { write: [] } }),
289
+ doors: { hot: { args: [{ name: "x" }] } },
290
+ base: () => undefined,
291
+ summary: () => "s",
292
+ })
293
+ // @ts-expect-error -- the flow is the first argument
294
+ void (() => workflow(async () => {}, { flow: async () => {} }))
295
+ })
296
+ })
297
+
298
+ describe("step options", () => {
299
+ it("reject the combinations replay refuses", () => {
300
+ expectTypeOf<{ file: ".gtd/A.md"; mode: "qa" }>().toMatchTypeOf<RunOptions>()
301
+ expectTypeOf<{ file: string | undefined }>().toMatchTypeOf<RunOptions>()
302
+ // @ts-expect-error -- mode requires file
303
+ expectTypeOf<{ mode: "qa" }>().toMatchTypeOf<RunOptions>()
304
+ expectTypeOf<{ approve: true; message: "m" }>().toMatchTypeOf<HumanOptions>()
305
+ // @ts-expect-error -- approve excludes file
306
+ expectTypeOf<{ approve: true; file: ".gtd/A.md" }>().toMatchTypeOf<HumanOptions>()
307
+ expectTypeOf<{ keep: true; allowEmpty: false }>().toMatchTypeOf<AgentOptions>()
308
+ // @ts-expect-error -- keep excludes allowEmpty
309
+ expectTypeOf<{ keep: true; allowEmpty: true }>().toMatchTypeOf<AgentOptions>()
310
+ })
311
+ })
312
+
313
+ describe("mode", () => {
314
+ it("names the built-in modes and still takes any string", () => {
315
+ expectTypeOf<"qa">().toMatchTypeOf<ModeName>()
316
+ expectTypeOf<string>().toMatchTypeOf<ModeName>()
317
+ expectTypeOf<ModeName>().toMatchTypeOf<string>()
318
+ expectTypeOf<SteeringOptions["mode"]>().toEqualTypeOf<ModeName | undefined>()
319
+ })
320
+ })
@@ -3,52 +3,87 @@
3
3
  // a registered symbol, not a module-local variable, so a copy of this file
4
4
  // loaded through a user's `gtd.config.ts` (jiti) and the copy bundled into the
5
5
  // gtd binary drive the same replay.
6
+ import type { WorkflowBuild } from "./factory.js"
6
7
 
7
- /** A steering-file declaration shared by the steps that rest on one. */
8
- export interface SteeringOptions {
9
- /** The steering file — a repository path under `.gtd/`. */
10
- readonly file?: string | undefined
11
- /** The steering file's mode — a built-in name or a `modes:` entry. Requires `file`. */
12
- readonly mode?: string | undefined
8
+ /** A steering-file mode: a built-in format's name or a `config.modes` entry. */
9
+ export type ModeName = "qa" | "review" | "findings" | (string & {})
10
+
11
+ interface SteeringCommon {
13
12
  /** Display name for drivers and viewers. */
14
13
  readonly label?: string | undefined
15
14
  /** The commit this step reviews changes since — what `gtd base` prints while it rests here. */
16
15
  readonly base?: string | undefined
17
16
  }
18
17
 
19
- export interface AgentOptions extends SteeringOptions {
18
+ /** A step's steering file: none, or a file with an optional mode. */
19
+ type SteeringFile =
20
+ | {
21
+ /** The steering file — a repository path under `.gtd/`. */
22
+ readonly file?: string | undefined
23
+ readonly mode?: undefined
24
+ }
25
+ | {
26
+ readonly file: string
27
+ /** The steering file's mode — a built-in name or a `modes:` entry. Requires `file`. */
28
+ readonly mode: ModeName
29
+ }
30
+
31
+ /** A steering-file declaration shared by the steps that rest on one. `mode` requires `file`. */
32
+ export type SteeringOptions = SteeringCommon & SteeringFile
33
+
34
+ interface AgentCommon {
20
35
  readonly model?: string | undefined
21
36
  readonly system?: string | undefined
22
- /**
23
- * A turn that changes nothing completes the step. Without this an empty turn
24
- * is an attempt: recorded, but the process stays at the step (a stall).
25
- */
26
- readonly allowEmpty?: boolean | undefined
27
- /**
28
- * Set the turn aside: its tip goes to `refs/gtd/kept/<entry>/<step>/<hash>` and the
29
- * branch stays where it was, a marker commit recording that the step ran.
30
- * The step's full name must be usable as a git ref component.
31
- */
32
- readonly keep?: boolean | undefined
33
37
  }
34
38
 
35
- export interface HumanOptions extends SteeringOptions {
39
+ export type AgentOptions = SteeringOptions &
40
+ AgentCommon &
41
+ (
42
+ | {
43
+ /**
44
+ * A turn that changes nothing completes the step. Without this an empty turn
45
+ * is an attempt: recorded, but the process stays at the step (a stall).
46
+ */
47
+ readonly allowEmpty?: boolean | undefined
48
+ readonly keep?: false | undefined
49
+ }
50
+ | {
51
+ /**
52
+ * Set the turn aside: its tip goes to `refs/gtd/kept/<entry>/<step>/<hash>` and the
53
+ * branch stays where it was, a marker commit recording that the step ran.
54
+ * The step's full name must be usable as a git ref component. Excludes `allowEmpty`.
55
+ */
56
+ readonly keep: true
57
+ readonly allowEmpty?: false | undefined
58
+ }
59
+ )
60
+
61
+ interface HumanCommon {
36
62
  readonly message?: string | undefined
37
63
  /**
38
64
  * A landing that changes nothing completes the gate — accepting as-is.
39
65
  * Without this a clean landing is a no-op: the gate waits for a change.
40
66
  */
41
67
  readonly acceptClean?: boolean | undefined
42
- /**
43
- * A sign-off gate: only a clean landing completes it, and that commit
44
- * carries `Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}`.
45
- * A dirty landing is refused. Implies `acceptClean`. Cannot be combined with
46
- * `mode` or `file`: an approval records exactly the judged tree.
47
- */
48
- readonly approve?: boolean | undefined
49
68
  }
50
69
 
51
- export interface RunOptions extends SteeringOptions {}
70
+ export type HumanOptions = HumanCommon &
71
+ (
72
+ | (SteeringOptions & { readonly approve?: false | undefined })
73
+ | (SteeringCommon & {
74
+ /**
75
+ * A sign-off gate: only a clean landing completes it, and that commit
76
+ * carries `Gtd-Approved: {"step":"<step>#<n>","name":"<name>","email":"<email>"}`.
77
+ * A dirty landing is refused. Implies `acceptClean`. Excludes `mode` and
78
+ * `file`: an approval records exactly the judged tree.
79
+ */
80
+ readonly approve: true
81
+ readonly file?: undefined
82
+ readonly mode?: undefined
83
+ })
84
+ )
85
+
86
+ export type RunOptions = SteeringOptions
52
87
 
53
88
  export type JudgePrimitive = "noul" | "choice" | "score"
54
89
 
@@ -59,24 +94,36 @@ export interface JudgeQuestion {
59
94
  readonly criteria: string
60
95
  }
61
96
 
62
- export interface JudgeSpec {
63
- readonly questions: readonly JudgeQuestion[]
64
- /** What the judge sees. The `judgeBudgetBytes` budget is split evenly across the keys; a value over its share keeps its end. */
65
- readonly evidence: Readonly<Record<string, string>>
66
- /** The human-facing message a driver unaware of judge gates shows. */
67
- readonly message?: string | undefined
68
- readonly label?: string | undefined
97
+ /** A question as `judge()` takes it: `"yesno"` is another name for `"noul"`. */
98
+ export interface JudgeQuestionInput<Id extends string = string> extends Omit<
99
+ JudgeQuestion,
100
+ "id" | "primitive"
101
+ > {
102
+ readonly id: Id
103
+ readonly primitive: JudgePrimitive | "yesno"
69
104
  }
70
105
 
106
+ export type JudgeSpec<Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]> =
107
+ SteeringOptions & {
108
+ readonly questions: Q
109
+ /** What the judge sees. The `judgeBudgetBytes` budget is split evenly across the keys; a value over its share keeps its end. */
110
+ readonly evidence: Readonly<Record<string, string>>
111
+ /** The human-facing message a driver unaware of judge gates shows. */
112
+ readonly message?: string | undefined
113
+ }
114
+
71
115
  /** One recorded answer. A `noul`'s boolean reads as `"yes"`/`"no"`, a `score` as its decimal string. */
72
116
  export interface JudgeAnswer {
73
117
  readonly answer: string
74
118
  readonly p: number
75
119
  }
76
120
 
77
- export interface Judgment {
78
- /** Every question's answer, `undefined` when the turn landed without a verdict for it. */
79
- readonly answers: Readonly<Record<string, JudgeAnswer | undefined>>
121
+ export interface Judgment<Id extends string = string> {
122
+ /**
123
+ * Every question's answer, `undefined` when the turn landed without a verdict for it.
124
+ * Keyed by the question ids: literal ids allow only themselves, a computed id reads as any string.
125
+ */
126
+ readonly answers: Readonly<Record<Id, JudgeAnswer | undefined>>
80
127
  /** The evidence keys the budget cut. */
81
128
  readonly truncated: readonly string[]
82
129
  }
@@ -102,6 +149,22 @@ export interface ScopeAccess {
102
149
  readonly write?: readonly string[] | undefined
103
150
  }
104
151
 
152
+ /** A workflow's `access` option: scope full name -> access, or a function of the process settings returning that. */
153
+ export type AccessExport =
154
+ | Readonly<Record<string, ScopeAccess>>
155
+ | ((vars: Settings) => Readonly<Record<string, ScopeAccess>>)
156
+
157
+ /** A workflow's `skills` option: scope full name -> skill names, or a function of the process settings returning that. */
158
+ export type SkillsExport =
159
+ | Readonly<Record<string, readonly string[]>>
160
+ | ((vars: Settings) => Readonly<Record<string, readonly string[]>>)
161
+
162
+ /** A workflow's `steering` option: steering file path -> its mode. */
163
+ export type Steering = Readonly<Record<string, ModeName>>
164
+
165
+ /** Settings as flow code reads them; `K` narrows to the names a workflow declares. */
166
+ export type Settings<K extends string = string> = Readonly<Record<K, string>>
167
+
105
168
  /** A resolved access: `null` is an unrestricted side. */
106
169
  export interface AccessDef {
107
170
  readonly read: readonly string[] | null
@@ -247,18 +310,51 @@ const ctx = (): FlowContext => {
247
310
  export const agent = (name: string, prompt: string, options: AgentOptions = {}): Promise<void> =>
248
311
  ctx().step({ kind: "agent", name, prompt, options }) as Promise<void>
249
312
 
313
+ // `Omit` over a union collapses it; this keeps each member's own rules.
314
+ type OmitEach<T, K extends PropertyKey> = T extends unknown ? Omit<T, K> : never
315
+
316
+ export interface Human {
317
+ /** `message` is what the person sees, so `options` take none. */
318
+ (name: string, message: string, options?: OmitEach<HumanOptions, "message">): Promise<void>
319
+ (name: string, options?: HumanOptions): Promise<void>
320
+ }
321
+
250
322
  /** A human gate: the process rests until a person lands a turn. */
251
- export const human = (name: string, options: HumanOptions = {}): Promise<void> =>
252
- ctx().step({ kind: "human", name, options }) as Promise<void>
323
+ export const human: Human = (
324
+ name: string,
325
+ messageOrOptions?: string | HumanOptions,
326
+ options?: OmitEach<HumanOptions, "message">,
327
+ ): Promise<void> =>
328
+ ctx().step({
329
+ kind: "human",
330
+ name,
331
+ options:
332
+ typeof messageOrOptions === "string"
333
+ ? { ...options, message: messageOrOptions }
334
+ : (messageOrOptions ?? {}),
335
+ }) as Promise<void>
253
336
 
254
337
  /** A check: `body` runs at the edge and its effect on the tree is committed. */
255
338
  export const run = (name: string, body: string, options: RunOptions = {}): Promise<void> =>
256
339
  ctx().step({ kind: "run", name, body, options }) as Promise<void>
257
340
 
258
341
  /** A judge gate: resolves to what the judge answered, for the flow to decide on. */
259
- export const judge = (name: string, spec: JudgeSpec): Promise<Judgment> => {
342
+ export const judge = <const Q extends readonly JudgeQuestionInput[] = readonly JudgeQuestion[]>(
343
+ name: string,
344
+ spec: JudgeSpec<Q>,
345
+ ): Promise<Judgment<Q[number]["id"]>> => {
260
346
  const { questions, evidence, ...options } = spec
261
- return ctx().step({ kind: "judge", name, questions, evidence, options }) as Promise<Judgment>
347
+ // Normalized here, so the engine and every driver only ever see `noul`.
348
+ const canonical = questions.map((q) =>
349
+ q.primitive === "yesno" ? { ...q, primitive: "noul" as const } : (q as JudgeQuestion),
350
+ )
351
+ return ctx().step({
352
+ kind: "judge",
353
+ name,
354
+ questions: canonical,
355
+ evidence,
356
+ options,
357
+ }) as Promise<Judgment<Q[number]["id"]>>
262
358
  }
263
359
 
264
360
  /** End the episode from any depth. The next episode starts the flow afresh. */
@@ -380,7 +476,7 @@ export const mark = (name: string, value: string): void => {
380
476
  /** The commit the process stands on at this point of the flow. */
381
477
  export const head = (): string => ctx().head()
382
478
 
383
- /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
479
+ /** The process's diff base: the commit before it began, or the base `gtd start` fixed. */
384
480
  export const start = (): string => ctx().start()
385
481
 
386
482
  /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
@@ -407,14 +503,28 @@ const settingsProxy = (
407
503
  )
408
504
 
409
505
  /** The process settings: pinned at process start, so flow code may branch on them. */
410
- export const vars: Readonly<Record<string, string>> = settingsProxy(() => ctx().vars)
506
+ export const settings: Settings = settingsProxy(() => ctx().vars)
411
507
 
412
508
  /**
413
509
  * The environment settings: resolved live on every invocation. Flow code must
414
510
  * read them only where they cannot change the next step (step options, script
415
511
  * bodies) — replay cannot enforce this.
416
512
  */
417
- export const env: Readonly<Record<string, string>> = settingsProxy(() => ctx().env)
513
+ export const envSettings: Settings = settingsProxy(() => ctx().env)
514
+
515
+ /**
516
+ * `settings`, typed by the names `declared` holds — pass the workflow's
517
+ * `defaults`. Every declared name resolves to a string, so none reads as
518
+ * possibly `undefined`, and a misspelled one is a type error.
519
+ */
520
+ export const settingsOf = <D extends Readonly<Record<string, string>>>(
521
+ _declared: D,
522
+ ): Settings<keyof D & string> => settings as Settings<keyof D & string>
523
+
524
+ /** `envSettings`, typed by the names `declared` holds — pass the workflow's `envDefaults`. */
525
+ export const envSettingsOf = <D extends Readonly<Record<string, string>>>(
526
+ _declared: D,
527
+ ): Settings<keyof D & string> => envSettings as Settings<keyof D & string>
418
528
 
419
529
  // ── Text utilities ──────────────────────────────────────────────────────────
420
530
 
@@ -472,6 +582,33 @@ export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().op
472
582
  /** A workflow's one flow. */
473
583
  export type Flow = () => Promise<void>
474
584
 
585
+ // Registered, like the replay context: any copy of this file marks for the engine's.
586
+ const MARKED = Symbol.for("@pmelab/gtd/workflow")
587
+
588
+ /** What `workflow(flow, options)` takes: a factory build's keys but `flow`. */
589
+ export type WorkflowOptions = Omit<WorkflowBuild, "flow">
590
+
591
+ /**
592
+ * Make `flow` a workflow: exported from `gtd.config.ts`, it is startable by its
593
+ * export name. An exported function not passed through this is a plain helper.
594
+ * `options` are the ones a `defineWorkflow` build returns, checked the same way.
595
+ * Returns a new function that runs `flow`, leaving `flow` itself untouched, so
596
+ * an already-marked flow can be wrapped again: like a build, the options given
597
+ * are the whole set — the wrapped flow's own are not inherited.
598
+ */
599
+ export const workflow = (flow: Flow, options: WorkflowOptions = {}): Flow => {
600
+ const marked: Flow = () => flow()
601
+ Object.defineProperty(marked, "name", { value: flow.name })
602
+ Object.defineProperty(marked, MARKED, { value: { ...options } })
603
+ return marked
604
+ }
605
+
606
+ /** The options `value` was marked with, or `undefined` when it went through no `workflow()`. */
607
+ export const markedOptions = (value: unknown): Readonly<Record<string, unknown>> | undefined =>
608
+ typeof value === "function" && MARKED in value
609
+ ? (value as unknown as Record<symbol, Readonly<Record<string, unknown>>>)[MARKED]
610
+ : undefined
611
+
475
612
  export interface SummaryContext {
476
613
  readonly entryCommit: string
477
614
  readonly processBase: string
@@ -485,23 +622,23 @@ export interface SummaryContext {
485
622
  readonly env: Readonly<Record<string, string>>
486
623
  }
487
624
 
488
- /** `gtd summary`'s prompt — a workflow module's optional `summary` export. */
625
+ /** `gtd summary`'s prompt — a workflow's optional `summary` option. */
489
626
  export type Summary = (context: SummaryContext) => string
490
627
 
491
628
  /**
492
- * A workflow file's optional `base` export: the commitish that fixes the diff
493
- * base of a process `gtd --workflow <workflow>` starts, or `undefined` for
494
- * none. Runs when the process is started, with the vars `--var` sets.
629
+ * A workflow's optional `base` option: the commitish that fixes the diff base
630
+ * of a process `gtd start <workflow>` starts, or `undefined` for none. Runs
631
+ * when the process is started, with the settings `--var` sets.
495
632
  */
496
633
  export type WorkflowBase = (
497
634
  workflow: string,
498
635
  vars: Readonly<Record<string, string>>,
499
636
  ) => string | undefined
500
637
 
501
- /** A named shortcut: `gtd door <name> [args…]` starts `workflow` with the positional args mapped to process settings. */
638
+ /** A named shortcut: `gtd start <name> [args…]` starts `workflow` with the positional args mapped to process settings. */
502
639
  export interface Door {
503
640
  readonly workflow: string
504
- /** Static, so `gtd doors --json` can list them. */
641
+ /** Static, so `gtd start --json` can list them. */
505
642
  readonly args?: readonly { readonly name: string; readonly optional?: boolean }[]
506
643
  /** A pure function of the args: no git runs at declaration time. */
507
644
  readonly vars?: (
@@ -509,8 +646,5 @@ export interface Door {
509
646
  ) => Readonly<Record<string, string>>
510
647
  }
511
648
 
512
- /** A workflow module's reserved `doors` export. */
513
- export type Doors = Readonly<Record<string, Door>>
514
-
515
- /** Doors a workflow factory declares on itself: each starts whatever name the config exports the workflow under. */
649
+ /** Doors a workflow declares on itself: each starts whatever name the config exports the workflow under. */
516
650
  export type WorkflowDoors = Readonly<Record<string, Omit<Door, "workflow">>>
@@ -9,17 +9,17 @@ Feature: a scenario controls the commands and the history a workflow sees
9
9
  Background:
10
10
  Given a gtd config with:
11
11
  """
12
- import { check, human, run, showScript } from "@pmelab/gtd/flows"
12
+ import { check, human, run, showScript, workflow } from "@pmelab/gtd/flows"
13
13
 
14
- export const tested = async () => {
15
- const green = await check("test", "npm test", { report: ".gtd/FEEDBACK.md" })
14
+ export const tested = workflow(async () => {
15
+ const green = await check("test", "npm test", ".gtd/FEEDBACK.md")
16
16
  await human(green ? "sign-off" : "red")
17
- }
17
+ })
18
18
 
19
- export const restored = async () => {
19
+ export const restored = workflow(async () => {
20
20
  await run("restore", showScript("origin/main", "a.txt", "a.txt"))
21
21
  await human("done")
22
- }
22
+ })
23
23
 
24
24
  export default tested
25
25
  """