@pmelab/gtd 30.0.0 → 31.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.
Files changed (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +23 -9
  3. package/claude/hooks/register.tsx +5 -0
  4. package/dist/chunks/{reserved-iG86IR3I.mjs → reserved-CHTJTzpA.mjs} +68 -9
  5. package/dist/chunks/{tail-DA2UuJNP.mjs → tail-CCsDBbZe.mjs} +1 -1
  6. package/dist/workflows/cleanup.mjs +2 -2
  7. package/dist/workflows/feature.mjs +141 -11
  8. package/dist/workflows/fix.mjs +2 -2
  9. package/dist/workflows/review.mjs +2 -2
  10. package/dist/workflows/rollout.mjs +1 -1
  11. package/dist/workflows.mjs +207 -18
  12. package/package.json +1 -1
  13. package/src/workflows/feature/acceptance-criteria-tier.feature +25 -0
  14. package/src/workflows/feature/acceptance-criteria.feature +189 -0
  15. package/src/workflows/feature/criteria.test.ts +106 -0
  16. package/src/workflows/feature/criteria.ts +168 -0
  17. package/src/workflows/feature/fast-path.feature +4 -0
  18. package/src/workflows/feature/feature.test.ts +81 -10
  19. package/src/workflows/feature/feature.ts +18 -10
  20. package/src/workflows/feature/flag-mode.feature +165 -0
  21. package/src/workflows/feature/index.ts +1 -0
  22. package/src/workflows/feature/one-step-land.feature +4 -0
  23. package/src/workflows/feature/planning.ts +2 -0
  24. package/src/workflows/feature/refactor-absorbs.feature +4 -0
  25. package/src/workflows/feature/refactor-first.feature +4 -0
  26. package/src/workflows/feature/refactor-review.feature +24 -0
  27. package/src/workflows/feature/risk-tier.feature +32 -0
  28. package/src/workflows/feature/seam-rejection.feature +8 -0
  29. package/src/workflows/feature/seam.feature +8 -0
  30. package/src/workflows/feature/seam.ts +14 -7
  31. package/src/workflows/feature/tier-pin.feature +4 -0
  32. package/src/workflows/index.ts +1 -0
  33. package/src/workflows/shared/options.test.ts +12 -0
  34. package/src/workflows/shared/options.ts +17 -2
  35. package/src/workflows/shared/text.test.ts +16 -0
  36. package/src/workflows/shared/text.ts +90 -8
  37. package/src/workflows/shared/vars.ts +1 -0
@@ -0,0 +1,106 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Change } from "../../flows/index.js"
3
+ import { criteriaOf, statedCriteria } from "./criteria.js"
4
+
5
+ const added = (path: string, after: string): Change => ({
6
+ path,
7
+ status: "added",
8
+ before: undefined,
9
+ after,
10
+ })
11
+
12
+ describe("criteriaOf", () => {
13
+ it("reads ID'd items, joining wrapped lines, and stops at the next section", () => {
14
+ expect(
15
+ criteriaOf(
16
+ "# R\n\n## Acceptance Criteria\n\n- AC-1: one\n continued\n- two\n\n## Notes\n\n- AC-9: not one\n",
17
+ ),
18
+ ).toEqual([
19
+ { id: "AC-1", text: "one continued" },
20
+ { id: "", text: "two" },
21
+ ])
22
+ })
23
+
24
+ it("skips fenced lines, reads an indented item, and stops at a subheading", () => {
25
+ expect(
26
+ criteriaOf(
27
+ "## Acceptance Criteria\n\n- AC-1: runs\n\n```sh\n# setup\n- key: v\n```\n - AC-2: two\n\n### Notes\n\n- note\n",
28
+ ),
29
+ ).toEqual([
30
+ { id: "AC-1", text: "runs" },
31
+ { id: "AC-2", text: "two" },
32
+ ])
33
+ })
34
+
35
+ it("reads only the level-2 section and joins an unindented wrapped line", () => {
36
+ expect(
37
+ criteriaOf(
38
+ "### Acceptance criteria\n\n- x\n\n## Acceptance Criteria\n\n- AC-1: exits 2 when the config\nfile is missing\n",
39
+ ),
40
+ ).toEqual([{ id: "AC-1", text: "exits 2 when the config file is missing" }])
41
+ })
42
+
43
+ it("folds a nested sub-bullet into its criterion", () => {
44
+ expect(
45
+ criteriaOf(
46
+ "## Acceptance Criteria\n\n- AC-1: exits 2 when config is bad\n - missing file\n",
47
+ ),
48
+ ).toEqual([{ id: "AC-1", text: "exits 2 when config is bad - missing file" }])
49
+ })
50
+
51
+ it("is undefined without the section", () => {
52
+ expect(criteriaOf("# R\n\n- AC-1: x\n")).toBeUndefined()
53
+ })
54
+ })
55
+
56
+ describe("statedCriteria", () => {
57
+ it("numbers a stated checklist from AC-1", () => {
58
+ const sketch = [added("TODO.md", "# T\n\n### Acceptance criteria:\n\n- [ ] a\n* b\n\nprose\n")]
59
+ expect(statedCriteria(sketch, "abc")).toMatchObject({
60
+ path: "TODO.md",
61
+ commit: "abc",
62
+ criteria: [
63
+ { id: "AC-1", text: "a" },
64
+ { id: "AC-2", text: "b" },
65
+ ],
66
+ })
67
+ })
68
+
69
+ it("renumbers when the stated IDs are partial or clash", () => {
70
+ const sketch = [added("TODO.md", "## Acceptance criteria\n\n- AC-4: a\n- b\n")]
71
+ expect(statedCriteria(sketch, "abc")?.criteria).toEqual([
72
+ { id: "AC-1", text: "a" },
73
+ { id: "AC-2", text: "b" },
74
+ ])
75
+ })
76
+
77
+ it("ignores a list the sketch left unchanged, a file other than TODO.md, a sketch with more in it, and an ambiguous pair", () => {
78
+ const list = "## Acceptance criteria\n\n- a\n"
79
+ expect(
80
+ statedCriteria(
81
+ [{ path: "TODO.md", status: "modified", before: list, after: `${list}\nmore\n` }],
82
+ "abc",
83
+ ),
84
+ ).toBeUndefined()
85
+ expect(
86
+ statedCriteria(
87
+ [
88
+ {
89
+ path: "TODO.md",
90
+ status: "modified",
91
+ before: `${list}- \n`,
92
+ after: `${list}- \n\nmore\n`,
93
+ },
94
+ ],
95
+ "abc",
96
+ ),
97
+ ).toBeUndefined()
98
+ expect(statedCriteria([added("docs/guide.md", list)], "abc")).toBeUndefined()
99
+ expect(
100
+ statedCriteria([added("TODO.md", list), added("src/a.ts", "x\n")], "abc"),
101
+ ).toBeUndefined()
102
+ expect(
103
+ statedCriteria([added("TODO.md", list), added(".gtd/TODO.md", list)], "abc"),
104
+ ).toBeUndefined()
105
+ })
106
+ })
@@ -0,0 +1,168 @@
1
+ import {
2
+ answered,
3
+ changes,
4
+ judge,
5
+ read,
6
+ refuse,
7
+ run,
8
+ scope,
9
+ type Change,
10
+ } from "../../flows/index.js"
11
+ import { markFenced } from "../shared/fences.js"
12
+ import { REQUIREMENTS } from "../shared/steps.js"
13
+ import * as t from "../shared/text.js"
14
+
15
+ export interface Criterion {
16
+ readonly id: string
17
+ readonly text: string
18
+ }
19
+
20
+ /** Acceptance criteria the human's sketch stated outright, ready to normalise. */
21
+ export interface StatedCriteria {
22
+ readonly path: string
23
+ readonly commit: string
24
+ /** The file as the sketch left it: `unwind` reverts it out of the tree. */
25
+ readonly text: string
26
+ readonly criteria: readonly Criterion[]
27
+ }
28
+
29
+ /** Below this the judge's `yes` is not trusted: a wrong skip builds against criteria nobody can check. */
30
+ const CLEAR_MIN_P = 0.8
31
+
32
+ const HEADING = /^ {0,3}(#{1,6})\s+(.*?)\s*#*\s*$/
33
+ const ITEM = /^ ?(?:[-*+]|\d+[.)])\s+(?:\[[ xX]\]\s+)?(.*)$/
34
+ const ID = /^(AC-\d+):\s*(.*)$/
35
+
36
+ /** The list items under the first heading `title` matches (at `level` when given), up to the next heading; wrapped lines joined, fenced lines ignored. */
37
+ const listUnder = (text: string, title: RegExp, level?: number): string[] | undefined => {
38
+ const lines = markFenced(text)
39
+ const at = lines.findIndex((l) => {
40
+ const heading = l.fenced ? null : HEADING.exec(l.line)
41
+ return (
42
+ heading !== null &&
43
+ (level === undefined || heading[1]!.length === level) &&
44
+ title.test(heading[2]!)
45
+ )
46
+ })
47
+ if (at === -1) return undefined
48
+ const items: string[] = []
49
+ let open = false
50
+ for (const { line, fenced } of lines.slice(at + 1)) {
51
+ if (fenced) {
52
+ open = false
53
+ continue
54
+ }
55
+ if (HEADING.test(line)) break
56
+ const item = ITEM.exec(line)
57
+ if (item !== null) {
58
+ items.push(item[1]!.trim())
59
+ open = true
60
+ } else if (open && line.trim() !== "") {
61
+ items[items.length - 1] = `${items.at(-1)!} ${line.trim()}`.trim()
62
+ } else open = false
63
+ }
64
+ return items
65
+ }
66
+
67
+ /** The `- AC-<n>: …` list of `## Acceptance Criteria`, or `undefined` when the section is missing. */
68
+ export const criteriaOf = (text: string): readonly Criterion[] | undefined =>
69
+ listUnder(text, /^acceptance criteria$/i, 2)?.map((item) => {
70
+ const id = ID.exec(item)
71
+ return id === null ? { id: "", text: item } : { id: id[1]!, text: id[2]! }
72
+ })
73
+
74
+ /** Items numbered `AC-1`…, unless every one already carries its own distinct ID. */
75
+ const numbered = (items: readonly string[]): Criterion[] => {
76
+ const ids = items.map((item) => ID.exec(item))
77
+ const own = ids.every((id) => id !== null) && new Set(ids.map((id) => id![1])).size === ids.length
78
+ return items.map((item, i) =>
79
+ own
80
+ ? { id: ids[i]![1]!, text: ids[i]![2]! }
81
+ : { id: `AC-${i + 1}`, text: item.replace(ID, "$2") },
82
+ )
83
+ }
84
+
85
+ const STATED = /^acceptance criteria:?$/i
86
+
87
+ const statedItems = (text: string | undefined): string[] =>
88
+ text === undefined ? [] : (listUnder(text, STATED) ?? []).filter((item) => item !== "")
89
+
90
+ /**
91
+ * The criteria a `TODO.md` in the sketch `sketch` (the idle turn's changes)
92
+ * added or changed under an `Acceptance criteria` heading. More than one such
93
+ * file is ambiguous, and any other change in the sketch is input only triage
94
+ * folds in, so none.
95
+ */
96
+ export const statedCriteria = (
97
+ sketch: readonly Change[],
98
+ commit: string,
99
+ ): StatedCriteria | undefined => {
100
+ const found = sketch.flatMap((change) => {
101
+ if (!/(^|\/)TODO\.md$/.test(change.path) || change.after === undefined) return []
102
+ const items = statedItems(change.after)
103
+ if (items.length === 0 || items.join("\n") === statedItems(change.before).join("\n")) return []
104
+ return [{ path: change.path, commit, text: change.after, criteria: numbered(items) }]
105
+ })
106
+ return found.length === 1 && sketch.length === 1 ? found[0] : undefined
107
+ }
108
+
109
+ const listing = (criteria: readonly Criterion[]): string => criteria.map(t.criterionLine).join("\n")
110
+
111
+ /** Refuse a turn that leaves `file` without ID'd acceptance criteria. A deleted file is `requireProgress`'s call. */
112
+ export const requireCriteria = (file: string): void => {
113
+ const text = read(file)
114
+ if (text === undefined) return
115
+ const criteria = criteriaOf(text) ?? []
116
+ const unnamed = criteria.filter((c) => c.id === "")
117
+ const ids = criteria.map((c) => c.id).filter((id) => id !== "")
118
+ const twice = ids.filter((id, i) => ids.indexOf(id) !== i)
119
+ if (criteria.length > 0 && unnamed.length === 0 && twice.length === 0) return
120
+ const why =
121
+ criteria.length === 0
122
+ ? `no "## Acceptance Criteria" list`
123
+ : unnamed.length > 0
124
+ ? `${unnamed.length} criterion without an ID:\n${unnamed.map((c) => ` - ${c.text}`).join("\n")}`
125
+ : `duplicate IDs: ${[...new Set(twice)].join(", ")}`
126
+ refuse(
127
+ `gtd land: acceptance-criteria: ${file} has ${why} — list every criterion under "## Acceptance Criteria" as "- AC-<n>: <checkable criterion>", each ID unique and never reused`,
128
+ )
129
+ }
130
+
131
+ /** Refuse a turn that changed the acceptance criteria in `file`: after the design gate only another design lap may. */
132
+ export const requireCriteriaFrozen = (file: string): void => {
133
+ const change = changes().get(file)
134
+ if (change === undefined) return
135
+ const before = listing(criteriaOf(change.before ?? "") ?? [])
136
+ if (before === listing(criteriaOf(change.after ?? "") ?? [])) return
137
+ refuse(
138
+ `gtd land: criteria-frozen: the acceptance criteria in ${file} are settled — restore them; only a design lap changes them`,
139
+ )
140
+ }
141
+
142
+ /** Whether the judge trusts `stated` as clear and checkable, every criterion of it. Truncated evidence never is. */
143
+ const clear = async (stated: StatedCriteria): Promise<boolean> => {
144
+ const { answers, truncated } = await judge("clear", {
145
+ questions: [t.clearCriteriaQuestion],
146
+ evidence: { criteria: listing(stated.criteria), sketch: stated.text },
147
+ message: t.clearCriteriaMessage(),
148
+ label: "Judging the stated acceptance criteria",
149
+ })
150
+ return truncated.length === 0 && answered(answers.clear, "yes", CLEAR_MIN_P)
151
+ }
152
+
153
+ /**
154
+ * The skip path: when the sketch stated clear, checkable criteria, write them
155
+ * to `.gtd/REQUIREMENTS.md` as they are and resolve `true` — no triage, no
156
+ * design gate. `false` when design must run.
157
+ */
158
+ export const skipDesign = (stated: StatedCriteria | undefined): Promise<boolean> =>
159
+ stated === undefined
160
+ ? Promise.resolve(false)
161
+ : scope("design", async () => {
162
+ if (!(await clear(stated))) return false
163
+ await run("normalise", t.normaliseCriteriaScript(stated), {
164
+ label: "Recording the stated acceptance criteria",
165
+ file: REQUIREMENTS,
166
+ })
167
+ return true
168
+ })
@@ -48,6 +48,10 @@ Feature: a trivial change takes the same few beats at either risk tier, flag-fre
48
48
  ## Risk Tier
