@pmelab/gtd 29.3.1 → 30.1.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.
Files changed (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +64 -63
  3. package/claude/hooks/register.tsx +5 -0
  4. package/dist/chunks/{reserved-Dmpg6dAp.mjs → reserved-DDiiAZ8U.mjs} +35 -239
  5. package/dist/chunks/{tail-CLyE5VFR.mjs → tail-ILPlELuy.mjs} +1 -1
  6. package/dist/gtd.bundle.mjs +0 -5
  7. package/dist/testkit.mjs +0 -5
  8. package/dist/workflows/cleanup.mjs +2 -2
  9. package/dist/workflows/feature.mjs +17 -182
  10. package/dist/workflows/fix.mjs +2 -2
  11. package/dist/workflows/review.mjs +2 -2
  12. package/dist/workflows/rollout.mjs +1 -1
  13. package/dist/workflows.mjs +47 -416
  14. package/package.json +1 -1
  15. package/skills/authoring/SKILL.md +15 -27
  16. package/src/cli/Cli.test.ts +6 -6
  17. package/src/edge/Edge.test.ts +7 -7
  18. package/src/flags/index.test.ts +0 -4
  19. package/src/flags/index.ts +0 -1
  20. package/src/workflow/load.test.ts +1 -1
  21. package/src/workflows/feature/fast-path.feature +1 -2
  22. package/src/workflows/feature/feature.test.ts +115 -63
  23. package/src/workflows/feature/feature.ts +21 -38
  24. package/src/workflows/feature/flag-mode.feature +157 -0
  25. package/src/workflows/feature/index.ts +0 -1
  26. package/src/workflows/feature/one-step-land.feature +4 -6
  27. package/src/workflows/feature/planning.ts +2 -27
  28. package/src/workflows/feature/refactor-absorbs.feature +1 -2
  29. package/src/workflows/feature/refactor-first.feature +1 -3
  30. package/src/workflows/feature/refactor-review.feature +0 -1
  31. package/src/workflows/feature/risk-tier.feature +2 -3
  32. package/src/workflows/feature/seam-rejection.feature +1 -3
  33. package/src/workflows/feature/seam.feature +4 -6
  34. package/src/workflows/feature/seam.ts +2 -0
  35. package/src/workflows/feature/steps.test.ts +0 -20
  36. package/src/workflows/feature/steps.ts +1 -49
  37. package/src/workflows/feature/{seam-pin.feature → tier-pin.feature} +17 -11
  38. package/src/workflows/feature/tier.test.ts +3 -5
  39. package/src/workflows/feature/tier.ts +2 -2
  40. package/src/workflows/index.ts +0 -1
  41. package/src/workflows/shared/access.test.ts +0 -2
  42. package/src/workflows/shared/access.ts +0 -2
  43. package/src/workflows/shared/options.test.ts +14 -3
  44. package/src/workflows/shared/options.ts +17 -2
  45. package/src/workflows/shared/prose.ts +4 -11
  46. package/src/workflows/shared/reserved.ts +2 -2
  47. package/src/workflows/shared/skills.test.ts +0 -11
  48. package/src/workflows/shared/skills.ts +2 -9
  49. package/src/workflows/shared/steps.ts +0 -1
  50. package/src/workflows/shared/text.test.ts +20 -104
  51. package/src/workflows/shared/text.ts +28 -229
  52. package/src/workflows/shared/vars.ts +2 -3
  53. package/src/workflows/feature/packages.test.ts +0 -368
  54. package/src/workflows/feature/packages.ts +0 -173
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.3.1",
3
+ "version": "30.1.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": {
@@ -74,7 +74,7 @@ current directory is the whole workflow. If one already exists, read it and edit
74
74
  it in place.
75
75
 
76
76
  Prefer the bundled workflow's own parts over re-implementing them — `healthy`,
77
- `escalation`, `gate`, `design`, `architecture`, `packages`, `qualityLap`,
77
+ `escalation`, `gate`, `design`, `seamBuild`, `implement`, `qualityLap`,
78
78
  `buildTail`, and single steps like `triage` or `fixCheck`. Their full step names
79
79
  are versioned API.
80
80
 
@@ -222,41 +222,29 @@ workflow.
222
222
 
223
223
  ## Worked example: add an approval gate before building
224
224
 
225
- In the bundled default, `planAndBuild` runs the design phase, the architecture
226
- pass (which writes `.gtd/packages/`), then builds the packages. Add a human
227
- sign-off between the two:
225
+ In the bundled default, `planAndBuild` runs the design phase, then
226
+ `buildFeature`: the naive pass and its seam judge, then the implementation and
227
+ review. Add a human sign-off before the first build (a review feedback lap
228
+ re-runs design inside `buildFeature` and does not pass it again):
228
229
 
229
230
  ```ts
230
231
  const planAndBuild = async (firstBase: string): Promise<void> => {
231
- let base = firstBase
232
- let lap: "first" | "feedback" = "first"
233
- for (;;) {
234
- await design(base)
235
- await architecture()
236
- await human("approve-plan", {
237
- message:
238
- "The packages under .gtd/packages/ are ready. Edit them to adjust the plan, or change nothing — then run `gtd land` to start building.",
239
- label: "Approve the plan",
240
- acceptClean: true,
241
- })
242
- await packages()
243
- const outcome = await buildTail(base, {
244
- green: true,
245
- ...(lap === "first" ? {} : { lap }),
246
- })
247
- if (outcome.verdict === "signoff") return
248
- await reUnwind(outcome)
249
- base = outcome.base
250
- lap = "feedback"
251
- }
232
+ await design(firstBase)
233
+ await human("approve-plan", {
234
+ message:
235
+ "`.gtd/REQUIREMENTS.md` is settled. Edit it to adjust the plan, or change nothing — then run `gtd land` to start building.",
236
+ label: "Approve the plan",
237
+ acceptClean: true,
238
+ })
239
+ await buildFeature(firstBase)
252
240
  }
253
241
  ```
254
242
 
255
243
  `acceptClean: true` is what makes an untouched landing approve; without it the
256
244
  gate would wait for an edit. `approve-plan` sits in the `root` scope and is a
257
245
  new, unique name. Verify: `gtd next` loads without errors, and in a scratch
258
- repository a landing at `architecture.decompose` now leads to `approve-plan`,
259
- and an untouched landing there to `packages.item.building`.
246
+ repository a landing at `design.gate.answer` now leads to `approve-plan`, and an
247
+ untouched landing there to `seam.naive`.
260
248
 
261
249
  ## No migration
262
250
 
@@ -1327,7 +1327,7 @@ export const side = async () => {
1327
1327
  await agent("working", "w")
1328
1328
  }
1329
1329
 
1330
- export const defaults = { flag_naive_pass: "0" }
1330
+ export const defaults = { flag_risk_tier: "0" }
1331
1331
  `
1332
1332
  const start = async (
1333
1333
  flags?: CliIo["flags"],
@@ -1343,24 +1343,24 @@ export const defaults = { flag_naive_pass: "0" }
1343
1343
  return result().stdout
1344
1344
  }
1345
1345
 
1346
- it("pins flag_naive_pass=1 with the flag on", async () => {
1347
- expect(await start(undefined, { GTD_FLAG_NAIVE_PASS: "1" })).toContain("flag_naive_pass=1")
1346
+ it("pins flag_risk_tier=1 with the flag on", async () => {
1347
+ expect(await start(undefined, { GTD_FLAG_RISK_TIER: "1" })).toContain("flag_risk_tier=1")
1348
1348
  })
1349
1349
 
1350
1350
  it("honours defaultOn with GTD_FLAG_* unset", async () => {
1351
1351
  const out = await start(
1352
1352
  [
1353
1353
  {
1354
- name: "naive_pass",
1354
+ name: "risk_tier",
1355
1355
  owner: "pmelab",
1356
1356
  expiry: "2099-01-01",
1357
1357
  kind: "release",
1358
1358
  defaultOn: true,
1359
1359
  },
1360
1360
  ],
1361
- { GTD_FLAG_NAIVE_PASS: "" },
1361
+ { GTD_FLAG_RISK_TIER: "" },
1362
1362
  )
1363
- expect(out).toContain("flag_naive_pass=1")
1363
+ expect(out).toContain("flag_risk_tier=1")
1364
1364
  })
1365
1365
  })
1366
1366
 
@@ -1272,7 +1272,7 @@ export const side = async () => {
1272
1272
  await agent("working", "w")
1273
1273
  }
1274
1274
 
1275
- export const defaults = { flag_naive_pass: "0", other: "x" }
1275
+ export const defaults = { flag_risk_tier: "0", other: "x" }
1276
1276
  `
1277
1277
  const withFlags = <A>(
1278
1278
  eff: Effect.Effect<A, Error, RestRequirements>,
@@ -1285,23 +1285,23 @@ export const defaults = { flag_naive_pass: "0", other: "x" }
1285
1285
 
1286
1286
  it("a starting process takes the declared flag from StartFlags, never an undeclared one", async () => {
1287
1287
  const rest = await withFlags(currentRest, repoWith(FLAGGED), {
1288
- flag_naive_pass: "1",
1288
+ flag_risk_tier: "1",
1289
1289
  flag_other: "1",
1290
1290
  })
1291
- expect(rest.vars).toEqual({ flag_naive_pass: "1", other: "x" })
1291
+ expect(rest.vars).toEqual({ flag_risk_tier: "1", other: "x" })
1292
1292
  })
1293
1293
 
1294
1294
  it("a running process missing the pin resolves the declared default, never the live flag", async () => {
1295
- const repo = repoWith(FLAGGED.replace('flag_naive_pass: "0", ', ""))
1295
+ const repo = repoWith(FLAGGED.replace('flag_risk_tier: "0", ', ""))
1296
1296
  await enter(repo, "side")
1297
1297
  // a process started before the flag existed: its opening commit lacks the pin
1298
1298
  repo.writeFile("gtd.config.ts", FLAGGED)
1299
- const rest = await withFlags(currentRest, repo, { flag_naive_pass: "1" })
1300
- expect(rest.vars.flag_naive_pass).toBe("0")
1299
+ const rest = await withFlags(currentRest, repo, { flag_risk_tier: "1" })
1300
+ expect(rest.vars.flag_risk_tier).toBe("0")
1301
1301
  })
1302
1302
 
1303
1303
  it("with no StartFlags provided, resolves the declared default", async () => {
1304
- expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_naive_pass).toBe("0")
1304
+ expect((await provide(currentRest, repoWith(FLAGGED))).vars.flag_risk_tier).toBe("0")
1305
1305
  })
1306
1306
  })
