@pmelab/gtd 21.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "21.0.0",
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, like the test command or a model hint. Read fresh on every gtd call, never pinned to a process. GTD_<NAME> environment variables override these entries. Scalars are coerced to strings.",
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",
@@ -36,15 +36,15 @@
36
36
  "jev",
37
37
  "llm"
38
38
  ],
39
- "description": "Judge provider `gtd judge run` uses when no --provider flag and no GTD_JUDGE_PROVIDER is set. Absent means auto: jev when TYPESAFE_API_KEY is set, otherwise llm."
39
+ "description": "Judge provider `gtd judge run` uses when no --provider flag, no GTD_JUDGE_PROVIDER and no user-level judge.json says. Absent means auto: jev when TYPESAFE_API_KEY is set, otherwise llm."
40
40
  },
41
41
  "model": {
42
42
  "type": "string",
43
- "description": "Model for provider llm, used when no --model flag and no GTD_JUDGE_MODEL is set. Pairing it with another provider is an error."
43
+ "description": "Model for provider llm, used when no --model flag, no GTD_JUDGE_MODEL and no user-level judge.json says. Pairing it with another provider is an error."
44
44
  }
45
45
  },
46
46
  "additionalProperties": false,
47
- "description": "Which judge `gtd judge run` uses, when no flag or GTD_JUDGE_* variable says."
47
+ "description": "Which judge `gtd judge run` uses, when no flag, GTD_JUDGE_* variable or user-level judge.json says."
48
48
  },
49
49
  "modes": {
50
50
  "type": "object",
@@ -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,4 @@
1
1
  export * from "./runtime.js"
2
2
  export * from "./helpers.js"
3
3
  export * from "./scripts.js"
4
+ export * from "./factory.js"
@@ -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
 
@@ -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
+ )