@pmelab/gtd 21.1.0 → 23.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 +24 -11
- package/claude/hooks/persona.ts +1 -1
- package/dist/gtd.bundle.mjs +709 -7775
- package/dist/workflows.mjs +19417 -49
- package/package.json +5 -5
- package/skills/authoring/SKILL.md +2 -2
- package/src/flows/config.test.ts +66 -0
- package/src/flows/config.ts +141 -0
- package/src/flows/factory.test.ts +94 -0
- package/src/flows/factory.ts +116 -0
- package/src/flows/index.ts +2 -0
- package/src/workflows/access.ts +1 -1
- package/src/workflows/bundled.ts +9 -4
- package/src/workflows/health.ts +1 -1
- package/src/workflows/options.test.ts +52 -0
- package/src/workflows/options.ts +38 -0
- package/src/workflows/skills.ts +1 -1
- package/src/workflows/text.fixture.ts +3 -3
- package/src/workflows/text.ts +1 -1
- package/src/workflows/vars.ts +1 -1
- package/schema.json +0 -136
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pmelab/gtd",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "23.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": {
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"src/workflows/",
|
|
26
26
|
"README.md",
|
|
27
27
|
"LICENSE",
|
|
28
|
-
"schema.json",
|
|
29
28
|
".claude-plugin/plugin.json",
|
|
30
29
|
"hooks/",
|
|
31
30
|
"bin/",
|
|
@@ -52,7 +51,7 @@
|
|
|
52
51
|
"prepare": "husky",
|
|
53
52
|
"dev": "node dev/run.mjs",
|
|
54
53
|
"build": "tsdown --filter web && npx @tailwindcss/cli -i src/web/styles.css -o dist/web/main.css --minify && node scripts/inline-web-client.mjs && tsdown --filter gtd && tsdown --filter workflows",
|
|
55
|
-
"postbuild": "
|
|
54
|
+
"postbuild": "node scripts/assert-no-test-doubles.mjs",
|
|
56
55
|
"watch": "tsdown --watch",
|
|
57
56
|
"bench:replay": "node scripts/bench-replay.mjs",
|
|
58
57
|
"test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:e2e:inmem test:e2e:live test:web analyze",
|
|
@@ -126,6 +125,7 @@
|
|
|
126
125
|
"tsdown": "^0.22.8",
|
|
127
126
|
"turbo": "^2.10.9",
|
|
128
127
|
"vitest": "^3.2.0",
|
|
128
|
+
"yaml": "^2.8.2",
|
|
129
129
|
"zustand": "^5.0.15"
|
|
130
130
|
},
|
|
131
131
|
"peerDependencies": {
|
|
@@ -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",
|
|
@@ -147,7 +148,6 @@
|
|
|
147
148
|
"micromark-extension-gfm-strikethrough": "^2.1.0",
|
|
148
149
|
"micromark-extension-gfm-task-list-item": "^2.1.0",
|
|
149
150
|
"vscode-languageserver": "^10.1.0",
|
|
150
|
-
"vscode-languageserver-textdocument": "^1.0.13"
|
|
151
|
-
"yaml": "^2.8.2"
|
|
151
|
+
"vscode-languageserver-textdocument": "^1.0.13"
|
|
152
152
|
}
|
|
153
153
|
}
|
|
@@ -109,7 +109,7 @@ workflow that fails to load breaks every gtd command in the repository.
|
|
|
109
109
|
step you add must fit one of them; there is no sixth.
|
|
110
110
|
|
|
111
111
|
Options (all optional): `label`, `file` (a `.gtd/` path), `mode` (needs `file`;
|
|
112
|
-
`qa`, `review`, or a
|
|
112
|
+
`qa`, `review`, or a `config.modes` name), `message` (human/judge), `model`,
|
|
113
113
|
`system` (agent), `allowEmpty` (agent), `acceptClean` (human), `base` (the
|
|
114
114
|
commit the step reviews since — what `gtd base` prints).
|
|
115
115
|
|
|
@@ -150,7 +150,7 @@ the pending landing — call it right after the step whose turn you reject.
|
|
|
150
150
|
`export const base = (workflow, vars) => commitish | undefined` fixes a
|
|
151
151
|
started process's diff base (blank = default-branch merge-base).
|
|
152
152
|
`--var <name>=<value>` only pins process settings: names the workflow's
|
|
153
|
-
`defaults`
|
|
153
|
+
`defaults` declare — never an environment setting.
|
|
154
154
|
|
|
155
155
|
## Landing rules you are designing for
|
|
156
156
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import { configProblems, defineConfig } from "./config.js"
|
|
3
|
+
|
|
4
|
+
describe("defineConfig", () => {
|
|
5
|
+
it("returns a valid config as given", () => {
|
|
6
|
+
const config = {
|
|
7
|
+
modes: { qa: { format: "fmt" }, adr: { validate: "check" } },
|
|
8
|
+
ui: { port: 8443, format: "fmt" },
|
|
9
|
+
skills: { "feature.plan": ["tdd"] },
|
|
10
|
+
access: { "feature.plan": { write: [".gtd/**"] } },
|
|
11
|
+
}
|
|
12
|
+
expect(defineConfig(config)).toBe(config)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it("rejects a removed key at type level and at runtime", () => {
|
|
16
|
+
// @ts-expect-error -- `vars` is no `gtd.config.ts` setting
|
|
17
|
+
expect(() => defineConfig({ vars: { testCommand: "npm test" } })).toThrow(
|
|
18
|
+
"config.vars: is not a known key",
|
|
19
|
+
)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("rejects an unknown nested key at type level and at runtime", () => {
|
|
23
|
+
// @ts-expect-error -- a mode has only format and validate
|
|
24
|
+
expect(() => defineConfig({ modes: { qa: { formatter: "fmt" } } })).toThrow(
|
|
25
|
+
"config.modes.qa.formatter: is not a known key",
|
|
26
|
+
)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
describe("configProblems", () => {
|
|
31
|
+
it("finds every malformed value", () => {
|
|
32
|
+
expect(
|
|
33
|
+
configProblems({
|
|
34
|
+
modes: { empty: {}, scalar: "fmt", blank: { format: " " } },
|
|
35
|
+
ui: { port: "80", host: 1 },
|
|
36
|
+
skills: { a: "tdd" },
|
|
37
|
+
access: { a: { read: "*", other: [] } },
|
|
38
|
+
}),
|
|
39
|
+
).toEqual([
|
|
40
|
+
{ path: ["modes", "scalar"], message: 'must be an object with "format" and/or "validate"' },
|
|
41
|
+
{ path: ["modes", "blank", "format"], message: "must be a non-empty shell command" },
|
|
42
|
+
{ path: ["ui", "port"], message: "must be an integer" },
|
|
43
|
+
{ path: ["ui", "host"], message: "must be a string" },
|
|
44
|
+
{ path: ["skills", "a"], message: "must be an array of skill names" },
|
|
45
|
+
{ path: ["access", "a", "other"], message: "is not a known key" },
|
|
46
|
+
{ path: ["access", "a", "read"], message: "must be an array of glob strings" },
|
|
47
|
+
])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("rejects a container of the wrong shape", () => {
|
|
51
|
+
expect(configProblems({ modes: { "": {} }, skills: [], ui: "x", access: { a: "x" } })).toEqual([
|
|
52
|
+
{ path: ["modes", ""], message: "is a mode without a name" },
|
|
53
|
+
{ path: ["ui"], message: "must be an object" },
|
|
54
|
+
{ path: ["skills"], message: "must be an object of skill-name arrays" },
|
|
55
|
+
{ path: ["access", "a"], message: "must be an object of { read?, write? } glob arrays" },
|
|
56
|
+
])
|
|
57
|
+
expect(configProblems({ modes: "x", access: [] })).toEqual([
|
|
58
|
+
{ path: ["modes"], message: "must be an object of modes" },
|
|
59
|
+
{ path: ["access"], message: "must be an object of scope access" },
|
|
60
|
+
])
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it("rejects a config that is no object", () => {
|
|
64
|
+
expect(configProblems([])).toEqual([{ path: [], message: "must be an object" }])
|
|
65
|
+
})
|
|
66
|
+
})
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import type { ScopeAccess } from "./runtime.js"
|
|
2
|
+
|
|
3
|
+
/** One steering-file mode's shell commands; each reads the file from `$GTD_FILE`. */
|
|
4
|
+
export interface ModeCommands {
|
|
5
|
+
readonly format?: string
|
|
6
|
+
readonly validate?: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** `gtd ui` settings; its `--host`/`--port` flags outrank `host`/`port`. */
|
|
10
|
+
export interface UiSettings {
|
|
11
|
+
readonly port?: number
|
|
12
|
+
readonly host?: string
|
|
13
|
+
readonly cert?: string
|
|
14
|
+
readonly key?: string
|
|
15
|
+
readonly format?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The repository settings a `gtd.config.ts` exports as `config`. */
|
|
19
|
+
export interface GtdConfig {
|
|
20
|
+
readonly modes?: Readonly<Record<string, ModeCommands>>
|
|
21
|
+
readonly ui?: UiSettings
|
|
22
|
+
readonly skills?: Readonly<Record<string, readonly string[]>>
|
|
23
|
+
readonly access?: Readonly<Record<string, ScopeAccess>>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface ConfigProblem {
|
|
27
|
+
readonly path: readonly string[]
|
|
28
|
+
readonly message: string
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const CONFIG_KEYS = ["modes", "ui", "skills", "access"] as const
|
|
32
|
+
const MODE_KEYS = ["format", "validate"] as const
|
|
33
|
+
const UI_KEYS = ["port", "host", "cert", "key", "format"] as const
|
|
34
|
+
|
|
35
|
+
const isRecord = (v: unknown): v is Record<string, unknown> =>
|
|
36
|
+
typeof v === "object" && v !== null && !Array.isArray(v)
|
|
37
|
+
|
|
38
|
+
const isStringArray = (v: unknown): boolean =>
|
|
39
|
+
Array.isArray(v) && v.every((entry) => typeof entry === "string")
|
|
40
|
+
|
|
41
|
+
const unknownKeys = (
|
|
42
|
+
value: Record<string, unknown>,
|
|
43
|
+
known: readonly string[],
|
|
44
|
+
path: readonly string[],
|
|
45
|
+
): ConfigProblem[] =>
|
|
46
|
+
Object.keys(value)
|
|
47
|
+
.filter((key) => !known.includes(key))
|
|
48
|
+
.map((key) => ({ path: [...path, key], message: "is not a known key" }))
|
|
49
|
+
|
|
50
|
+
const recordOf = (
|
|
51
|
+
value: unknown,
|
|
52
|
+
path: readonly string[],
|
|
53
|
+
shape: string,
|
|
54
|
+
entry: (value: unknown, path: readonly string[]) => ConfigProblem[],
|
|
55
|
+
): ConfigProblem[] =>
|
|
56
|
+
isRecord(value)
|
|
57
|
+
? Object.entries(value).flatMap(([key, v]) => entry(v, [...path, key]))
|
|
58
|
+
: [{ path, message: `must be an object of ${shape}` }]
|
|
59
|
+
|
|
60
|
+
const modeProblems = (value: unknown, path: readonly string[]): ConfigProblem[] => {
|
|
61
|
+
if (path.at(-1) === "") return [{ path, message: "is a mode without a name" }]
|
|
62
|
+
// An empty mode still registers its name: a step may use it, unformatted and unvalidated.
|
|
63
|
+
if (!isRecord(value))
|
|
64
|
+
return [{ path, message: 'must be an object with "format" and/or "validate"' }]
|
|
65
|
+
return [
|
|
66
|
+
...unknownKeys(value, MODE_KEYS, path),
|
|
67
|
+
...MODE_KEYS.flatMap((key) => {
|
|
68
|
+
const command = value[key]
|
|
69
|
+
return command === undefined || (typeof command === "string" && command.trim() !== "")
|
|
70
|
+
? []
|
|
71
|
+
: [{ path: [...path, key], message: "must be a non-empty shell command" }]
|
|
72
|
+
}),
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const uiProblems = (value: unknown, path: readonly string[]): ConfigProblem[] => {
|
|
77
|
+
if (!isRecord(value)) return [{ path, message: "must be an object" }]
|
|
78
|
+
return [
|
|
79
|
+
...unknownKeys(value, UI_KEYS, path),
|
|
80
|
+
...UI_KEYS.flatMap((key) => {
|
|
81
|
+
const v = value[key]
|
|
82
|
+
if (v === undefined) return []
|
|
83
|
+
const ok = key === "port" ? Number.isInteger(v) : typeof v === "string"
|
|
84
|
+
return ok
|
|
85
|
+
? []
|
|
86
|
+
: [
|
|
87
|
+
{
|
|
88
|
+
path: [...path, key],
|
|
89
|
+
message: key === "port" ? "must be an integer" : "must be a string",
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
}),
|
|
93
|
+
]
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const accessProblems = (value: unknown, path: readonly string[]): ConfigProblem[] => {
|
|
97
|
+
if (!isRecord(value))
|
|
98
|
+
return [{ path, message: "must be an object of { read?, write? } glob arrays" }]
|
|
99
|
+
return [
|
|
100
|
+
...unknownKeys(value, ["read", "write"], path),
|
|
101
|
+
...(["read", "write"] as const).flatMap((side) =>
|
|
102
|
+
value[side] === undefined || isStringArray(value[side])
|
|
103
|
+
? []
|
|
104
|
+
: [{ path: [...path, side], message: "must be an array of glob strings" }],
|
|
105
|
+
),
|
|
106
|
+
]
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Every way `value` is not a `GtdConfig`; an unknown key is one, so a removed key fails loudly. */
|
|
110
|
+
export const configProblems = (value: unknown): readonly ConfigProblem[] => {
|
|
111
|
+
if (!isRecord(value)) return [{ path: [], message: "must be an object" }]
|
|
112
|
+
return [
|
|
113
|
+
...unknownKeys(value, CONFIG_KEYS, []),
|
|
114
|
+
...(value.modes === undefined ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems)),
|
|
115
|
+
...(value.ui === undefined ? [] : uiProblems(value.ui, ["ui"])),
|
|
116
|
+
...(value.skills === undefined
|
|
117
|
+
? []
|
|
118
|
+
: recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) =>
|
|
119
|
+
isStringArray(v) ? [] : [{ path, message: "must be an array of skill names" }],
|
|
120
|
+
)),
|
|
121
|
+
...(value.access === undefined
|
|
122
|
+
? []
|
|
123
|
+
: recordOf(value.access, ["access"], "scope access", accessProblems)),
|
|
124
|
+
]
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// The loader's diagnostic format, so a problem reads the same whether
|
|
128
|
+
// `defineConfig` throws it or the loader finds it in a plain object.
|
|
129
|
+
const formatConfigProblem = ({ path, message }: ConfigProblem): string =>
|
|
130
|
+
`${["config", ...path].join(".")}: ${message}`
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Type and check the `config` export. Not generic on purpose: a literal argument
|
|
134
|
+
* then gets TypeScript's excess-property check at every depth, so an unknown key
|
|
135
|
+
* is a type error — and it throws at runtime too.
|
|
136
|
+
*/
|
|
137
|
+
export const defineConfig = (config: GtdConfig): GtdConfig => {
|
|
138
|
+
const problems = configProblems(config)
|
|
139
|
+
if (problems.length > 0) throw new Error(problems.map(formatConfigProblem).join("; "))
|
|
140
|
+
return config
|
|
141
|
+
}
|
|
@@ -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/access.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { ScopeAccess } from "../flows/index.js"
|
|
|
2
2
|
import { lensesOf } from "./review.js"
|
|
3
3
|
|
|
4
4
|
// The bundled workflow's default file access, keyed by scope full name — the
|
|
5
|
-
// name a
|
|
5
|
+
// name a `config.access` entry addresses. Only planning and review scopes
|
|
6
6
|
// are restricted, and only on writes; no scope restricts reads. A step's own
|
|
7
7
|
// steering file is folded in per step, so it is not listed here. Build and fix
|
|
8
8
|
// scopes carry no entry: no project layout is assumed.
|
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
|
|
package/src/workflows/health.ts
CHANGED
|
@@ -19,7 +19,7 @@ import * as t from "./text.js"
|
|
|
19
19
|
export const SETUP = ".gtd/SETUP.md"
|
|
20
20
|
|
|
21
21
|
const SETUP_TEXT =
|
|
22
|
-
"The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it
|
|
22
|
+
"The bundled workflow needs the `fastTestCommand` setting: the fast suite (everything but e2e).\nSet it as a workflow option in `gtd.config.ts` or as GTD_FASTTESTCOMMAND."
|
|
23
23
|
|
|
24
24
|
/** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
|
|
25
25
|
export const fastSuiteGuard = (): string[] =>
|
|
@@ -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
|
+
)
|
package/src/workflows/skills.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { builtInLenses } from "./steps.js"
|
|
|
2
2
|
import { lensesOf } from "./review.js"
|
|
3
3
|
|
|
4
4
|
// The bundled workflow's default skills, one list per SCOPE, keyed by the
|
|
5
|
-
// scope's full name — the same name a
|
|
5
|
+
// scope's full name — the same name a `config.skills` entry addresses. A
|
|
6
6
|
// nested scope with no entry inherits its parent's; group scopes
|
|
7
7
|
// (`packages.item.fix`, `build.fix`, `build.review.fix`, `build.quality`) run
|
|
8
8
|
// no turn and carry no key. `build.quality.<lens>` is one key per
|