@pmelab/gtd 19.0.0 → 20.0.1

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": "19.0.0",
3
+ "version": "20.0.1",
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": {
@@ -8,7 +8,7 @@
8
8
  },
9
9
  "exports": {
10
10
  "./flows": "./src/flows/index.ts",
11
- "./workflow": "./src/workflows/unified.ts"
11
+ "./workflow": "./src/workflows/bundled.ts"
12
12
  },
13
13
  "files": [
14
14
  "dist/",
@@ -11,33 +11,38 @@ description: >-
11
11
  # Authoring a gtd workflow
12
12
 
13
13
  A gtd workflow is **plain async TypeScript**: a `gtd.config.ts` at the
14
- repository root default-exports the **flow**, one async function that awaits
15
- **steps** built from `@pmelab/gtd/flows`; optional `defaults` (process
16
- settings), `envDefaults` (environment settings), `summary`, `base` and
17
- `steering` (steering file → mode, for the LSP) exports sit beside it, and any
18
- other export is a helper gtd ignores. Every step is a commit; gtd finds where a
19
- process rests by **replaying** the flow over the episode's commits, so the git
20
- history IS the state and nothing is stored anywhere else.
14
+ repository root exports **workflows** — every exported async function is one,
15
+ named by its export, awaiting **steps** built from `@pmelab/gtd/flows`; the
16
+ `default` export is the ordinary start. Optional reserved exports (`defaults`
17
+ process settings, `envDefaults` environment settings, `summary`, `base`,
18
+ `steering`, `skills`, `doors`) are shared by the file's workflows. **Never
19
+ export a helper function you do not want startable** — it becomes a workflow.
20
+ Every step is a commit; gtd finds where a process rests by **replaying** the
21
+ flow over the episode's commits, so the git history IS the state and nothing is
22
+ stored anywhere else.
21
23
 
22
24
  Your job is to produce or edit that module so it loads cleanly and does what the
23
25
  user wants. Driving a workflow once it exists is a separate concern — that is
24
26
  what a driver does.
25
27
 
26
28
  **Trust:** gtd evaluates `gtd.config.ts` on every command that resolves workflow
27
- state (`gtd next` and `gtd lsp` included). It is code the user's repository
28
- runs; write it with the same care as a build script.
29
+ state (`gtd next`, `gtd lsp`, `gtd door` and `gtd doors` included). It is code
30
+ the user's repository runs; write it with the same care as a build script.
29
31
 
30
32
  ## Golden rule: start from the bundled default, edit incrementally
31
33
 
32
34
  Do **not** write a workflow from a blank page unless the user wants something
33
- tiny. gtd ships one known-good workflow and runs it when no `gtd.config.ts` is
34
- found, and publishes it as `@pmelab/gtd/workflow`: its default export is that
35
- flow, and every phase and single step it is built from is a named export. Start
36
- by importing what you keep and writing only what changes:
35
+ tiny. gtd ships three startable workflows (`feature`, `review`, `fix`) as named
36
+ exports of `@pmelab/gtd/workflow` and runs them when no `gtd.config.ts` is
37
+ found. Its default export is `feature` (the ordinary start), and every phase and
38
+ single step they are built from is a named export too. Start by importing what
39
+ you keep and writing only what changes. Import phases by name; **never
40
+ `export *` from it** — every exported function of your file would become a
41
+ startable workflow:
37
42
 
38
43
  ```ts
39
44
  import { start } from "@pmelab/gtd/flows"
40
- import bundled, { afterTail, buildTail } from "@pmelab/gtd/workflow"
45
+ import { afterTail, buildTail, feature } from "@pmelab/gtd/workflow"
41
46
 
42
47
  export {
43
48
  defaults,
@@ -45,14 +50,20 @@ export {
45
50
  summary,
46
51
  base,
47
52
  steering,
53
+ skills,
48
54
  } from "@pmelab/gtd/workflow"
49
55
 
50
- export default async ({ entry }) =>
51
- entry === "hotfix"
52
- ? afterTail(await buildTail(true, start()))
53
- : bundled({ entry })
56
+ export default feature
57
+
58
+ export async function hotfix() {
59
+ return afterTail(await buildTail(true, start()))
60
+ }
61
+
62
+ export const doors = { hotfix: { workflow: "hotfix" } }
54
63
  ```
55
64
 
65
+ `gtd --workflow hotfix` (or `gtd door hotfix`) starts it.
66
+
56
67
  To change a phase itself, read its source in the npm package
57
68
  (`node_modules/@pmelab/gtd/src/workflows/`, or under `$(npm root -g)` for a
58
69
  global install) and write your own version in `gtd.config.ts`, reusing its
@@ -64,8 +75,8 @@ it in place.
64
75
 
65
76
  Prefer the bundled workflow's own parts over re-implementing them — `healthy`,
66
77
  `escalation`, `gate`, `design`, `architecture`, `packages`, `qualityLap`,
67
- `review`, `buildTail`, and single steps like `triage` or `fix`. Their full step
68
- names are versioned API.
78
+ `buildTail`, and single steps like `triage` or `fixCheck`. Their full step names
79
+ are versioned API.
69
80
 
70
81
  Make one small change, **verify it loads** (see "Verify"), then make the next. A
71
82
  workflow that fails to load breaks every gtd command in the repository.
@@ -132,13 +143,14 @@ the pending landing — call it right after the step whose turn you reject.
132
143
  - An episode ends when the flow returns or calls `restart()`; the next starts at
133
144
  the flow's first step on an ordinary start — that step is where a finished
134
145
  process waits (the bundled one is `human("idle", …)`).
135
- - `gtd --entry <name>` starts a process with the flow's `{ entry }` argument set
136
- to `<name>` (`undefined` on an ordinary start). Branch on it, and `refuse()`
137
- names you don't accept; a flow that never reads `entry` accepts none. An
138
- `export const base = (entry, vars) => commitish | undefined` fixes an entered
139
- process's diff base. `--var <name>=<value>` only pins process settings: names
140
- the workflow's `defaults` or `.gtdrc` `vars:` declare — never an environment
141
- setting.
146
+ - `gtd --workflow <name>` starts a process on the exported workflow `<name>` (an
147
+ unknown name is a usage error); `gtd door <name> [args]` starts one through a
148
+ `doors` entry `{ workflow, args?, vars? }` (`gtd doors` lists them; bundled:
149
+ `fix`, `review [base]`; yours merge over them). An
150
+ `export const base = (workflow, vars) => commitish | undefined` fixes a
151
+ started process's diff base (blank = default-branch merge-base).
152
+ `--var <name>=<value>` only pins process settings: names the workflow's
153
+ `defaults` or `.gtdrc` `vars:` declare — never an environment setting.
142
154
 
143
155
  ## Landing rules you are designing for
144
156
 
@@ -259,7 +259,7 @@ export const changesSince = (hash: string, pattern?: string): Changes => {
259
259
  /** The commit the process stands on at this point of the flow. */
260
260
  export const head = (): string => ctx().head()
261
261
 
262
- /** The process's diff base: the commit before it began, or the base `gtd --entry` fixed. */
262
+ /** The process's diff base: the commit before it began, or the base `gtd --workflow` fixed. */
263
263
  export const start = (): string => ctx().start()
264
264
 
265
265
  /** The skill list `localName` (scoped from here, same as `agent()`) resolves to — for a prompt preamble. */
@@ -348,17 +348,8 @@ export const openQuestions = (text: string): readonly OpenQuestion[] => ctx().op
348
348
 
349
349
  // ── The workflow ────────────────────────────────────────────────────────────
350
350
 
351
- export interface FlowArgs {
352
- /** The name `gtd --entry <name>` started the process with; `undefined` for an ordinary start. */
353
- readonly entry: string | undefined
354
- }
355
-
356
- /**
357
- * A workflow's one flow. A flow that never reads `entry` accepts no
358
- * `--entry`; one that does decides for itself which names it honours,
359
- * `refuse()`-ing the rest.
360
- */
361
- export type Flow = (args: FlowArgs) => Promise<void>
351
+ /** A workflow's one flow. */
352
+ export type Flow = () => Promise<void>
362
353
 
363
354
  export interface SummaryContext {
364
355
  readonly entryCommit: string
@@ -375,11 +366,25 @@ export interface SummaryContext {
375
366
  export type Summary = (context: SummaryContext) => string
376
367
 
377
368
  /**
378
- * A workflow module's optional `base` export: the commitish that fixes the
379
- * diff base of a process `gtd --entry <entry>` starts, or `undefined` for
380
- * none. Runs when the process is entered, with the vars `--var` sets.
369
+ * A workflow file's optional `base` export: the commitish that fixes the diff
370
+ * base of a process `gtd --workflow <workflow>` starts, or `undefined` for
371
+ * none. Runs when the process is started, with the vars `--var` sets.
381
372
  */
382
- export type EntryBase = (
383
- entry: string,
373
+ export type WorkflowBase = (
374
+ workflow: string,
384
375
  vars: Readonly<Record<string, string>>,
385
376
  ) => string | undefined
377
+
378
+ /** A named shortcut: `gtd door <name> [args…]` starts `workflow` with the positional args mapped to process settings. */
379
+ export interface Door {
380
+ readonly workflow: string
381
+ /** Static, so `gtd doors --json` can list them. */
382
+ readonly args?: readonly { readonly name: string; readonly optional?: boolean }[]
383
+ /** A pure function of the args: no git runs at declaration time. */
384
+ readonly vars?: (
385
+ args: Readonly<Record<string, string | undefined>>,
386
+ ) => Readonly<Record<string, string>>
387
+ }
388
+
389
+ /** A workflow module's reserved `doors` export. */
390
+ export type Doors = Readonly<Record<string, Door>>
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { access } from "./access.js"
3
- import { unified } from "./index.js"
3
+ import { bundled as workflow } from "./index.js"
4
4
 
5
- const { defaults } = unified
5
+ const { defaults } = workflow
6
6
 
7
7
  const bundled = access(defaults)
8
8
 
@@ -34,7 +34,7 @@ describe("the bundled workflow's access export", () => {
34
34
  })
35
35
 
36
36
  it("keys only scopes that run a turn (every key is a skills key)", () => {
37
- const known = Object.keys(unified.skills(defaults))
37
+ const known = Object.keys(workflow.skills(defaults))
38
38
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
39
39
  })
40
40
  })
@@ -2,14 +2,13 @@ import {
2
2
  changes,
3
3
  head,
4
4
  human,
5
- refuse,
6
5
  requireRevert,
7
6
  restoreScript,
8
7
  revertScript,
9
8
  run,
10
9
  start,
11
- type EntryBase,
12
- type FlowArgs,
10
+ type Doors,
11
+ type WorkflowBase,
13
12
  type Summary,
14
13
  } from "../flows/index.js"
15
14
  import { baseline, gate } from "./health.js"
@@ -20,13 +19,13 @@ import { buildTail, type ReviewOutcome } from "./review.js"
20
19
  import { ARCHITECTURE, FEEDBACK, REQUIREMENTS, REVIEW } from "./steps.js"
21
20
  import * as t from "./text.js"
22
21
 
23
- // gtd's built-in default workflow. Any change to the tree starts a process:
22
+ // gtd's bundled workflows. Any change to the tree starts a process:
24
23
  // `idle` → `unwind` reverts the sketch (its intent survives in history) → a
25
24
  // green-baseline gate → design, architecture and one package per concern →
26
25
  // the quality lap → human review, which signs off (the episode ends back at
27
- // `idle`) or sends a full re-plan lap. `--entry fix-precheck`, `--entry
28
- // review-gate.check --var reviewBase=<commitish>` and `--entry
29
- // start-gate.check` enter the same flow further in.
26
+ // `idle`) or sends a full re-plan lap. `feature` is that ordinary start;
27
+ // `gtd --workflow fix` and `gtd --workflow review --var reviewBase=<commitish>`
28
+ // enter the same build tail further in.
30
29
  //
31
30
  // Every part is exported for other workflows to compose; see the modules
32
31
  // re-exported below.
@@ -107,30 +106,34 @@ export const ordinaryStart = async (): Promise<void> => {
107
106
  await planAndBuild(start())
108
107
  }
109
108
 
110
- const ENTRIES = ["fix-precheck", "review-gate.check", "start-gate.check"]
109
+ /** Repair a red baseline through the build tail, as its own reviewed commit. */
110
+ export const fix = async (): Promise<void> => {
111
+ if (await baseline("fix-precheck")) return
112
+ return afterTail(await buildTail(true, start()))
113
+ }
111
114
 
112
- export default async function unified({ entry }: FlowArgs): Promise<void> {
113
- if (entry === undefined) return ordinaryStart()
114
- if (entry === "fix-precheck") {
115
- if (await baseline("fix-precheck")) return
116
- return afterTail(await buildTail(true, start()))
117
- }
118
- if (entry === "review-gate.check") {
119
- await gate("review-gate", t.reviewGateBlockedMessage())
120
- return afterTail(await buildTail(false, start()))
121
- }
122
- if (entry === "start-gate.check") {
123
- await gate("start-gate", t.startGateBlockedMessage())
124
- return planAndBuild(start())
125
- }
126
- refuse(
127
- `"${entry}" is not an enterable state — enterable states:\n${ENTRIES.map((name) => ` ${name}`).join("\n")}`,
128
- )
115
+ /** Pure review of everything since `reviewBase`. */
116
+ export const review = async (): Promise<void> => {
117
+ await gate("review-gate", t.reviewGateBlockedMessage())
118
+ return afterTail(await buildTail(false, start()))
129
119
  }
130
120
 
121
+ export const feature = ordinaryStart
122
+
123
+ export default feature
124
+
131
125
  export const summary: Summary = t.summaryPrompt
132
126
 
133
127
  export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
134
128
 
135
- export const base: EntryBase = (entry, vars) =>
136
- entry === "review-gate.check" ? (vars.reviewBase ?? "") : undefined
129
+ export const base: WorkflowBase = (workflow, vars) =>
130
+ workflow === "review" ? (vars.reviewBase ?? "") : undefined
131
+
132
+ export const doors: Doors = {
133
+ fix: { workflow: "fix" },
134
+ review: {
135
+ workflow: "review",
136
+ args: [{ name: "base", optional: true }],
137
+ vars: ({ base }) => (base === undefined ? {} : { reviewBase: base }),
138
+ },
139
+ }
@@ -1 +1 @@
1
- export * as unified from "./unified.js"
1
+ export * as bundled from "./bundled.js"
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
2
  import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
3
  import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
4
- import { unified } from "./index.js"
4
+ import { bundled } from "./index.js"
5
5
  import { fixtureContext } from "./text.fixture.js"
6
6
 
7
7
  afterEach(() => installContext(undefined))
@@ -313,7 +313,7 @@ describe("review verdict routing", () => {
313
313
 
314
314
  describe("the default quality lenses", () => {
315
315
  it("are the six lenses in the settled order", () => {
316
- expect(unified.defaults.qualityReviews!.split(",").map((l) => l.trim())).toEqual([
316
+ expect(bundled.defaults.qualityReviews!.split(",").map((l) => l.trim())).toEqual([
317
317
  "correctness",
318
318
  "owasp-security",
319
319
  "ponytail-review",
@@ -324,7 +324,7 @@ describe("the default quality lenses", () => {
324
324
  })
325
325
 
326
326
  it("expose builtInLenses through the public workflow module", () => {
327
- expect(Object.keys(unified.builtInLenses).sort()).toEqual([
327
+ expect(Object.keys(bundled.builtInLenses).sort()).toEqual([
328
328
  "conventions",
329
329
  "correctness",
330
330
  "spec-challenge",
@@ -26,7 +26,7 @@ import {
26
26
  ARCHITECTURE,
27
27
  awaitReview,
28
28
  collecting,
29
- fix,
29
+ fixCheck,
30
30
  fixNits,
31
31
  fixQuality,
32
32
  fixRisks,
@@ -230,7 +230,7 @@ const routeNotes = async (notes: readonly ReviewNote[], r: Round): Promise<Finis
230
230
  }
231
231
  if (nits.length > 0) {
232
232
  await guarded(r.frozen, () => fixNits(nits), "review")()
233
- await healthy(guarded(r.frozen, fix, "review"), { escalations: r.escalations })
233
+ await healthy(guarded(r.frozen, fixCheck, "review"), { escalations: r.escalations })
234
234
  }
235
235
  if (edits.length > 0) {
236
236
  await r.close()
@@ -291,7 +291,7 @@ const reviewOnce = async (
291
291
  const risks = reviewRisks(read(REVIEW) ?? "")
292
292
  if (risks.length === 0) return
293
293
  await guarded(frozen, () => fixRisks(risks), "review")()
294
- await healthy(guarded(frozen, fix, "review"), { escalations })
294
+ await healthy(guarded(frozen, fixCheck, "review"), { escalations })
295
295
  await reviewing(base, carry)
296
296
  }
297
297
 
@@ -335,12 +335,12 @@ export const buildTail = (
335
335
  scope("build", async () => {
336
336
  const frozen = built?.frozen
337
337
  const escalations: EscalationCount = { rounds: 0 }
338
- const guardedFix = guarded(frozen, () => fix())
338
+ const guardedFix = guarded(frozen, () => fixCheck())
339
339
  let redFirst = fixFirst
340
340
  // fixFirst's own fix + healthy below already is the full run.
341
341
  if (!fixFirst) {
342
342
  await healthy(
343
- guarded(frozen, () => fix(built)),
343
+ guarded(frozen, () => fixCheck(built)),
344
344
  { escalations, sweepOnGreen: [ARCHITECTURE] },
345
345
  )
346
346
  }
@@ -1,8 +1,8 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { skills } from "./skills.js"
3
- import { unified } from "./index.js"
3
+ import { bundled as workflow } from "./index.js"
4
4
 
5
- const { defaults } = unified
5
+ const { defaults } = workflow
6
6
 
7
7
  const bundled = skills(defaults)
8
8
 
@@ -41,7 +41,7 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
41
41
  })
42
42
 
43
43
  it("fix carries build.fix's bundled skills", async () => {
44
- const prompt = agentPrompt(await capture(() => steps.fix(), "build"))
44
+ const prompt = agentPrompt(await capture(() => steps.fixCheck(), "build"))
45
45
  expect(prompt).toContain("debugging-and-error-recovery")
46
46
  })
47
47
 
@@ -110,7 +110,7 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
110
110
  })
111
111
 
112
112
  it("an empty configured list leaves that step's skills empty, and its rendered prompt bare", async () => {
113
- const request = await capture(() => steps.fix(), "build", { build: [] })
113
+ const request = await capture(() => steps.fixCheck(), "build", { build: [] })
114
114
  if (request.kind !== "agent") throw new Error("unreachable")
115
115
  expect(request.prompt).not.toContain("Load whatever's listed here")
116
116
  })
@@ -81,7 +81,7 @@ export const fixSuite = (): Promise<void> =>
81
81
 
82
82
  // ── Keeping the suite green ─────────────────────────────────────────────────
83
83
 
84
- export const fix = (built?: t.BuildContext): Promise<void> =>
84
+ export const fixCheck = (built?: t.BuildContext): Promise<void> =>
85
85
  t.agentWithSkills("fix", t.buildFixPrompt(built), {
86
86
  label: "Fixing the check",
87
87
  file: FEEDBACK,
@@ -87,7 +87,7 @@ export const startGateBlockedMessage = (): string =>
87
87
  \`.gtd/FEEDBACK.md\` holds the failing output.
88
88
 
89
89
  What each change does next (then run \`gtd land\`):
90
- - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**start-gate.check**). To repair the baseline as its own separate reviewed commit instead, abandon this start and run \`gtd --entry fix-precheck\` from a clean \`idle\`.
90
+ - **Retry check** — edit the code and/or \`.gtd/FEEDBACK.md\` to fix the failing tests (**start-gate.check**). To repair the baseline as its own separate reviewed commit instead, abandon this start and run \`gtd --workflow fix\` from a clean \`idle\`.
91
91
  `
92
92
 
93
93
  export const reviewGateBlockedMessage = (): string =>
@@ -1,34 +0,0 @@
1
- // The workflow's side doors: `fix` repairs a red baseline as its own reviewed
2
- // commit, `review` reviews everything since a base, straight to the review
3
- // tail. `gtd --entry` prints the script that starts the process; running it
4
- // is the driver's job, as with every git write gtd plans.
5
-
6
- import type { ShipIo, Shipped } from "./ship"
7
-
8
- const fail = (text: string): Shipped => ({ ok: false, text })
9
-
10
- export async function enter(io: ShipIo, door: "fix" | "review", base?: string): Promise<Shipped> {
11
- const args = door === "fix" ? ["--entry", "fix-precheck"] : await reviewArgs(io, base)
12
- if (typeof args === "string") return fail(args)
13
- const planned = await io.run(["gtd", ...args])
14
- if (planned.code !== 0)
15
- return fail(planned.err.trim() || `gtd ${args.join(" ")} exited ${planned.code}`)
16
- const started = await io.run(["sh", "-c", planned.out])
17
- if (started.code !== 0) return fail(`The entry script failed.\n${started.err.trim()}`)
18
- return {
19
- ok: true,
20
- text: door === "fix" ? "Started a fix." : `Started a review since ${args.at(-1)?.slice(-7)}.`,
21
- }
22
- }
23
-
24
- // Reviews the branch since it left `base`, the default branch unless named.
25
- async function reviewArgs(io: ShipIo, base: string | undefined) {
26
- const git = async (...a: string[]) => (await io.run(["git", ...a])).out.trim()
27
- const from =
28
- base || (await git("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")) || "main"
29
- const mergeBase = await git("merge-base", from, "HEAD")
30
- if (!mergeBase) return `There is no common ancestor of ${from} and HEAD to review from.`
31
- if (mergeBase === (await git("rev-parse", "HEAD")))
32
- return `Nothing to review: HEAD has no commits beyond ${from}.`
33
- return ["--entry", "review-gate.check", "--var", `reviewBase=${mergeBase}`]
34
- }