1307
1307
 
@@ -92,10 +92,6 @@ describe("FLAGS", () => {
92
92
  })
93
93
  expect(new Set(names).size).toBe(names.length)
94
94
  })
95
-
96
- it("leaves naive_pass off by default", () => {
97
- expect(resolveFlags({}).naive_pass).toBe(false)
98
- })
99
95
  })
100
96
 
101
97
  describe("flagSettings", () => {
@@ -14,7 +14,6 @@ export interface FlagDeclaration {
14
14
  }
15
15
 
16
16
  export const FLAGS = [
17
- { name: "naive_pass", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
18
17
  { name: "risk_tier", owner: "pmelab", expiry: "2027-01-15", kind: "release" },
19
18
  ] as const satisfies readonly FlagDeclaration[]
20
19
 
@@ -853,7 +853,7 @@ describe("ConfigService", () => {
853
853
  it("names the key and lists every known scope", async () => {
854
854
  const message = await loadError(keyed("nope"))
855
855
  expect(message).toContain('"skills.nope" is not a scope that runs a turn — known scopes: ')
856
- expect(message).toContain("architecture, architecture.decompose, build,")
856
+ expect(message).toContain("known scopes: build, build.fix.quality,")
857
857
  })
858
858
 
859
859
  it("gives a group key the same plain error", async () => {
@@ -1,7 +1,7 @@
1
1
  @inmem
2
2
  Feature: a trivial change takes the same few beats at either risk tier, flag-free at low
3
3
 
4
- With GTD_FLAG_NAIVE_PASS and GTD_FLAG_RISK_TIER on, a one-line copy fix goes
4
+ With GTD_FLAG_RISK_TIER on, a one-line copy fix goes
5
5
  from idle through the naive pass, the seam judge's land, implement and human
6
6
  review back to idle. Low tier walks exactly the steps high does; only the
7
7
  implementation prompt and the summary differ.
@@ -9,7 +9,6 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
9
9
  Background:
10
10
  Given the shipped workflows, as gtd install scaffolds them
11
11
  And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
12
- And an environment variable "GTD_FLAG_NAIVE_PASS" set to "1"
13
12
  And an environment variable "GTD_FLAG_RISK_TIER" set to "1"
14
13
  And a file "src/greeting.ts" with:
15
14
  """
@@ -47,13 +47,12 @@ const review = (text?: string): string =>
47
47
 
48
48
  const CALC = "export const add = (a: number, b: number) => a + b\n"
49
49
 
50
- const naivePlanned = (flag: "0" | "1") => {
50
+ const naivePlanned = () => {
51
51
  const repo = testRepo({
52
52
  workflow: CONFIG_SCAFFOLD,
53
53
  env: {
54
54
  GTD_QUALITYREVIEWS: "",
55
55
  GTD_REFACTORQUALITYREVIEWS: "",
56
- GTD_FLAG_NAIVE_PASS: flag,
57
56
  GTD_FLAG_RISK_TIER: "0",
58
57
  },
59
58
  })
@@ -63,9 +62,9 @@ const naivePlanned = (flag: "0" | "1") => {
63
62
  return repo
64
63
  }
65
64
 
66
- describe("planAndBuild on the naive pass", () => {
65
+ describe("planAndBuild", () => {
67
66
  it("a refactor that carried the whole feature: the empty implement turn passes and the review covers the whole change", async () => {
68
- const repo = naivePlanned("1")
67
+ const repo = naivePlanned()
69
68
  const SUBTRACT = `${CALC}export const subtract = (a: number, b: number) => a - b\n`
70
69
  expect(
71
70
  (
@@ -99,7 +98,7 @@ describe("planAndBuild on the naive pass", () => {
99
98
  })
100
99
 
101
100
  it("after land, feedback rests next at design.triage, then implement.building, never seam.naive", async () => {
102
- const repo = naivePlanned("1")
101
+ const repo = naivePlanned()
103
102
  const requirements = (_: unknown, r: typeof repo) =>
104
103
  r.write(
105
104
  ".gtd/REQUIREMENTS.md",
@@ -149,20 +148,6 @@ describe("planAndBuild on the naive pass", () => {
149
148
  })
150
149
  })
151
150
 
152
- describe("planAndBuild without the naive pass", () => {
153
- it("runs the design → architecture path: no seam, no implement", async () => {
154
- const repo = naivePlanned("0")
155
- const rest = await repo.drive({
156
- unwind: () => undefined,
157
- "start-gate.check": () => undefined,
158
- "design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n"),
159
- "design.gate.answer": () => undefined,
160
- })
161
- expect(rest.state).toBe("architecture.author")
162
- expect(repo.subjects().some((subject) => /seam|implement/.test(subject))).toBe(false)
163
- })
164
- })
165
-
166
151
  describe("afterTail feedback lap", () => {
167
152
  const TAIL = [
168
153
  `import { start } from "@pmelab/gtd/flows"`,
@@ -176,14 +161,13 @@ describe("afterTail feedback lap", () => {
176
161
 
177
162
  const prompts: string[] = []
178
163
 
179
- const feedbackLap = async (flag: "0" | "1") => {
164
+ const feedbackLap = async () => {
180
165
  prompts.length = 0
181
166
  const repo = testRepo({
182
167
  workflow: TAIL,
183
168
  env: {
184
169
  GTD_QUALITYREVIEWS: "",
185
170
  GTD_REFACTORQUALITYREVIEWS: "",
186
- GTD_FLAG_NAIVE_PASS: flag,
187
171
  GTD_FLAG_RISK_TIER: "0",
188
172
  },
189
173
  })
@@ -219,8 +203,8 @@ describe("afterTail feedback lap", () => {
219
203
  "design.gate.answer": () => undefined,
220
204
  }
221
205
 
222
- it("flag_naive_pass=1 rests at design.triage, then implement.building, reaching no seam step", async () => {
223
- const repo = await feedbackLap("1")
206
+ it("rests at design.triage, then implement.building, reaching no seam step", async () => {
207
+ const repo = await feedbackLap()
224
208
  expect(
225
209
  (await repo.drive({ "build.review.closing": runsItsScript, "re-unwind": runsItsScript }))
226
210
  .state,
@@ -232,46 +216,30 @@ describe("afterTail feedback lap", () => {
232
216
  expect(repo.subjects().some((subject) => subject.includes("seam."))).toBe(false)
233
217
  })
234
218
 
235
- it("flag_naive_pass=0 re-plans through architecture and packages", async () => {
236
- const repo = await feedbackLap("0")
237
- const rest = await repo.drive(afterFeedback)
238
- expect(rest.state).toBe("architecture.author")
219
+ it("the first review says everything since base, the one after feedback only what is new", async () => {
220
+ const repo = await feedbackLap()
221
+ expect(prompts[0]).toContain("it covers everything since the base")
222
+ expect(prompts[0]).not.toContain("only what is new since the previous review")
223
+ const writes: Record<string, () => [string, string]> = {
224
+ "design.triage": () => [".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"],
225
+ "implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
226
+ }
227
+ const actors = new Proxy({} as Record<string, Actor>, {
228
+ get: (_, state: string): Actor | undefined =>
229
+ state === "build.review.await-review" || state === "idle"
230
+ ? undefined
231
+ : (beat, r) => {
232
+ if (state === "build.review.reviewing") {
233
+ prompts.push(beat.content)
234
+ r.write(".gtd/REVIEW.md", review())
235
+ } else if (writes[state]) r.write(...writes[state]())
236
+ else if (state.endsWith("closing") || state === "re-unwind") r.runScript(beat.content)
237
+ },
238
+ })
239
+ await repo.drive(actors, { maxBeats: 80 })
240
+ expect(prompts[1]).toContain("it covers only what is new since the previous review")
241
+ expect(prompts[1]).not.toContain("everything since the base")
239
242
  })
240
-
241
- it.each(["0", "1"] as const)(
242
- "flag_naive_pass=%s: the first review says everything since base, the one after feedback only what is new",
243
- async (flag) => {
244
- const repo = await feedbackLap(flag)
245
- expect(prompts[0]).toContain("it covers everything since the base")
246
- expect(prompts[0]).not.toContain("only what is new since the previous review")
247
- const writes: Record<string, () => [string, string]> = {
248
- "design.triage": () => [".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"],
249
- "implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
250
- "packages.item.building": () => ["src/calc.ts", `${CALC}// package\n`],
251
- "architecture.author": () => [".gtd/ARCHITECTURE.md", "# Architecture\n\nmultiply\n"],
252
- "architecture.decompose.decomposing": () => [
253
- ".gtd/packages/01-multiply.md",
254
- "# 01 multiply\n",
255
- ],
256
- }
257
- const actors = new Proxy({} as Record<string, Actor>, {
258
- get: (_, state: string): Actor | undefined =>
259
- state === "build.review.await-review" || state === "idle"
260
- ? undefined
261
- : (beat, r) => {
262
- if (state === "build.review.reviewing") {
263
- prompts.push(beat.content)
264
- r.write(".gtd/REVIEW.md", review())
265
- } else if (writes[state]) r.write(...writes[state]())
266
- else if (state.endsWith("closing") || state === "re-unwind")
267
- r.runScript(beat.content)
268
- },
269
- })
270
- await repo.drive(actors, { maxBeats: 80 })
271
- expect(prompts[1]).toContain("it covers only what is new since the previous review")
272
- expect(prompts[1]).not.toContain("everything since the base")
273
- },
274
- )
275
243
  })
276
244
 
277
245
  describe("an in-flight 28.0.0 naive-pass process", () => {
@@ -340,7 +308,7 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
340
308
 
341
309
  /** The repo at the judge's rest, then `trail` forged edge by edge as `Gtd-Step:` commits. */
342
310
  const forged = async (trail: readonly Edge[], upTo: number) => {
343
- const repo = naivePlanned("1")
311
+ const repo = naivePlanned()
344
312
  await repo.drive({
345
313
  unwind: () => undefined,
346
314
  "start-gate.check": () => undefined,
@@ -386,3 +354,87 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
386
354
  })
387
355
  }
388
356
  })
357
+
358
+ describe("an in-flight process from before the architecture path was removed", () => {
359
+ const REQUIREMENTS = "# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n"
360
+
361
+ /** Its history up to the design gate, forged with the old opening commit's `flag_naive_pass=0`. */
362
+ const atDesignGate = async () => {
363
+ const live = naivePlanned()
364
+ await live.drive({ unwind: () => undefined, "start-gate.check": () => undefined })
365
+ const opening = live.messages().find((m) => m.startsWith("gtd(human): idle → unwind"))!
366
+ const repo = naivePlanned()
367
+ repo.commit(
368
+ opening.replace(
369
+ "Gtd-Var: flag_risk_tier=0",
370
+ "Gtd-Var: flag_naive_pass=0\nGtd-Var: flag_risk_tier=0",
371
+ ),
372
+ )
373
+ repo.remove("TODO.md")
374
+ repo.commit("gtd(check): unwind → start-gate.check\n\nGtd-Step: unwind#1\nGtd-Format: 1")
375
+ repo.commit(
376
+ "gtd(check): start-gate.check → design.triage\n\nGtd-Step: start-gate.check#1\nGtd-Format: 1",
377
+ )
378
+ repo.write(".gtd/REQUIREMENTS.md", REQUIREMENTS)
379
+ repo.commit(
380
+ "gtd(agent): design.triage → design.gate.answer\n\nGtd-Step: design.triage#1\nGtd-Format: 1",
381
+ )
382
+ return repo
383
+ }
384
+
385
+ it("resting at the design gate, replays onto the naive pass", async () => {
386
+ const repo = await atDesignGate()
387
+ expect((await repo.next()).state).toBe("design.gate.answer")
388
+ await repo.land()
389
+ expect((await repo.next()).state).toBe("seam.naive")
390
+ })
391
+
392
+ it("past the design gate on the architecture path, fails replay", async () => {
393
+ const repo = await atDesignGate()
394
+ repo.commit(
395
+ "gtd(human): design.gate.answer → architecture.author\n\nGtd-Step: design.gate.answer#1\nGtd-Format: 1",
396
+ )
397
+ await expect(repo.next()).rejects.toThrow("the workflow changed under this process")
398
+ })
399
+ })
400
+
401
+ describe("flagMode", () => {
402
+ const config = (workflow: string): string =>
403
+ [
404
+ 'import { feature } from "@pmelab/gtd/workflows/feature"',
405
+ 'export { summary, steering, skills, access } from "@pmelab/gtd/workflows/feature"',
406
+ `export default ${workflow}`,
407
+ "",
408
+ ].join("\n")
409
+
410
+ const designed = async (workflow: string, env: Record<string, string> = {}) => {
411
+ const repo = testRepo({ workflow, env: { GTD_QUALITYREVIEWS: "", ...env } })
412
+ repo.write("src/calc.ts", CALC)
413
+ repo.commit("feat: add calculator")
414
+ repo.write("TODO.md", "# Add subtract\n")
415
+ return repo.drive({
416
+ unwind: () => undefined,
417
+ "start-gate.check": () => undefined,
418
+ "design.triage": (_, r) =>
419
+ r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
420
+ "design.gate.answer": () => undefined,
421
+ })
422
+ }
423
+
424
+ it("set on explicitly, runs the naive pass like the default", async () => {
425
+ expect((await designed(config("feature({ flagMode: true })"))).state).toBe("seam.naive")
426
+ })
427
+
428
+ it("off through GTD_FLAGMODE=false, skips the naive pass and the seam judge", async () => {
429
+ const rest = await designed(CONFIG_SCAFFOLD, { GTD_FLAGMODE: "false" })
430
+ expect(rest.state).toBe("implement.building")
431
+ expect(rest.content).toContain("this project ships without release flags")
432
+ })
433
+
434
+ it("refuses a value other than true or false before the process starts", async () => {
435
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_FLAGMODE: "off" } })
436
+ repo.write("TODO.md", "# Add subtract\n")
437
+ await expect(repo.drive({})).rejects.toThrow('flagMode must be "true" or "false", got "off"')
438
+ expect(repo.subjects()).toEqual(["chore: initial commit"])
439
+ })
440
+ })
@@ -7,24 +7,23 @@ import {
7
7
  run,
8
8
  slice,
9
9
  start,
10
- vars,
11
10
  } from "../../flows/index.js"
12
11
  import { gate } from "../shared/health.js"
13
- import { shipped } from "../shared/options.js"
12
+ import { flagModeOn, shipped } from "../shared/options.js"
14
13
  import { FEEDBACK } from "../shared/steps.js"
15
14
  import { refusePendingHandOff } from "../shared/tail.js"
16
15
  import * as t from "../shared/text.js"
17
- import { packages } from "./packages.js"
18
- import { architecture, design } from "./planning.js"
16
+ import { design } from "./planning.js"
19
17
  import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
20
- import { implement, seamBuild } from "./seam.js"
18
+ import { implement, seamBuild, type SeamOutcome } from "./seam.js"
21
19
  import { recordRisk, riskTierOn } from "./tier.js"
22
20
 
23
21
  // The ordinary start. Any change to the tree starts a process: `idle` →
24
22
  // `unwind` reverts the sketch (its intent survives in history) → a
25
- // green-baseline gate → design, architecture and one package per concern →
26
- // the quality lap → human review, which signs off (the episode ends back at
27
- // `idle`) or sends a full re-plan lap.
23
+ // green-baseline gate → design → a judged naive pass (refactoring first when
24
+ // the judge asks; with `flagMode` off, neither) → implement → the quality lap
25
+ // → human review, which signs off (the episode ends back at `idle`) or sends a
26
+ // design → implement lap.
28
27
 
29
28
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
30
29
  export const unwind = (): Promise<void> => {
@@ -36,33 +35,14 @@ export const unwind = (): Promise<void> => {
36
35
 
37
36
  export { reUnwind }
38
37
 
39
- /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
40
- export const planAndBuild = async (
41
- firstBase: string,
42
- firstLap: t.ReviewRange = "first",
43
- ): Promise<void> => {
44
- // GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
45
- if (vars.flag_naive_pass === "1") return naivePlanAndBuild(firstBase)
46
- let base = firstBase
47
- let lap = firstLap
48
- for (;;) {
49
- await design(base)
50
- await architecture()
51
- await packages()
52
- const outcome = await buildTail(base, { green: true, lap })
53
- if (outcome.verdict === "signoff") return
54
- await reUnwind(outcome)
55
- base = outcome.base
56
- lap = "feedback"
57
- }
58
- }
59
-
60
38
  // seam.naive / seam.judge run once: a feedback lap is design → implement on every verdict, so a
61
39
  // seam a re-plan turns up is built unjudged and unrefactored.
62
- const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
63
- await design(firstBase)
40
+ /** A judged naive pass (none with `flagMode` off), then implement and review until a review round signs off; design must have run since `firstBase`. */
41
+ export const buildFeature = async (firstBase: string): Promise<void> => {
64
42
  const tier = riskTierOn() ? recordRisk() : undefined
65
- const seam = await seamBuild(firstBase)
43
+ const seam: Omit<SeamOutcome, "reference"> & { reference?: string } = flagModeOn()
44
+ ? await seamBuild(firstBase)
45
+ : { base: firstBase }
66
46
  await slice("feature", async () => {
67
47
  await implement({ reference: seam.reference, tier, afterRefactor: seam.refactored })
68
48
  // An empty feature slice: the refactor carried the feature, so review the whole change.
@@ -74,6 +54,12 @@ const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
74
54
  })
75
55
  }
76
56
 
57
+ /** Design, then `buildFeature`. */
58
+ export const planAndBuild = async (firstBase: string): Promise<void> => {
59
+ await design(firstBase)
60
+ await buildFeature(firstBase)
61
+ }
62
+
77
63
  const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise<void> => {
78
64
  let outcome = first
79
65
  while (outcome.verdict === "feedback") {
@@ -86,17 +72,14 @@ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise
86
72
  }
87
73
 
88
74
  /** After a tail that skipped planning: stop on sign-off, re-plan on feedback. Opens no slice: slices cannot nest. */
89
- export const afterTail = async (outcome: ReviewOutcome): Promise<void> => {
90
- if (outcome.verdict === "signoff") return
91
- if (vars.flag_naive_pass === "1") return featureFeedback(outcome)
92
- await reUnwind(outcome)
93
- await planAndBuild(outcome.base, "feedback")
94
- }
75
+ export const afterTail = (outcome: ReviewOutcome): Promise<void> => featureFeedback(outcome)
95
76
 
96
77
  /** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
97
78
  export const ordinaryStart = async (): Promise<void> => {
98
79
  await human("idle", { message: t.idleMessage(), label: "Idle", file: ".gtd/TODO.md" })
99
80
  refusePendingHandOff()
81
+ // Read right after idle, so a bad value refuses the landing that would pin it.
82
+ flagModeOn()
100
83
  await unwind()
101
84
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
102
85
  await human("unwind-failed", {