49
49
 
50
50
  <tier> — one-line copy fix
51
+
52
+ ## Acceptance Criteria
53
+
54
+ - AC-1: the change behaves as described.
51
55
  """
52
56
  When the process is driven
53
57
  Then the process rests at "seam.naive"
@@ -72,7 +72,10 @@ describe("planAndBuild", () => {
72
72
  unwind: () => undefined,
73
73
  "start-gate.check": () => undefined,
74
74
  "design.triage": (_, r) =>
75
- r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
75
+ r.write(
76
+ ".gtd/REQUIREMENTS.md",
77
+ "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
78
+ ),
76
79
  "design.gate.answer": () => undefined,
77
80
  "seam.naive": (_, r) => r.write("src/calc.ts", SUBTRACT),
78
81
  "seam.judge": () => '[{"id":"shape","answer":"refactor-first","p":0.9}]',
@@ -102,7 +105,7 @@ describe("planAndBuild", () => {
102
105
  const requirements = (_: unknown, r: typeof repo) =>
103
106
  r.write(
104
107
  ".gtd/REQUIREMENTS.md",
105
- "# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n",
108
+ "# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
106
109
  )
107
110
  const lap = {
108
111
  unwind: () => undefined,
@@ -134,7 +137,11 @@ describe("planAndBuild", () => {
134
137
  const rest = await repo.drive({
135
138
  "build.review.closing": runsItsScript,
136
139
  "re-unwind": runsItsScript,
137
- "design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n"),
140
+ "design.triage": (_, r) =>
141
+ r.write(
142
+ ".gtd/REQUIREMENTS.md",
143
+ "# Requirements\n\n- multiply\n\n## Acceptance Criteria\n\n- AC-1: done\n",
144
+ ),
138
145
  "design.gate.answer": () => undefined,
139
146
  })
140
147
  expect(rest.state).toBe("implement.building")
@@ -186,7 +193,10 @@ describe("afterTail feedback lap", () => {
186
193
  await repo.land()
187
194
  await repo.drive({
188
195
  "build.review.collecting": (_, r) => {
189
- r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
196
+ r.write(
197
+ ".gtd/REQUIREMENTS.md",
198
+ "# Requirements\n\n- multiply\n\n## Acceptance Criteria\n\n- AC-1: done\n",
199
+ )
190
200
  r.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
191
201
  },
192
202
  })
@@ -199,7 +209,10 @@ describe("afterTail feedback lap", () => {
199
209
  "build.review.closing": runsItsScript,
200
210
  "re-unwind": runsItsScript,
201
211
  "design.triage": (_: unknown, r: ReturnType<typeof testRepo>) =>
202
- r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"),
212
+ r.write(
213
+ ".gtd/REQUIREMENTS.md",
214
+ "# Requirements\n\n- multiply, designed\n\n## Acceptance Criteria\n\n- AC-1: done\n",
215
+ ),
203
216
  "design.gate.answer": () => undefined,
204
217
  }
205
218
 
@@ -221,7 +234,10 @@ describe("afterTail feedback lap", () => {
221
234
  expect(prompts[0]).toContain("it covers everything since the base")
222
235
  expect(prompts[0]).not.toContain("only what is new since the previous review")
223
236
  const writes: Record<string, () => [string, string]> = {
224
- "design.triage": () => [".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"],
237
+ "design.triage": () => [
238
+ ".gtd/REQUIREMENTS.md",
239
+ "# Requirements\n\n- multiply, designed\n\n## Acceptance Criteria\n\n- AC-1: done\n",
240
+ ],
225
241
  "implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
226
242
  }
227
243
  const actors = new Proxy({} as Record<string, Actor>, {
@@ -295,13 +311,20 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
295
311
  from: string,
296
312
  to: string,
297
313
  ) => {
298
- if (from === "seam.steer") repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- steered\n")
314
+ if (from === "seam.steer")
315
+ repo.write(
316
+ ".gtd/REQUIREMENTS.md",
317
+ "# Requirements\n\n- steered\n\n## Acceptance Criteria\n\n- AC-1: done\n",
318
+ )
299
319
  if (from === "build.review.reviewing") repo.write(".gtd/REVIEW.md", review())
300
320
  if (to === "build.review.collecting")
301
321
  repo.write(".gtd/REVIEW.md", review("also export multiply"))
302
322
  if (from === "build.review.collecting") {
303
323
  repo.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
304
- repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
324
+ repo.write(
325
+ ".gtd/REQUIREMENTS.md",
326
+ "# Requirements\n\n- multiply\n\n## Acceptance Criteria\n\n- AC-1: done\n",
327
+ )
305
328
  }
306
329
  if (to === "build.review.closing") repo.write(".gtd/REVIEW.md", review())
307
330
  }
@@ -313,7 +336,10 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
313
336
  unwind: () => undefined,
314
337
  "start-gate.check": () => undefined,
315
338
  "design.triage": (_, r) =>
316
- r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
339
+ r.write(
340
+ ".gtd/REQUIREMENTS.md",
341
+ "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
342
+ ),
317
343
  "design.gate.answer": () => undefined,
318
344
  "seam.naive": (_, r) => r.write("src/calc.ts", `${CALC}// naive\n`),
319
345
  })
@@ -356,7 +382,8 @@ describe("an in-flight 28.0.0 naive-pass process", () => {
356
382
  })
357
383
 
358
384
  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"
385
+ const REQUIREMENTS =
386
+ "# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n\n## Acceptance Criteria\n\n- AC-1: done\n"
360
387
 
361
388
  /** Its history up to the design gate, forged with the old opening commit's `flag_naive_pass=0`. */
362
389
  const atDesignGate = async () => {
@@ -397,3 +424,47 @@ describe("an in-flight process from before the architecture path was removed", (
397
424
  await expect(repo.next()).rejects.toThrow("the workflow changed under this process")
398
425
  })
399
426
  })
427
+
428
+ describe("flagMode", () => {
429
+ const config = (workflow: string): string =>
430
+ [
431
+ 'import { feature } from "@pmelab/gtd/workflows/feature"',
432
+ 'export { summary, steering, skills, access } from "@pmelab/gtd/workflows/feature"',
433
+ `export default ${workflow}`,
434
+ "",
435
+ ].join("\n")
436
+
437
+ const designed = async (workflow: string, env: Record<string, string> = {}) => {
438
+ const repo = testRepo({ workflow, env: { GTD_QUALITYREVIEWS: "", ...env } })
439
+ repo.write("src/calc.ts", CALC)
440
+ repo.commit("feat: add calculator")
441
+ repo.write("TODO.md", "# Add subtract\n")
442
+ return repo.drive({
443
+ unwind: () => undefined,
444
+ "start-gate.check": () => undefined,
445
+ "design.triage": (_, r) =>
446
+ r.write(
447
+ ".gtd/REQUIREMENTS.md",
448
+ "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n\n## Acceptance Criteria\n\n- AC-1: done\n",
449
+ ),
450
+ "design.gate.answer": () => undefined,
451
+ })
452
+ }
453
+
454
+ it("set on explicitly, runs the naive pass like the default", async () => {
455
+ expect((await designed(config("feature({ flagMode: true })"))).state).toBe("seam.naive")
456
+ })
457
+
458
+ it("off through GTD_FLAGMODE=false, skips the naive pass and the seam judge", async () => {
459
+ const rest = await designed(CONFIG_SCAFFOLD, { GTD_FLAGMODE: "false" })
460
+ expect(rest.state).toBe("implement.building")
461
+ expect(rest.content).toContain("this project ships without release flags")
462
+ })
463
+
464
+ it("refuses a value other than true or false before the process starts", async () => {
465
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_FLAGMODE: "off" } })
466
+ repo.write("TODO.md", "# Add subtract\n")
467
+ await expect(repo.drive({})).rejects.toThrow('flagMode must be "true" or "false", got "off"')
468
+ expect(repo.subjects()).toEqual(["chore: initial commit"])
469
+ })
470
+ })
@@ -9,20 +9,22 @@ import {
9
9
  start,
10
10
  } from "../../flows/index.js"
11
11
  import { gate } from "../shared/health.js"
12
- import { shipped } from "../shared/options.js"
12
+ import { flagModeOn, shipped } from "../shared/options.js"
13
13
  import { FEEDBACK } from "../shared/steps.js"
14
14
  import { refusePendingHandOff } from "../shared/tail.js"
15
15
  import * as t from "../shared/text.js"
16
+ import { skipDesign, statedCriteria, type StatedCriteria } from "./criteria.js"
16
17
  import { design } from "./planning.js"
17
18
  import { buildTail, isCode, reUnwind, type ReviewOutcome } from "./review.js"
18
- import { implement, seamBuild } from "./seam.js"
19
+ import { implement, seamBuild, type SeamOutcome } from "./seam.js"
19
20
  import { recordRisk, riskTierOn } from "./tier.js"
20
21
 
21
22
  // The ordinary start. Any change to the tree starts a process: `idle` →
22
23
  // `unwind` reverts the sketch (its intent survives in history) → a
23
24
  // green-baseline gate → design → a judged naive pass (refactoring first when
24
- // the judge asks) → implement → the quality lap → human review, which signs
25
- // off (the episode ends back at `idle`) or sends a design → implement lap.
25
+ // the judge asks; with `flagMode` off, neither) → implement → the quality lap
26
+ // → human review, which signs off (the episode ends back at `idle`) or sends a
27
+ // design → implement lap.
26
28
 
27
29
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
28
30
  export const unwind = (): Promise<void> => {
@@ -36,10 +38,12 @@ export { reUnwind }
36
38
 
37
39
  // seam.naive / seam.judge run once: a feedback lap is design → implement on every verdict, so a
38
40
  // seam a re-plan turns up is built unjudged and unrefactored.
39
- /** A judged naive pass, then implement and review until a review round signs off; design must have run since `firstBase`. */
41
+ /** A judged naive pass (none with `flagMode` off), then implement and review until a review round signs off; design must have run since `firstBase`. */
40
42
  export const buildFeature = async (firstBase: string): Promise<void> => {
41
43
  const tier = riskTierOn() ? recordRisk() : undefined
42
- const seam = await seamBuild(firstBase)
44
+ const seam: Omit<SeamOutcome, "reference"> & { reference?: string } = flagModeOn()
45
+ ? await seamBuild(firstBase)
46
+ : { base: firstBase }
43
47
  await slice("feature", async () => {
44
48
  await implement({ reference: seam.reference, tier, afterRefactor: seam.refactored })
45
49
  // An empty feature slice: the refactor carried the feature, so review the whole change.
@@ -51,9 +55,10 @@ export const buildFeature = async (firstBase: string): Promise<void> => {
51
55
  })
52
56
  }
53
57
 
54
- /** Design, then `buildFeature`. */
55
- export const planAndBuild = async (firstBase: string): Promise<void> => {
56
- await design(firstBase)
58
+ /** Design — skipped when the sketch's own criteria are judged clear — then `buildFeature`. */
59
+ export const planAndBuild = async (firstBase: string, stated?: StatedCriteria): Promise<void> => {
60
+ // GTD_FLAG_RISK_TIER: the tier is proposed at triage and set at the design gate, so no skip.
61
+ if (!(await skipDesign(riskTierOn() ? undefined : stated))) await design(firstBase)
57
62
  await buildFeature(firstBase)
58
63
  }
59
64
 
@@ -75,6 +80,9 @@ export const afterTail = (outcome: ReviewOutcome): Promise<void> => featureFeedb
75
80
  export const ordinaryStart = async (): Promise<void> => {
76
81
  await human("idle", { message: t.idleMessage(), label: "Idle", file: ".gtd/TODO.md" })
77
82
  refusePendingHandOff()
83
+ // Read right after idle, so a bad value refuses the landing that would pin it.
84
+ flagModeOn()
85
+ const stated = statedCriteria(changes(), head())
78
86
  await unwind()
79
87
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
80
88
  await human("unwind-failed", {
@@ -84,7 +92,7 @@ export const ordinaryStart = async (): Promise<void> => {
84
92
  })
85
93
  }
86
94
  await gate("start-gate", t.startGateBlockedMessage())
87
- await planAndBuild(start())
95
+ await planAndBuild(start(), stated)
88
96
  }
89
97
 
90
98
  export const feature = shipped(ordinaryStart)