@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "21.1.0",
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": "jiti scripts/generate-schema.ts && node scripts/assert-no-test-doubles.mjs",
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 `.gtdrc` `modes:` name), `message` (human/judge), `model`,
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` or `.gtdrc` `vars:` declare — never an environment setting.
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
+ }
@@ -1,3 +1,5 @@
1
1
  export * from "./runtime.js"
2
2
  export * from "./helpers.js"
3
3
  export * from "./scripts.js"
4
+ export * from "./factory.js"
5
+ export * from "./config.js"
@@ -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 `.gtdrc` `access:` entry addresses. Only planning and review scopes
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.
@@ -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 fix = async (): Promise<void> => {
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 review = async (): Promise<void> => {
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
 
@@ -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 under `env:` in `.gtdrc` or as GTD_FASTTESTCOMMAND."
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
+ )
@@ -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 `.gtdrc` `skills:` entry addresses. 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