@pmelab/gtd 18.1.0 → 20.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": "18.1.0",
3
+ "version": "20.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": {
@@ -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/",
@@ -47,6 +47,7 @@
47
47
  "watch": "tsdown --watch",
48
48
  "bench:replay": "node scripts/bench-replay.mjs",
49
49
  "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:e2e:inmem test:e2e:live test:web analyze",
50
+ "test:fast": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:web analyze",
50
51
  "test:unit": "vitest run --project unit",
51
52
  "test:watch": "vitest",
52
53
  "test:changed": "vitest run --project unit --project e2e-inmem --changed HEAD",
@@ -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
@@ -63,9 +74,9 @@ current directory is the whole workflow. If one already exists, read it and edit
63
74
  it in place.
64
75
 
65
76
  Prefer the bundled workflow's own parts over re-implementing them — `healthy`,
66
- `escalation`, `gate`, `design`, `architecturePass`, `packages`, `specReview`,
67
- `qualityLap`, `review`, `buildTail`, and single steps like `triage` or `fix`.
68
- Their full step names are versioned API.
77
+ `escalation`, `gate`, `design`, `architecture`, `packages`, `qualityLap`,
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
 
@@ -212,7 +224,7 @@ sign-off between the two:
212
224
  const planAndBuild = async (): Promise<void> => {
213
225
  for (;;) {
214
226
  await design()
215
- await architecturePass()
227
+ await architecture()
216
228
  await human("approve-plan", {
217
229
  message:
218
230
  "The packages under .gtd/packages/ are ready. Edit them to adjust the plan, or change nothing — then run `gtd land` to start building.",
@@ -229,9 +241,8 @@ const planAndBuild = async (): Promise<void> => {
229
241
  `acceptClean: true` is what makes an untouched landing approve; without it the
230
242
  gate would wait for an edit. `approve-plan` sits in the `root` scope and is a
231
243
  new, unique name. Verify: `gtd next` loads without errors, and in a scratch
232
- repository a landing at `architecture.decompose` (or `architecture-promote`) now
233
- leads to `approve-plan`, and an untouched landing there to
234
- `packages.item.building`.
244
+ repository a landing at `architecture.decompose` now leads to `approve-plan`,
245
+ and an untouched landing there to `packages.item.building`.
235
246
 
236
247
  ## No migration
237
248
 
@@ -36,6 +36,8 @@ export interface CheckOptions {
36
36
  readonly sweep?: readonly string[] | undefined
37
37
  /** Paths removed once it passes. */
38
38
  readonly sweepOnGreen?: readonly string[] | undefined
39
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
40
+ readonly preamble?: readonly string[] | undefined
39
41
  }
40
42
 
41
43
  /**
@@ -48,10 +50,10 @@ export const check = async (
48
50
  command: string,
49
51
  options: CheckOptions,
50
52
  ): Promise<boolean> => {
51
- const { report, label, sweep, sweepOnGreen } = options
53
+ const { report, label, sweep, sweepOnGreen, preamble } = options
52
54
  await run(
53
55
  name,
54
- checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen }),
56
+ checkScript(command, { report, stamp: head().slice(0, 7), sweep, sweepOnGreen, preamble }),
55
57
  { label },
56
58
  )
57
59
  return !wrote(report)
@@ -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,5 +1,9 @@
1
+ import { spawnSync } from "node:child_process"
2
+ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
1
5
  import { describe, expect, it } from "vitest"
2
- import { restoreScript } from "./scripts.js"
6
+ import { checkScript, restoreScript } from "./scripts.js"
3
7
 
4
8
  describe("restoreScript", () => {
5
9
  const paths = { restore: ["a.ts"], remove: [] }
@@ -14,3 +18,26 @@ describe("restoreScript", () => {
14
18
  expect(script).not.toContain("abc~1")
15
19
  })
16
20
  })
21
+
22
+ describe("checkScript preamble", () => {
23
+ const options = { report: ".gtd/FEEDBACK.md", stamp: "abc" }
24
+ const runIn = (script: string, dir: string): void => {
25
+ writeFileSync(join(dir, "s.sh"), script)
26
+ spawnSync("sh", ["s.sh"], { cwd: dir })
27
+ }
28
+
29
+ it("renders before the command subshell", () => {
30
+ const script = checkScript("npm test", { ...options, preamble: ["echo pre"] })
31
+ expect(script.indexOf("echo pre")).toBeGreaterThan(-1)
32
+ expect(script.indexOf("echo pre")).toBeLessThan(script.indexOf("npm test"))
33
+ })
34
+
35
+ it("an exit 0 in the preamble skips the command and leaves report alone", () => {
36
+ const dir = mkdtempSync(join(tmpdir(), "gtd-pre-"))
37
+ mkdirSync(join(dir, ".gtd"))
38
+ writeFileSync(join(dir, ".gtd/FEEDBACK.md"), "old")
39
+ runIn(checkScript("touch ran", { ...options, preamble: ["exit 0"] }), dir)
40
+ expect(existsSync(join(dir, "ran"))).toBe(false)
41
+ expect(readFileSync(join(dir, ".gtd/FEEDBACK.md"), "utf8")).toBe("old")
42
+ })
43
+ })
@@ -14,6 +14,8 @@ export interface CheckScriptOptions {
14
14
  readonly sweep?: readonly string[] | undefined
15
15
  /** Paths removed once it passes. */
16
16
  readonly sweepOnGreen?: readonly string[] | undefined
17
+ /** Shell lines run before the command, outside its output capture; an `exit 0` skips it and leaves `report` untouched. */
18
+ readonly preamble?: readonly string[] | undefined
17
19
  }
18
20
 
19
21
  const removal = (paths: readonly string[]): string[] =>
@@ -29,6 +31,7 @@ export const checkScript = (command: string, options: CheckScriptOptions): strin
29
31
  return [
30
32
  "#!/usr/bin/env sh",
31
33
  "set +e",
34
+ ...(options.preamble ?? []),
32
35
  ...removal(options.sweep ?? []),
33
36
  `mkdir -p "$(dirname ${report})"`,
34
37
  // A subshell, so an `exit` inside the command ends only the command.
@@ -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
 
@@ -12,7 +12,6 @@ describe("the bundled workflow's access export", () => {
12
12
  design: { write: [] },
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
- "packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
16
15
  "build.review": { write: [".gtd/REVIEW.md"] },
17
16
  "build.review.fix.nits": {},
18
17
  "build.review.fix.risks": {},
@@ -35,7 +34,7 @@ describe("the bundled workflow's access export", () => {
35
34
  })
36
35
 
37
36
  it("keys only scopes that run a turn (every key is a skills key)", () => {
38
- const known = Object.keys(unified.skills(defaults))
37
+ const known = Object.keys(workflow.skills(defaults))
39
38
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
40
39
  })
41
40
  })
@@ -12,7 +12,6 @@ export const access = (
12
12
  design: { write: [] },
13
13
  architecture: { write: [".gtd/REQUIREMENTS.md"] },
14
14
  "architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
15
- "packages.item.spec": { write: [".gtd/SPEC_FEEDBACK.md"] },
16
15
  "build.review": { write: [".gtd/REVIEW.md"] },
17
16
  // `{}` reopens what `build.review` restricted: the fixes edit code.
18
17
  "build.review.fix.nits": {},
@@ -2,30 +2,30 @@ 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"
16
15
  import { packages } from "./packages.js"
17
- import { architecturePass, design } from "./planning.js"
16
+ import { architecture, design } from "./planning.js"
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
20
  import * as t from "./text.js"
21
21
 
22
- // 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:
23
23
  // `idle` → `unwind` reverts the sketch (its intent survives in history) → a
24
24
  // green-baseline gate → design, architecture and one package per concern →
25
25
  // the quality lap → human review, which signs off (the episode ends back at
26
- // `idle`) or sends a full re-plan lap. `--entry fix-precheck`, `--entry
27
- // review-gate.check --var reviewBase=<commitish>` and `--entry
28
- // 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.
29
29
  //
30
30
  // Every part is exported for other workflows to compose; see the modules
31
31
  // re-exported below.
@@ -39,6 +39,7 @@ export * from "./steps.js"
39
39
  export * from "./health.js"
40
40
  export * from "./planning.js"
41
41
  export * from "./packages.js"
42
+ export * from "./scenarios.js"
42
43
  export * from "./review.js"
43
44
 
44
45
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
@@ -70,11 +71,13 @@ export const reUnwind = async (
70
71
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
71
72
  export const planAndBuild = async (firstBase: string): Promise<void> => {
72
73
  let base = firstBase
74
+ let carried: FrozenScenarios | undefined
73
75
  for (;;) {
74
76
  await design(base)
75
- await architecturePass()
76
- await packages()
77
- const outcome = await buildTail(false, base)
77
+ await architecture()
78
+ const built = await packages(carried)
79
+ carried = built.frozen
80
+ const outcome = await buildTail(false, base, built)
78
81
  if (outcome.verdict === "signoff") return
79
82
  await reUnwind(outcome)
80
83
  base = outcome.base
@@ -103,30 +106,34 @@ export const ordinaryStart = async (): Promise<void> => {
103
106
  await planAndBuild(start())
104
107
  }
105
108
 
106
- 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
+ }
107
114
 
108
- export default async function unified({ entry }: FlowArgs): Promise<void> {
109
- if (entry === undefined) return ordinaryStart()
110
- if (entry === "fix-precheck") {
111
- if (await baseline("fix-precheck")) return
112
- return afterTail(await buildTail(true, start()))
113
- }
114
- if (entry === "review-gate.check") {
115
- await gate("review-gate", t.reviewGateBlockedMessage())
116
- return afterTail(await buildTail(false, start()))
117
- }
118
- if (entry === "start-gate.check") {
119
- await gate("start-gate", t.startGateBlockedMessage())
120
- return planAndBuild(start())
121
- }
122
- refuse(
123
- `"${entry}" is not an enterable state — enterable states:\n${ENTRIES.map((name) => ` ${name}`).join("\n")}`,
124
- )
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()))
125
119
  }
126
120
 
121
+ export const feature = ordinaryStart
122
+
123
+ export default feature
124
+
127
125
  export const summary: Summary = t.summaryPrompt
128
126
 
129
127
  export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
130
128
 
131
- export const base: EntryBase = (entry, vars) =>
132
- 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,6 +1,7 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../judges/index.js"
3
- import { retryQuestion } from "./health.js"
3
+ import { fastSuiteGuard, retryQuestion } from "./health.js"
4
+ import { renderText } from "./text.fixture.js"
4
5
 
5
6
  describe("retryQuestion", () => {
6
7
  it.each([true, false])("is sendable to jev (comparable=%s)", (comparable) => {
@@ -11,3 +12,22 @@ describe("retryQuestion", () => {
11
12
  ).toBeGreaterThanOrEqual(2)
12
13
  })
13
14
  })
15
+
16
+ describe("fastSuiteGuard", () => {
17
+ it("unset: names the setting and exits 0", () => {
18
+ const lines = renderText(fastSuiteGuard).join("\n")
19
+ expect(lines).toContain("fastTestCommand")
20
+ expect(lines).toContain("GTD_FASTTESTCOMMAND")
21
+ expect(lines).toContain("exit 0")
22
+ })
23
+
24
+ it("blank after trim counts as unset", () => {
25
+ expect(renderText(fastSuiteGuard, { env: { fastTestCommand: " \t " } })).toContain("exit 0")
26
+ })
27
+
28
+ it("set: only removes SETUP", () => {
29
+ const lines = renderText(fastSuiteGuard, { env: { fastTestCommand: "true" } })
30
+ expect(lines.join("\n")).not.toContain("exit 0")
31
+ expect(lines.join("\n")).toContain("rm -f")
32
+ })
33
+ })
@@ -1,3 +1,4 @@
1
+ import { dirname } from "node:path"
1
2
  import {
2
3
  answered,
3
4
  check,
@@ -5,6 +6,7 @@ import {
5
6
  human,
6
7
  judge,
7
8
  numeric,
9
+ quote,
8
10
  read,
9
11
  scope,
10
12
  vars,
@@ -13,12 +15,37 @@ import {
13
15
  import { describeEscalation, escalate, escalationExhausted, ESCALATION, FEEDBACK } from "./steps.js"
14
16
  import * as t from "./text.js"
15
17
 
18
+ /** Written while `fastTestCommand` is unset; its presence rests the process at the check. */
19
+ export const SETUP = ".gtd/SETUP.md"
20
+
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."
23
+
24
+ /** Shell lines for a check's preamble: unset (blank after trim) setting writes `SETUP` and skips the suite. */
25
+ export const fastSuiteGuard = (): string[] =>
26
+ (env.fastTestCommand ?? "").trim() === ""
27
+ ? [
28
+ `mkdir -p ${quote(dirname(SETUP))}`,
29
+ `printf '%s\\n' ${quote(SETUP_TEXT)} > ${quote(SETUP)}`,
30
+ `printf '%s\\n' ${quote(SETUP_TEXT)} >&2`,
31
+ "exit 0",
32
+ ]
33
+ : [`rm -f ${quote(SETUP)}`]
34
+
16
35
  /** Fix turns a run of red checks gets before it escalates. */
17
36
  export const FIX_CAP = 3
18
37
 
19
38
  /** Run the suite as step `name`; resolves `true` when it passed. A failure is in `.gtd/FEEDBACK.md`. */
20
- export const baseline = (name: string, label = "Checking the baseline"): Promise<boolean> =>
21
- check(name, env.testCommand ?? "", { report: FEEDBACK, label })
39
+ export const baseline = async (name: string, label = "Checking the baseline"): Promise<boolean> => {
40
+ for (;;) {
41
+ const green = await check(name, env.testCommand ?? "", {
42
+ report: FEEDBACK,
43
+ label,
44
+ preamble: fastSuiteGuard(),
45
+ })
46
+ if (read(SETUP) === undefined) return green
47
+ }
48
+ }
22
49
 
23
50
  /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
24
51
  export interface EscalationCount {
@@ -81,6 +108,21 @@ export interface HealthOptions {
81
108
  readonly fixesSoFar?: number
82
109
  /** Escalation rounds shared with other callers in the same run of red checks. */
83
110
  readonly escalations?: EscalationCount
111
+ /** `fast` runs `fastTestCommand` (default `full`: `testCommand`). */
112
+ readonly suite?: "full" | "fast"
113
+ /** Paths removed on a green run. */
114
+ readonly sweepOnGreen?: readonly string[]
115
+ }
116
+
117
+ const runSuite = (options: HealthOptions): Promise<boolean> => {
118
+ const fast = options.suite === "fast"
119
+ return check("health.check", (fast ? env.fastTestCommand : env.testCommand) ?? "", {
120
+ report: FEEDBACK,
121
+ label: "Running checks",
122
+ // Swept only on green: an unresolved analysis survives every retry.
123
+ sweepOnGreen: [ESCALATION, ...(options.sweepOnGreen ?? [])],
124
+ ...(fast ? { preamble: fastSuiteGuard() } : {}),
125
+ })
84
126
  }
85
127
 
86
128
  /**
@@ -96,12 +138,9 @@ export const healthy = async (
96
138
  let fixes = options.fixesSoFar ?? 0
97
139
  let previous: string | undefined
98
140
  for (;;) {
99
- const green = await check("health.check", env.testCommand ?? "", {
100
- report: FEEDBACK,
101
- label: "Running checks",
102
- // Swept only on green: an unresolved analysis survives every retry.
103
- sweepOnGreen: [ESCALATION],
104
- })
141
+ const green = await runSuite(options)
142
+ // Unconfigured: straight back to the check, no fix turn or count.
143
+ if (options.suite === "fast" && read(SETUP) !== undefined) continue
105
144
  if (green) {
106
145
  escalations.rounds = 0
107
146
  return
@@ -1 +1 @@
1
- export * as unified from "./unified.js"
1
+ export * as bundled from "./bundled.js"