@pmelab/gtd 21.1.0 → 22.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 +4 -1
- package/dist/gtd.bundle.mjs +242 -40
- package/dist/workflows.mjs +19415 -47
- package/package.json +2 -1
- package/schema.json +2 -2
- package/src/flows/factory.test.ts +94 -0
- package/src/flows/factory.ts +116 -0
- package/src/flows/index.ts +1 -0
- package/src/workflows/bundled.ts +9 -4
- package/src/workflows/options.test.ts +52 -0
- package/src/workflows/options.ts +38 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "22.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
|
|
6
6
|
"bin": {
|
|
@@ -134,6 +134,7 @@
|
|
|
134
134
|
"dependencies": {
|
|
135
135
|
"@effect/platform": "^0.94.2",
|
|
136
136
|
"@effect/platform-node": "^0.104.1",
|
|
137
|
+
"@standard-schema/spec": "^1.1.0",
|
|
137
138
|
"@trpc/server": "^11.18.0",
|
|
138
139
|
"effect": "^3.19.15",
|
|
139
140
|
"jiti": "^2.7.0",
|
package/schema.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"properties": {
|
|
6
6
|
"vars": {
|
|
7
7
|
"type": "object",
|
|
8
|
-
"description": "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. Scalars are coerced to strings.",
|
|
8
|
+
"description": "Flat name -> scalar map merged into the workflow's process settings (defaults), pinned for the whole process at its start. A workflow built by a factory receives these entries as options and validates them itself. Scalars are coerced to strings.",
|
|
9
9
|
"additionalProperties": {
|
|
10
10
|
"type": [
|
|
11
11
|
"string",
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
},
|
|
17
17
|
"env": {
|
|
18
18
|
"type": "object",
|
|
19
|
-
"description": "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine
|
|
19
|
+
"description": "Flat name -> scalar map merged into the workflow's environment settings (envDefaults) — values that change how a step runs on this machine. A workflow built by a factory receives these entries as options and validates them itself. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
|
|
20
20
|
"additionalProperties": {
|
|
21
21
|
"type": [
|
|
22
22
|
"string",
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
+
import { describe, expect, it } from "vitest"
|
|
3
|
+
import { configure, defineWorkflow, isFactoryWorkflow } from "./factory.js"
|
|
4
|
+
|
|
5
|
+
// A hand-written Standard Schema: `{ greeting?: string }`, unknown keys rejected.
|
|
6
|
+
const greetingSchema: StandardSchemaV1<{ greeting?: string }> = {
|
|
7
|
+
"~standard": {
|
|
8
|
+
version: 1,
|
|
9
|
+
vendor: "test",
|
|
10
|
+
validate: (value) => {
|
|
11
|
+
const issues: StandardSchemaV1.Issue[] = []
|
|
12
|
+
for (const [key, v] of Object.entries(value as object)) {
|
|
13
|
+
if (key !== "greeting") issues.push({ message: "is unexpected", path: [key] })
|
|
14
|
+
else if (typeof v !== "string") issues.push({ message: "Expected string", path: [{ key }] })
|
|
15
|
+
}
|
|
16
|
+
return issues.length > 0 ? { issues } : { value: value as { greeting?: string } }
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const flow = async (): Promise<void> => {}
|
|
22
|
+
const greeter = defineWorkflow(greetingSchema, (options) => ({
|
|
23
|
+
flow,
|
|
24
|
+
defaults: { greeting: options.greeting ?? "hello" },
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
describe("defineWorkflow", () => {
|
|
28
|
+
it("marks the factory and a call's result as factory workflows, a plain flow not", () => {
|
|
29
|
+
expect(isFactoryWorkflow(greeter)).toBe(true)
|
|
30
|
+
expect(isFactoryWorkflow(greeter({ greeting: "hi" }))).toBe(true)
|
|
31
|
+
expect(isFactoryWorkflow(flow)).toBe(false)
|
|
32
|
+
expect(configure(flow)).toBeUndefined()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it("builds the factory itself with no options", () => {
|
|
36
|
+
expect(configure(greeter)).toEqual({
|
|
37
|
+
ok: true,
|
|
38
|
+
build: { flow, defaults: { greeting: "hello" } },
|
|
39
|
+
})
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("builds a call's result from the options it was called with", () => {
|
|
43
|
+
expect(configure(greeter({ greeting: "hi" }))).toEqual({
|
|
44
|
+
ok: true,
|
|
45
|
+
build: { flow, defaults: { greeting: "hi" } },
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it("lays overrides over the options a call was given", () => {
|
|
50
|
+
expect(configure(greeter({ greeting: "hi" }), { greeting: "hey" })).toEqual({
|
|
51
|
+
ok: true,
|
|
52
|
+
build: { flow, defaults: { greeting: "hey" } },
|
|
53
|
+
})
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it("reports every issue with its key path, path segments flattened", () => {
|
|
57
|
+
const bad = greeter({ greeting: 1 } as never)
|
|
58
|
+
expect(configure(bad, { greting: "x" })).toEqual({
|
|
59
|
+
ok: false,
|
|
60
|
+
issues: [
|
|
61
|
+
{ path: ["greeting"], message: "Expected string" },
|
|
62
|
+
{ path: ["greting"], message: "is unexpected" },
|
|
63
|
+
],
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("refuses a schema that validates asynchronously", () => {
|
|
68
|
+
const asyncSchema: StandardSchemaV1<object> = {
|
|
69
|
+
"~standard": {
|
|
70
|
+
version: 1,
|
|
71
|
+
vendor: "test",
|
|
72
|
+
validate: async (value) => ({ value: value as object }),
|
|
73
|
+
},
|
|
74
|
+
}
|
|
75
|
+
const result = configure(defineWorkflow(asyncSchema, () => ({ flow })))
|
|
76
|
+
expect(result).toMatchObject({ ok: false, issues: [{ path: [] }] })
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it("treats a schema failing with no issues as a rejection", () => {
|
|
80
|
+
const empty: StandardSchemaV1<object> = {
|
|
81
|
+
"~standard": { version: 1, vendor: "test", validate: () => ({ issues: [] }) },
|
|
82
|
+
}
|
|
83
|
+
expect(configure(defineWorkflow(empty, () => ({ flow })))).toMatchObject({
|
|
84
|
+
ok: false,
|
|
85
|
+
issues: [{ path: [] }],
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it("throws when a call's result is awaited as if it were a flow", async () => {
|
|
90
|
+
await expect(Promise.resolve(greeter() as unknown as PromiseLike<void>)).rejects.toThrow(
|
|
91
|
+
/not a flow/,
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import type { StandardSchemaV1 } from "@standard-schema/spec"
|
|
2
|
+
import type { Flow } from "./runtime.js"
|
|
3
|
+
|
|
4
|
+
// Registered, so a factory made by any copy of this file is recognised by the
|
|
5
|
+
// engine's copy — the same reason the replay context is.
|
|
6
|
+
const FACTORY = Symbol.for("@pmelab/gtd/workflow-factory")
|
|
7
|
+
|
|
8
|
+
export interface WorkflowBuild {
|
|
9
|
+
readonly flow: Flow
|
|
10
|
+
/** Process settings this workflow declares, over its file's `defaults` export. */
|
|
11
|
+
readonly defaults?: Readonly<Record<string, string>> | undefined
|
|
12
|
+
/** Environment settings this workflow declares, over its file's `envDefaults` export. */
|
|
13
|
+
readonly envDefaults?: Readonly<Record<string, string>> | undefined
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface FactoryInternals {
|
|
17
|
+
readonly schema: StandardSchemaV1
|
|
18
|
+
readonly build: (options: never) => WorkflowBuild
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* A factory call's result. Nothing is validated yet: gtd validates the options
|
|
23
|
+
* when it loads the config, so a bad one is reported like any other config
|
|
24
|
+
* finding instead of throwing out of `gtd.config.ts`.
|
|
25
|
+
*/
|
|
26
|
+
export interface ConfiguredWorkflow {
|
|
27
|
+
readonly [FACTORY]: FactoryInternals & { readonly options: unknown }
|
|
28
|
+
readonly then: () => never
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export type WorkflowFactory<Options> = ({} extends Options
|
|
32
|
+
? (options?: Options) => ConfiguredWorkflow
|
|
33
|
+
: (options: Options) => ConfiguredWorkflow) & { readonly [FACTORY]: FactoryInternals }
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Define a workflow configured by options. `schema` is any Standard Schema
|
|
37
|
+
* (https://standardschema.dev); it should reject unknown keys, so a removed or
|
|
38
|
+
* misspelled option fails at load. Exported from `gtd.config.ts`, the factory
|
|
39
|
+
* itself and a call's result are both workflows.
|
|
40
|
+
*/
|
|
41
|
+
export const defineWorkflow = <S extends StandardSchemaV1>(
|
|
42
|
+
schema: S,
|
|
43
|
+
build: (options: StandardSchemaV1.InferOutput<S>) => WorkflowBuild,
|
|
44
|
+
): WorkflowFactory<StandardSchemaV1.InferInput<S>> => {
|
|
45
|
+
const internals: FactoryInternals = { schema, build }
|
|
46
|
+
const factory = (options?: unknown): ConfiguredWorkflow => ({
|
|
47
|
+
[FACTORY]: { ...internals, options },
|
|
48
|
+
// A factory call used to be a flow; awaiting one in a composed flow must fail, not no-op.
|
|
49
|
+
// oxlint-disable-next-line unicorn/no-thenable -- awaiting a call must fail
|
|
50
|
+
then: () => {
|
|
51
|
+
throw new Error("a configured workflow is not a flow — compose fixFlow/reviewFlow instead")
|
|
52
|
+
},
|
|
53
|
+
})
|
|
54
|
+
return Object.assign(factory, { [FACTORY]: internals }) as never
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface OptionIssue {
|
|
58
|
+
readonly path: readonly (string | number)[]
|
|
59
|
+
readonly message: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type Configured =
|
|
63
|
+
| { readonly ok: true; readonly build: WorkflowBuild }
|
|
64
|
+
| { readonly ok: false; readonly issues: readonly OptionIssue[] }
|
|
65
|
+
|
|
66
|
+
const internalsOf = (
|
|
67
|
+
value: unknown,
|
|
68
|
+
): (FactoryInternals & { readonly options?: unknown }) | undefined =>
|
|
69
|
+
(typeof value === "function" || (typeof value === "object" && value !== null)) && FACTORY in value
|
|
70
|
+
? (value as { readonly [FACTORY]: FactoryInternals & { readonly options?: unknown } })[FACTORY]
|
|
71
|
+
: undefined
|
|
72
|
+
|
|
73
|
+
export const isFactoryWorkflow = (value: unknown): boolean => internalsOf(value) !== undefined
|
|
74
|
+
|
|
75
|
+
const segment = (key: PropertyKey | StandardSchemaV1.PathSegment): string | number => {
|
|
76
|
+
const raw = typeof key === "object" ? key.key : key
|
|
77
|
+
return typeof raw === "number" ? raw : String(raw)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Validate a factory workflow's options — those it was called with, `overrides`
|
|
82
|
+
* on top — and build it. `undefined` for an export that is no factory workflow.
|
|
83
|
+
*/
|
|
84
|
+
export const configure = (
|
|
85
|
+
value: unknown,
|
|
86
|
+
overrides: Readonly<Record<string, unknown>> = {},
|
|
87
|
+
): Configured | undefined => {
|
|
88
|
+
const internals = internalsOf(value)
|
|
89
|
+
if (internals === undefined) return undefined
|
|
90
|
+
const options = { ...(internals.options as object), ...overrides }
|
|
91
|
+
const result = internals.schema["~standard"].validate(options)
|
|
92
|
+
if (result instanceof Promise) {
|
|
93
|
+
return {
|
|
94
|
+
ok: false,
|
|
95
|
+
issues: [
|
|
96
|
+
{
|
|
97
|
+
path: [],
|
|
98
|
+
message: "the options schema validates asynchronously — gtd needs a synchronous one",
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
if (result.issues !== undefined) {
|
|
104
|
+
return {
|
|
105
|
+
ok: false,
|
|
106
|
+
issues:
|
|
107
|
+
result.issues.length === 0
|
|
108
|
+
? [{ path: [], message: "the options schema rejected them without saying why" }]
|
|
109
|
+
: result.issues.map((issue) => ({
|
|
110
|
+
path: (issue.path ?? []).map(segment),
|
|
111
|
+
message: issue.message,
|
|
112
|
+
})),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { ok: true, build: internals.build(result.value as never) }
|
|
116
|
+
}
|
package/src/flows/index.ts
CHANGED
package/src/workflows/bundled.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { architecture, design } from "./planning.js"
|
|
|
17
17
|
import type { FrozenScenarios } from "./scenarios.js"
|
|
18
18
|
import { buildTail, type ReviewOutcome } from "./review.js"
|
|
19
19
|
import { ARCHITECTURE, FEEDBACK, REQUIREMENTS, REVIEW } from "./steps.js"
|
|
20
|
+
import { shipped } from "./options.js"
|
|
20
21
|
import * as t from "./text.js"
|
|
21
22
|
|
|
22
23
|
// gtd's bundled workflows. Any change to the tree starts a process:
|
|
@@ -25,13 +26,15 @@ import * as t from "./text.js"
|
|
|
25
26
|
// the quality lap → human review, which signs off (the episode ends back at
|
|
26
27
|
// `idle`) or sends a full re-plan lap. `feature` is that ordinary start;
|
|
27
28
|
// `gtd --workflow fix` and `gtd --workflow review --var reviewBase=<commitish>`
|
|
28
|
-
// enter the same build tail further in.
|
|
29
|
+
// enter the same build tail further in. Each of the three is a factory over
|
|
30
|
+
// the settings in `./vars.ts`; its flow is exported too, for composing.
|
|
29
31
|
//
|
|
30
32
|
// Every part is exported for other workflows to compose; see the modules
|
|
31
33
|
// re-exported below.
|
|
32
34
|
|
|
33
35
|
export { threads, type ThreadInfo } from "../flows/index.js"
|
|
34
36
|
export { defaults, envDefaults } from "./vars.js"
|
|
37
|
+
export { ShippedOptions } from "./options.js"
|
|
35
38
|
export { skills } from "./skills.js"
|
|
36
39
|
export { access } from "./access.js"
|
|
37
40
|
export { agentWithSkills } from "./text.js"
|
|
@@ -107,18 +110,20 @@ export const ordinaryStart = async (): Promise<void> => {
|
|
|
107
110
|
}
|
|
108
111
|
|
|
109
112
|
/** Repair a red baseline through the build tail, as its own reviewed commit. */
|
|
110
|
-
export const
|
|
113
|
+
export const fixFlow = async (): Promise<void> => {
|
|
111
114
|
if (await baseline("fix-precheck")) return
|
|
112
115
|
return afterTail(await buildTail(true, start()))
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
/** Pure review of everything since `reviewBase`. */
|
|
116
|
-
export const
|
|
119
|
+
export const reviewFlow = async (): Promise<void> => {
|
|
117
120
|
await gate("review-gate", t.reviewGateBlockedMessage())
|
|
118
121
|
return afterTail(await buildTail(false, start()))
|
|
119
122
|
}
|
|
120
123
|
|
|
121
|
-
export const feature = ordinaryStart
|
|
124
|
+
export const feature = shipped(ordinaryStart)
|
|
125
|
+
export const fix = shipped(fixFlow)
|
|
126
|
+
export const review = shipped(reviewFlow)
|
|
122
127
|
|
|
123
128
|
export default feature
|
|
124
129
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { configure } from "../flows/index.js"
|
|
3
|
+
import * as workflows from "./index.js"
|
|
4
|
+
|
|
5
|
+
const { defaults, envDefaults, feature, fix, review, ShippedOptions } = workflows
|
|
6
|
+
|
|
7
|
+
describe("the shipped workflow factories", () => {
|
|
8
|
+
it("take exactly the settings the shipped workflows declare as options", () => {
|
|
9
|
+
expect(Object.keys(ShippedOptions.fields).sort()).toEqual(
|
|
10
|
+
[...Object.keys(defaults), ...Object.keys(envDefaults)].sort(),
|
|
11
|
+
)
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it("lay each option over the shipped setting of its own kind", () => {
|
|
15
|
+
const result = configure(feature({ qualityReviews: "correctness", testCommand: "npm run ci" }))
|
|
16
|
+
expect(result).toMatchObject({
|
|
17
|
+
ok: true,
|
|
18
|
+
build: {
|
|
19
|
+
defaults: { ...defaults, qualityReviews: "correctness" },
|
|
20
|
+
envDefaults: { ...envDefaults, testCommand: "npm run ci" },
|
|
21
|
+
},
|
|
22
|
+
})
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it("build the shipped settings unchanged with no options", () => {
|
|
26
|
+
for (const factory of [feature, fix, review]) {
|
|
27
|
+
expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
|
|
28
|
+
}
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it("reject a misspelled option, at the type level and at load, naming the known ones", () => {
|
|
32
|
+
// @ts-expect-error — `testComand` is no option
|
|
33
|
+
const configured = feature({ testComand: "npm test" })
|
|
34
|
+
const result = configure(configured)
|
|
35
|
+
expect(result).toMatchObject({ ok: false, issues: [{ path: ["testComand"] }] })
|
|
36
|
+
expect(result?.ok === false && result.issues[0]?.message).toMatch(
|
|
37
|
+
/^is unexpected, expected: .*"testCommand"/,
|
|
38
|
+
)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it("reject a removed option at the type level and at load", () => {
|
|
42
|
+
// @ts-expect-error — `model` was never (or is no longer) an option
|
|
43
|
+
const result = configure(review({ model: "smart" }))
|
|
44
|
+
expect(result).toMatchObject({ ok: false, issues: [{ path: ["model"] }] })
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it("reject a value of the wrong type at the type level and at load", () => {
|
|
48
|
+
// @ts-expect-error — settings are strings
|
|
49
|
+
const result = configure(fix({ judgeBudgetBytes: 65536 }))
|
|
50
|
+
expect(result).toMatchObject({ ok: false, issues: [{ path: ["judgeBudgetBytes"] }] })
|
|
51
|
+
})
|
|
52
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { Schema } from "effect"
|
|
2
|
+
import { defineWorkflow, type Flow, type WorkflowFactory } from "../flows/index.js"
|
|
3
|
+
import { defaults, envDefaults } from "./vars.js"
|
|
4
|
+
|
|
5
|
+
const setting = Schema.optionalWith(Schema.String, { exact: true })
|
|
6
|
+
|
|
7
|
+
export const ShippedOptions = Schema.Struct({
|
|
8
|
+
reviewBase: setting,
|
|
9
|
+
judgeBudgetBytes: setting,
|
|
10
|
+
judgeIdenticalMinP: setting,
|
|
11
|
+
reviewNoteActionable: setting,
|
|
12
|
+
qualityReviews: setting,
|
|
13
|
+
testCommand: setting,
|
|
14
|
+
fastTestCommand: setting,
|
|
15
|
+
plannerModel: setting,
|
|
16
|
+
coderModel: setting,
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
export type ShippedOptions = Schema.Schema.Encoded<typeof ShippedOptions>
|
|
20
|
+
|
|
21
|
+
const over = (
|
|
22
|
+
base: Readonly<Record<string, string>>,
|
|
23
|
+
options: Readonly<Record<string, string>>,
|
|
24
|
+
): Record<string, string> => ({
|
|
25
|
+
...base,
|
|
26
|
+
...Object.fromEntries(Object.entries(options).filter(([name]) => Object.hasOwn(base, name))),
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
/** A shipped flow as a factory: its options override the settings it declares, each in its own kind. */
|
|
30
|
+
export const shipped = (flow: Flow): WorkflowFactory<ShippedOptions> =>
|
|
31
|
+
defineWorkflow(
|
|
32
|
+
Schema.standardSchemaV1(ShippedOptions, { onExcessProperty: "error", errors: "all" }),
|
|
33
|
+
(options) => ({
|
|
34
|
+
flow,
|
|
35
|
+
defaults: over(defaults, options),
|
|
36
|
+
envDefaults: over(envDefaults, options),
|
|
37
|
+
}),
|
|
38
|
+
)
|