@pmelab/gtd 59.0.1 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +37 -33
- package/claude/hooks/doors.ts +8 -4
- package/dist/chunks/flows-DENt0uWV.mjs +707 -0
- package/dist/chunks/{steering-CCNvngSH.mjs → steering-DF_BMadH.mjs} +292 -289
- package/dist/chunks/{testkit-07Z_ENch.mjs → testkit-BvecVjQ0.mjs} +570 -390
- package/dist/chunks/{wire-BTUhRGXj.mjs → wire-DZ8R1RU9.mjs} +8 -1
- package/dist/flows.mjs +2 -676
- package/dist/gtd.bundle.mjs +751 -645
- package/dist/steering.mjs +2 -2
- package/dist/testkit/gherkin.mjs +245 -36
- package/dist/testkit.mjs +3 -2
- package/dist/web/main.js +234 -232
- package/dist/wire.mjs +2 -2
- package/dist/workflow-kit.mjs +3 -3
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +66 -60
- package/src/cli/Cli.test.ts +183 -182
- package/src/cli/Cli.ts +164 -186
- package/src/cli/History.ts +9 -4
- package/src/cli/Judges.ts +1 -1
- package/src/cli/Output.test.ts +35 -0
- package/src/cli/Output.ts +31 -1
- package/src/cli/Program.test.ts +65 -79
- package/src/cli/Program.ts +157 -118
- package/src/edge/Edge.test.ts +64 -56
- package/src/edge/Edge.ts +3 -3
- package/src/flows/factory.test.ts +14 -0
- package/src/flows/factory.ts +22 -5
- package/src/flows/helpers.test.ts +35 -0
- package/src/flows/helpers.ts +19 -10
- package/src/flows/index.test.ts +66 -0
- package/src/flows/index.ts +109 -5
- package/src/flows/internal.ts +7 -0
- package/src/flows/runtime.test.ts +192 -1
- package/src/flows/runtime.ts +188 -54
- package/src/gherkin/git-control.feature +6 -6
- package/src/gherkin/index.test.ts +127 -0
- package/src/gherkin/index.ts +370 -51
- package/src/gherkin/messages.test.ts +38 -4
- package/src/gherkin/messages.ts +21 -4
- package/src/gherkin/more-steps.feature +170 -0
- package/src/gherkin/scenario-end.feature +101 -0
- package/src/gherkin/testkit.feature +83 -7
- package/src/install/Install.test.ts +7 -7
- package/src/install/Install.ts +8 -8
- package/src/replay/Replay.test.ts +13 -2
- package/src/replay/Replay.ts +19 -30
- package/src/replay/judgeDiffShape.test.ts +1 -1
- package/src/steering/Footnotes.test.ts +1 -1
- package/src/steering/SteeringFormats.test.ts +1 -1
- package/src/steering/freeform.test.ts +1 -1
- package/src/steering/index.test.ts +27 -1
- package/src/steering/index.ts +6 -104
- package/src/steering/internal.ts +11 -0
- package/src/steering/qa.test.ts +7 -2
- package/src/steering/reads.test.ts +42 -0
- package/src/steering/reads.ts +43 -0
- package/src/steering/registry.ts +93 -0
- package/src/steering/review.test.ts +2 -2
- package/src/step/planStart.test.ts +18 -16
- package/src/step/planStart.ts +9 -9
- package/src/testkit/TestRepo.test.ts +245 -7
- package/src/testkit/TestRepo.ts +271 -40
- package/src/testkit/imports.test.ts +58 -0
- package/src/testkit/imports.ts +102 -0
- package/src/testkit/index.test.ts +28 -0
- package/src/testkit/index.ts +14 -2
- package/src/twins/EmittedScriptRecognizer.ts +2 -6
- package/src/twins/EmittedScripts.test.ts +4 -2
- package/src/twins/InMemRepo.ts +1 -0
- package/src/wire/Script.ts +11 -0
- package/src/wire/WireSchema.test.ts +3 -0
- package/src/wire/golden/1/script.json +4 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/SteeringFormats.test.ts +1 -1
- package/src/workflow/compile.ts +2 -2
- package/src/workflow/discovery.test.ts +3 -3
- package/src/workflow/load.test.ts +256 -116
- package/src/workflow/load.ts +169 -146
- package/src/workflow-kit/index.test.ts +25 -0
- package/src/workflow-kit/index.ts +37 -4
- package/src/workflow-kit/review.ts +2 -2
|
@@ -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
|
+
})
|
package/src/flows/runtime.ts
CHANGED
|
@@ -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
|
|
8
|
-
export
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
readonly
|
|
68
|
-
readonly
|
|
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
|
-
/**
|
|
79
|
-
|
|
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
|
|
252
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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",
|
|
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
|
"""
|