@pmelab/gtd 28.1.0 → 29.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 (54) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -17
  3. package/claude/hooks/assist.ts +1 -1
  4. package/claude/hooks/register.tsx +1 -1
  5. package/claude/hooks/ship.ts +963 -143
  6. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  7. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  8. package/dist/testkit.mjs +2 -1
  9. package/dist/workflow-kit.mjs +51 -21
  10. package/dist/workflows/cleanup.mjs +2 -2
  11. package/dist/workflows/feature.mjs +247 -96
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +1 -1
  15. package/dist/workflows.mjs +528 -262
  16. package/package.json +1 -1
  17. package/src/testkit/TestRepo.ts +3 -0
  18. package/src/testkit/index.ts +1 -1
  19. package/src/testkit/steps.ts +44 -0
  20. package/src/workflow-kit/review.test.ts +366 -8
  21. package/src/workflow-kit/review.ts +109 -26
  22. package/src/workflows/feature/feature.test.ts +313 -1
  23. package/src/workflows/feature/feature.ts +48 -43
  24. package/src/workflows/feature/one-step-land.feature +4 -0
  25. package/src/workflows/feature/refactor-first.feature +54 -24
  26. package/src/workflows/feature/refactor-review.feature +1706 -0
  27. package/src/workflows/feature/refactor.test.ts +592 -2
  28. package/src/workflows/feature/refactor.ts +160 -42
  29. package/src/workflows/feature/review.test.ts +322 -5
  30. package/src/workflows/feature/review.ts +77 -10
  31. package/src/workflows/feature/seam-rejection.feature +4 -0
  32. package/src/workflows/feature/seam.feature +3 -4
  33. package/src/workflows/feature/seam.test.ts +196 -1
  34. package/src/workflows/feature/seam.ts +23 -9
  35. package/src/workflows/feature/steps.test.ts +54 -2
  36. package/src/workflows/feature/steps.ts +45 -9
  37. package/src/workflows/feature/tier.test.ts +4 -0
  38. package/src/workflows/feature/tier.ts +2 -20
  39. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  40. package/src/workflows/shared/access.test.ts +31 -1
  41. package/src/workflows/shared/access.ts +7 -1
  42. package/src/workflows/shared/fences.ts +19 -0
  43. package/src/workflows/shared/health.ts +32 -12
  44. package/src/workflows/shared/options.test.ts +18 -0
  45. package/src/workflows/shared/options.ts +1 -0
  46. package/src/workflows/shared/prose.ts +4 -2
  47. package/src/workflows/shared/skills.test.ts +20 -1
  48. package/src/workflows/shared/skills.ts +43 -32
  49. package/src/workflows/shared/steps.ts +33 -19
  50. package/src/workflows/shared/tail.test.ts +84 -1
  51. package/src/workflows/shared/tail.ts +30 -12
  52. package/src/workflows/shared/text.test.ts +266 -4
  53. package/src/workflows/shared/text.ts +183 -70
  54. package/src/workflows/shared/vars.ts +12 -2
@@ -9,7 +9,13 @@ import {
9
9
  requireThreadsClosed,
10
10
  type Change,
11
11
  } from "../flows/index.js"
12
- import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
12
+ import {
13
+ parseThreads,
14
+ reviewNotes,
15
+ reviewRisks,
16
+ stripCodeThreads,
17
+ type ReviewNote,
18
+ } from "../steering/index.js"
13
19
 
14
20
  /** How a review round ended. `feedback` carries what the human edited, for the re-unwind to undo. */
15
21
  export type ReviewOutcome =
@@ -24,6 +30,15 @@ export type ReviewOutcome =
24
30
  readonly edited: readonly Change[]
25
31
  }
26
32
 
33
+ export type CollectRound = "reply" | "edits" | "notes" | "triaged"
34
+
35
+ /** What a round asked to change: the notes judged `edit` and the code paths the human hand-edited. */
36
+ export interface Requested {
37
+ /** Empty on a hand-edit round: that path runs no triage. */
38
+ readonly notes: readonly ReviewNote[]
39
+ readonly paths: readonly string[]
40
+ }
41
+
27
42
  export type Verdict = "edit" | "question" | "nit" | "praise" | "defer"
28
43
 
29
44
  export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
@@ -44,7 +59,7 @@ export interface ReviewPolicy {
44
59
  /** The round at commit `round` left no `file`. */
45
60
  readonly reviewMissing: (round: string) => Promise<void>
46
61
  /** Turn the round `capture` describes into `feedback`, or change nothing. */
47
- readonly collecting: (capture: string) => Promise<void>
62
+ readonly collecting: (capture: string, round: CollectRound) => Promise<void>
48
63
  /** One verdict per note, keyed by note id; a note without one is an edit. */
49
64
  readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
50
65
  readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
@@ -55,7 +70,8 @@ export interface ReviewPolicy {
55
70
  readonly close: (round: string) => Promise<void>
56
71
  /** What a collecting turn reads. */
57
72
  readonly captures: {
58
- readonly edits: (round: string) => string
73
+ /** `from` set: the hand-edits landed in an earlier commit than `round`, so name the range. */
74
+ readonly edits: (round: string, from?: string) => string
59
75
  readonly notes: (round: string) => string
60
76
  readonly editNotes: (
61
77
  round: string,
@@ -68,24 +84,56 @@ export interface ReviewPolicy {
68
84
  readonly deleted: string
69
85
  /** The collecting turn changed something without writing `feedback`. */
70
86
  readonly unmatched: string
87
+ /**
88
+ * The collecting turn changed nothing though the round carried an `edit`
89
+ * note or a code hand-edit. Absent: that round signs off.
90
+ */
91
+ readonly dropped?: (requested: Requested) => string
71
92
  }
72
93
  }
73
94
 
74
- const collect = async (p: ReviewPolicy, capture: string): Promise<"signoff" | "feedback"> => {
75
- await p.collecting(capture)
95
+ const collect = async (
96
+ p: ReviewPolicy,
97
+ capture: string,
98
+ round: CollectRound,
99
+ requested?: Requested,
100
+ ): Promise<"signoff" | "feedback"> => {
101
+ await p.collecting(capture, round)
76
102
  if (read(p.feedback) !== undefined) return "feedback"
77
- if (changes().length === 0) return "signoff"
103
+ if (changes().length === 0) {
104
+ if (
105
+ p.refusals.dropped !== undefined &&
106
+ requested !== undefined &&
107
+ requested.notes.length + requested.paths.length > 0
108
+ )
109
+ return refuse(p.refusals.dropped(requested))
110
+ return "signoff"
111
+ }
78
112
  return refuse(p.refusals.unmatched)
79
113
  }
80
114
 
81
- /** A review with ticks, footnote markers and thread definitions removed: what is left is line notes. */
82
- const notesOf = (text: string): string =>
83
- text
84
- .replace(/^\[\^[^\]]+\]:[^\n]*(\n(?: {2,}|\t)[^\n]*|\n(?=\s*\n(?: {2,}|\t)))*/gm, "")
85
- .replace(/\[\^[^\]]+\]/g, "")
86
- .replace(/\[x\]/gi, "[ ]")
87
- .replace(/\s+/g, " ")
88
- .trim()
115
+ const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
116
+
117
+ /**
118
+ * A review with ticks and THREAD footnotes (definition plus markers) removed:
119
+ * what is left is line notes and one-shot footnotes, which the triage judges.
120
+ */
121
+ const notesOf = (text: string): string => {
122
+ let out = text
123
+ for (const { name } of parseThreads(text)) {
124
+ const n = escapeRe(name)
125
+ out = out
126
+ .replace(
127
+ new RegExp(
128
+ `^\\[\\^${n}\\]:[^\\n]*(\\n(?: {2,}|\\t)[^\\n]*|\\n(?=\\s*\\n(?: {2,}|\\t)))*`,
129
+ "gim",
130
+ ),
131
+ "",
132
+ )
133
+ .replace(new RegExp(`\\[\\^${n}\\]`, "gi"), "")
134
+ }
135
+ return out.replace(/\[x\]/gi, "[ ]").replace(/\s+/g, " ").trim()
136
+ }
89
137
 
90
138
  /**
91
139
  * The human gate and its reply rounds: a question gets an answer inside
@@ -108,7 +156,7 @@ const converse = async (
108
156
  }
109
157
  requireThreadsClosed(p.file)
110
158
  if (!hasThreadFor("agent", p.file)) return collectedAt
111
- await p.collecting(p.captures.edits(head()))
159
+ await p.collecting(p.captures.edits(head()), "reply")
112
160
  collectedAt = head()
113
161
  requireReplies(p.file)
114
162
  if (!hasThreadFor("human", p.file)) return collectedAt
@@ -152,7 +200,12 @@ export const routeNotes = async (
152
200
  if (deferred.length > 0) await p.defer(deferred)
153
201
  if (edits.length > 0) {
154
202
  await r.close()
155
- return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
203
+ return r.outcome(
204
+ await collect(p, p.captures.editNotes(r.round, edits, answeredAt), "triaged", {
205
+ notes: edits,
206
+ paths: [],
207
+ }),
208
+ )
156
209
  }
157
210
  if (nits.length > 0) {
158
211
  await r.close()
@@ -196,6 +249,28 @@ const settledAfter = (
196
249
  !notedSince(p, collectedAt),
197
250
  })
198
251
 
252
+ const finishNotes = async (
253
+ p: ReviewPolicy,
254
+ before: string,
255
+ reopened: boolean,
256
+ ctx: Parameters<typeof routeNotes>[2],
257
+ ): Promise<Finish> => {
258
+ // An unchanged review file has no baseline entry, which would read as all-new.
259
+ const notes = reviewNotes(before, read(p.file) ?? "")
260
+ if (notes.length > 0) return routeNotes(p, notes, ctx)
261
+ if (reopened) return ctx.unfolded()
262
+ await ctx.close()
263
+ return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
264
+ }
265
+
266
+ const toOutcome = (
267
+ verdict: "signoff" | "feedback",
268
+ rest: { base: string; restoreFrom: string; edited: ReadonlyArray<Change> },
269
+ ): ReviewOutcome => (verdict === "signoff" ? { verdict } : { verdict, ...rest })
270
+
271
+ const isReopened = (p: ReviewPolicy, r: { settled: boolean; folded: boolean }): boolean =>
272
+ r.settled && !r.folded && p.refusals.dropped !== undefined
273
+
199
274
  const finish = async (
200
275
  p: ReviewPolicy,
201
276
  reviewed: string,
@@ -206,29 +281,37 @@ const finish = async (
206
281
  const round = head()
207
282
  const since = changesSince(reviewed)
208
283
  const edited = since.filter((c) => isCodeEdit(p, c))
209
- const baselineText = since.get(p.file)?.before ?? ""
210
- const record = read(p.file) ?? ""
211
284
  const { folded, settled } = afterCollect(p, collectedAt)
212
285
  // The review gate clears every tick before its commit, so a changed
213
286
  // review file is a note, never a tick.
214
287
  const noted = since.some((c) => c.path === p.file)
215
288
  const close = (): Promise<void> => p.close(round)
216
289
  const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
217
- verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
290
+ toOutcome(verdict, { base: round, restoreFrom: reviewed, edited })
218
291
  const unfolded = async (): Promise<Finish> => {
219
292
  await close()
220
293
  return outcome(folded ? "feedback" : "signoff")
221
294
  }
222
295
 
223
- const collectWith = async (capture: string): Promise<Finish> => {
296
+ // Thread-only edits settled the round, but a change note or hand-edit since
297
+ // `reviewed` must still reach a collecting turn when a dropped edit is refused.
298
+ const reopened = isReopened(p, { settled, folded })
299
+ if ((settled && !reopened) || (edited.length === 0 && !noted)) return unfolded()
300
+ if (edited.length > 0) {
224
301
  await close()
225
- return outcome(await collect(p, capture))
302
+ return outcome(
303
+ await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
304
+ notes: [],
305
+ paths: edited.map((c) => c.path),
306
+ }),
307
+ )
226
308
  }
227
- if (settled || (edited.length === 0 && !noted)) return unfolded()
228
- if (edited.length > 0) return collectWith(p.captures.edits(round))
229
- const notes = reviewNotes(baselineText, record)
230
- if (notes.length === 0) return collectWith(p.captures.notes(round))
231
- return routeNotes(p, notes, { round, close, outcome, unfolded })
309
+ return finishNotes(p, since.get(p.file)?.before ?? "", reopened, {
310
+ round,
311
+ close,
312
+ outcome,
313
+ unfolded,
314
+ })
232
315
  }
233
316
 
234
317
  /** Write the review; if it marks risks, fix them, keep green, and write it again — once, so the re-review's own risks reach the human unfixed. */
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { testRepo } from "../../testkit/index.js"
2
+ import { runsItsScript, testRepo, type Actor } from "../../testkit/index.js"
3
3
  import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
4
  import { reviewDoc } from "../shared/config.fixture.js"
5
5
 
@@ -30,3 +30,315 @@ describe("an ordinary feature start", () => {
30
30
  expect(repo.subjects()).toEqual(["chore: initial commit"])
31
31
  })
32
32
  })
33
+
34
+ const review = (text?: string): string =>
35
+ [
36
+ "# Review: abc1234",
37
+ "",
38
+ "<!-- base: 0000000 -->",
39
+ "",
40
+ "## calc",
41
+ "",
42
+ "- [ ] ./src/calc.ts#1-1",
43
+ text === undefined ? " add" : " add[^why]",
44
+ ...(text === undefined ? [] : ["", "[^why]:", ` - H: ${text}`]),
45
+ "",
46
+ ].join("\n")
47
+
48
+ const CALC = "export const add = (a: number, b: number) => a + b\n"
49
+
50
+ const naivePlanned = (flag: "0" | "1") => {
51
+ const repo = testRepo({
52
+ workflow: CONFIG_SCAFFOLD,
53
+ env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
54
+ })
55
+ repo.write("src/calc.ts", CALC)
56
+ repo.commit("feat: add calculator")
57
+ repo.write("TODO.md", "# Add subtract\n")
58
+ return repo
59
+ }
60
+
61
+ describe("planAndBuild on the naive pass", () => {
62
+ it("after land, feedback rests next at design.triage, then implement.building, never seam.naive", async () => {
63
+ const repo = naivePlanned("1")
64
+ const requirements = (_: unknown, r: typeof repo) =>
65
+ r.write(
66
+ ".gtd/REQUIREMENTS.md",
67
+ "# Requirements\n\n## Concerns\n\n### Subtract\n\n- `subtract`\n",
68
+ )
69
+ const lap = {
70
+ unwind: () => undefined,
71
+ "start-gate.check": () => undefined,
72
+ "design.triage": requirements,
73
+ "design.gate.answer": () => undefined,
74
+ "seam.naive": (_: unknown, r: typeof repo) => r.write("src/calc.ts", `${CALC}// naive\n`),
75
+ "seam.judge": () => '[{"id":"shape","answer":"land","p":0.9}]',
76
+ "implement.building": (_: unknown, r: typeof repo) =>
77
+ r.write("src/calc.ts", `${CALC}// built\n`),
78
+ "implement.health.check": () => undefined,
79
+ "implement.closing": runsItsScript,
80
+ "build.review.reviewing": (_: unknown, r: typeof repo) => r.write(".gtd/REVIEW.md", review()),
81
+ }
82
+ expect((await repo.drive(lap)).state).toBe("build.review.await-review")
83
+
84
+ repo.write(".gtd/REVIEW.md", review("also export multiply"))
85
+ await repo.land()
86
+ const collected = await repo.drive({
87
+ "build.review.collecting": (_, r) => {
88
+ requirements(_, r)
89
+ r.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
90
+ },
91
+ })
92
+ expect(collected.state).toBe("build.review.await-review")
93
+
94
+ repo.write(".gtd/REVIEW.md", review())
95
+ await repo.land()
96
+ const rest = await repo.drive({
97
+ "build.review.closing": runsItsScript,
98
+ "re-unwind": runsItsScript,
99
+ "design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n"),
100
+ "design.gate.answer": () => undefined,
101
+ })
102
+ expect(rest.state).toBe("implement.building")
103
+ expect(rest.content).toContain("already exists")
104
+ expect(rest.content).not.toContain("write the change fresh")
105
+ const subjects = repo.subjects()
106
+ const reUnwind = subjects.findIndex((subject) => subject.endsWith("→ re-unwind"))
107
+ expect(reUnwind).toBeGreaterThan(-1)
108
+ expect(subjects.slice(reUnwind + 1).some((subject) => subject.includes("seam."))).toBe(false)
109
+ expect(subjects.filter((subject) => subject.includes("→ seam.naive"))).toHaveLength(1)
110
+ })
111
+ })
112
+
113
+ describe("planAndBuild without the naive pass", () => {
114
+ it("runs the design → architecture path: no seam, no implement", async () => {
115
+ const repo = naivePlanned("0")
116
+ const rest = await repo.drive({
117
+ unwind: () => undefined,
118
+ "start-gate.check": () => undefined,
119
+ "design.triage": (_, r) => r.write(".gtd/REQUIREMENTS.md", "# Requirements\n"),
120
+ "design.gate.answer": () => undefined,
121
+ })
122
+ expect(rest.state).toBe("architecture.author")
123
+ expect(repo.subjects().some((subject) => /seam|implement/.test(subject))).toBe(false)
124
+ })
125
+ })
126
+
127
+ describe("afterTail feedback lap", () => {
128
+ const TAIL = [
129
+ `import { start } from "@pmelab/gtd/flows"`,
130
+ `import { afterTail, buildTail, gate } from "@pmelab/gtd/workflows"`,
131
+ `export { feature as default, defaults, envDefaults, summary, base, steering, skills, access } from "@pmelab/gtd/workflows"`,
132
+ `export async function tail() {`,
133
+ ` await gate("review-gate", "The baseline is red.")`,
134
+ ` return afterTail(await buildTail(false, start()))`,
135
+ `}`,
136
+ ].join("\n")
137
+
138
+ const prompts: string[] = []
139
+
140
+ const feedbackLap = async (flag: "0" | "1") => {
141
+ prompts.length = 0
142
+ const repo = testRepo({
143
+ workflow: TAIL,
144
+ env: { GTD_QUALITYREVIEWS: "", GTD_FLAG_NAIVE_PASS: flag, GTD_FLAG_RISK_TIER: "0" },
145
+ })
146
+ repo.write("src/calc.ts", CALC)
147
+ repo.commit("feat: add calculator")
148
+ await repo.start("tail", "--var", `reviewBase=${repo.hashes()[0]}`)
149
+ await repo.drive({
150
+ "review-gate.check": () => undefined,
151
+ "build.health.check": () => undefined,
152
+ "build.review.reviewing": (beat, r) => {
153
+ prompts.push(beat.content)
154
+ r.write(".gtd/REVIEW.md", review())
155
+ },
156
+ })
157
+ repo.write(".gtd/REVIEW.md", review("also export multiply"))
158
+ await repo.land()
159
+ await repo.drive({
160
+ "build.review.collecting": (_, r) => {
161
+ r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
162
+ r.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
163
+ },
164
+ })
165
+ repo.write(".gtd/REVIEW.md", review())
166
+ await repo.land()
167
+ return repo
168
+ }
169
+
170
+ const afterFeedback = {
171
+ "build.review.closing": runsItsScript,
172
+ "re-unwind": runsItsScript,
173
+ "design.triage": (_: unknown, r: ReturnType<typeof testRepo>) =>
174
+ r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"),
175
+ "design.gate.answer": () => undefined,
176
+ }
177
+
178
+ it("flag_naive_pass=1 rests at design.triage, then implement.building, reaching no seam step", async () => {
179
+ const repo = await feedbackLap("1")
180
+ expect(
181
+ (await repo.drive({ "build.review.closing": runsItsScript, "re-unwind": runsItsScript }))
182
+ .state,
183
+ ).toBe("design.triage")
184
+ const rest = await repo.drive(afterFeedback)
185
+ expect(rest.state).toBe("implement.building")
186
+ expect(rest.content).toContain("already exists")
187
+ expect(rest.content).not.toContain("write the change fresh")
188
+ expect(repo.subjects().some((subject) => subject.includes("seam."))).toBe(false)
189
+ })
190
+
191
+ it("flag_naive_pass=0 re-plans through architecture and packages", async () => {
192
+ const repo = await feedbackLap("0")
193
+ const rest = await repo.drive(afterFeedback)
194
+ expect(rest.state).toBe("architecture.author")
195
+ })
196
+
197
+ it.each(["0", "1"] as const)(
198
+ "flag_naive_pass=%s: the first review says everything since base, the one after feedback only what is new",
199
+ async (flag) => {
200
+ const repo = await feedbackLap(flag)
201
+ expect(prompts[0]).toContain("it covers everything since the base")
202
+ expect(prompts[0]).not.toContain("only what is new since the previous review")
203
+ const writes: Record<string, () => [string, string]> = {
204
+ "design.triage": () => [".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply, designed\n"],
205
+ "implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
206
+ "packages.item.building": () => ["src/calc.ts", `${CALC}// package\n`],
207
+ "architecture.author": () => [".gtd/ARCHITECTURE.md", "# Architecture\n\nmultiply\n"],
208
+ "architecture.decompose.decomposing": () => [
209
+ ".gtd/packages/01-multiply.md",
210
+ "# 01 multiply\n",
211
+ ],
212
+ }
213
+ const actors = new Proxy({} as Record<string, Actor>, {
214
+ get: (_, state: string): Actor | undefined =>
215
+ state === "build.review.await-review" || state === "idle"
216
+ ? undefined
217
+ : (beat, r) => {
218
+ if (state === "build.review.reviewing") {
219
+ prompts.push(beat.content)
220
+ r.write(".gtd/REVIEW.md", review())
221
+ } else if (writes[state]) r.write(...writes[state]())
222
+ else if (state.endsWith("closing") || state === "re-unwind")
223
+ r.runScript(beat.content)
224
+ },
225
+ })
226
+ await repo.drive(actors, { maxBeats: 80 })
227
+ expect(prompts[1]).toContain("it covers only what is new since the previous review")
228
+ expect(prompts[1]).not.toContain("everything since the base")
229
+ },
230
+ )
231
+ })
232
+
233
+ describe("an in-flight 28.0.0 naive-pass process", () => {
234
+ type Edge = readonly [actor: string, from: string, to: string, trailer?: string]
235
+ const verdict = (answer: string) => `Gtd-Judge: {"id":"shape","answer":"${answer}","p":0.9}`
236
+ const approved = 'Gtd-Approved: {"step":"seam.approve#1","name":"Test","email":"test@test.com"}'
237
+ const building: readonly Edge[] = [
238
+ ["agent", "implement.building", "implement.health.check"],
239
+ ["check", "implement.health.check", "implement.closing"],
240
+ ["check", "implement.closing", "build.review.reviewing"],
241
+ ["agent", "build.review.reviewing", "build.review.await-review"],
242
+ ]
243
+ const feedback: readonly Edge[] = [
244
+ ["human", "build.review.await-review", "build.review.collecting"],
245
+ ["agent", "build.review.collecting", "build.review.await-review"],
246
+ ["human", "build.review.await-review", "build.review.closing"],
247
+ ["check", "build.review.closing", "re-unwind"],
248
+ ["check", "re-unwind", "design.triage"],
249
+ ["agent", "design.triage", "design.gate.answer"],
250
+ ["human", "design.gate.answer", "implement.building"],
251
+ ]
252
+ const trails: Record<string, readonly Edge[]> = {
253
+ land: [["judge", "seam.judge", "implement.building", verdict("land")], ...building],
254
+ "land, then a feature feedback lap": [
255
+ ["judge", "seam.judge", "implement.building", verdict("land")],
256
+ ...building,
257
+ ...feedback,
258
+ ],
259
+ "rejected twice, steered, then landed": [
260
+ ["judge", "seam.judge", "seam.naive", verdict("reject")],
261
+ ["agent", "seam.naive", "seam.judge"],
262
+ ["judge", "seam.judge", "seam.naive", verdict("reject")],
263
+ ["agent", "seam.naive", "seam.judge"],
264
+ ["judge", "seam.judge", "seam.steer", verdict("reject")],
265
+ ["human", "seam.steer", "seam.naive"],
266
+ ["agent", "seam.naive", "seam.judge"],
267
+ ["judge", "seam.judge", "implement.building", verdict("land")],
268
+ ...building,
269
+ ],
270
+ "refactor-first": [
271
+ ["judge", "seam.judge", "seam.approve", verdict("refactor-first")],
272
+ ["human", "seam.approve", "refactor.deriving", approved],
273
+ ["check", "refactor.deriving", "refactor.building"],
274
+ ["agent", "refactor.building", "refactor.health.check"],
275
+ ["check", "refactor.health.check", "refactor.closing"],
276
+ ["check", "refactor.closing", "implement.building"],
277
+ ...building,
278
+ ],
279
+ }
280
+
281
+ const writeEdgeFiles = (
282
+ repo: { write: (path: string, content: string) => void },
283
+ from: string,
284
+ to: string,
285
+ ) => {
286
+ if (from === "seam.steer") repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- steered\n")
287
+ if (from === "build.review.reviewing") repo.write(".gtd/REVIEW.md", review())
288
+ if (to === "build.review.collecting")
289
+ repo.write(".gtd/REVIEW.md", review("also export multiply"))
290
+ if (from === "build.review.collecting") {
291
+ repo.write(".gtd/REVIEW.md", `${review("also export multiply")} - A: noted.\n`)
292
+ repo.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n- multiply\n")
293
+ }
294
+ if (to === "build.review.closing") repo.write(".gtd/REVIEW.md", review())
295
+ }
296
+
297
+ /** The repo at the judge's rest, then `trail` forged edge by edge as `Gtd-Step:` commits. */
298
+ const forged = async (trail: readonly Edge[], upTo: number) => {
299
+ const repo = naivePlanned("1")
300
+ await repo.drive({
301
+ unwind: () => undefined,
302
+ "start-gate.check": () => undefined,
303
+ "design.triage": (_, r) =>
304
+ r.write(".gtd/REQUIREMENTS.md", "# Requirements\n\n## Concerns\n\n### S\n\n- `s`\n"),
305
+ "design.gate.answer": () => undefined,
306
+ "seam.naive": (_, r) => r.write("src/calc.ts", `${CALC}// naive\n`),
307
+ })
308
+ const kept = /Gtd-Kept: (\w+)/.exec(repo.messages().at(-1)!)![1]!
309
+ const seen: Record<string, number> = {
310
+ "seam.naive": 1,
311
+ "design.triage": 1,
312
+ "design.gate.answer": 1,
313
+ }
314
+ for (const [actor, from, to, trailer] of trail.slice(0, upTo)) {
315
+ seen[from] = (seen[from] ?? 0) + 1
316
+ writeEdgeFiles(repo, from, to)
317
+ repo.commit(
318
+ [
319
+ `gtd(${actor}): ${from} → ${to}`,
320
+ "",
321
+ `Gtd-Step: ${from}#${seen[from]}`,
322
+ ...(trailer === undefined ? [] : [trailer]),
323
+ ...(actor === "agent" && from === "seam.naive" ? [`Gtd-Kept: ${kept}`] : []),
324
+ "Gtd-Format: 1",
325
+ ].join("\n"),
326
+ )
327
+ }
328
+ return repo
329
+ }
330
+
331
+ for (const [name, trail] of Object.entries(trails)) {
332
+ const replays = name !== "refactor-first"
333
+ it(`${name}: ${replays ? "every rest replays to itself" : "every rest from the verdict on fails replay"}`, async () => {
334
+ for (let upTo = 1; upTo <= trail.length; upTo++) {
335
+ const rest = trail[upTo - 1]![2]
336
+ const repo = await forged(trail, upTo)
337
+ if (replays) expect((await repo.next()).state, rest).toBe(rest)
338
+ else {
339
+ await expect(repo.next(), rest).rejects.toThrow("the workflow changed under this process")
340
+ }
341
+ }
342
+ })
343
+ }
344
+ })
@@ -1,23 +1,13 @@
1
- import {
2
- changes,
3
- head,
4
- human,
5
- requireRevert,
6
- restoreScript,
7
- revertScript,
8
- run,
9
- start,
10
- vars,
11
- } from "../../flows/index.js"
1
+ import { changes, head, human, revertScript, run, slice, start, vars } from "../../flows/index.js"
12
2
  import { gate } from "../shared/health.js"
13
3
  import { shipped } from "../shared/options.js"
14
- import { FEEDBACK, REVIEW } from "../shared/steps.js"
4
+ import { FEEDBACK } from "../shared/steps.js"
15
5
  import { refusePendingHandOff } from "../shared/tail.js"
16
6
  import * as t from "../shared/text.js"
17
7
  import { packages } from "./packages.js"
18
8
  import { architecture, design } from "./planning.js"
19
- import { buildTail, type ReviewOutcome } from "./review.js"
20
- import { seamBuild } from "./seam.js"
9
+ import { buildTail, reUnwind, type ReviewOutcome } from "./review.js"
10
+ import { implement, seamBuild } from "./seam.js"
21
11
  import { recordRisk, riskTierOn } from "./tier.js"
22
12
 
23
13
  // The ordinary start. Any change to the tree starts a process: `idle` →
@@ -34,49 +24,64 @@ export const unwind = (): Promise<void> => {
34
24
  })
35
25
  }
36
26
 
37
- /**
38
- * Undo the human's review-round code edits. A path changed since the human
39
- * left it is not overwritten; requireRevert names it instead.
40
- */
41
- export const reUnwind = async (
42
- feedback: Extract<ReviewOutcome, { verdict: "feedback" }>,
43
- ): Promise<void> => {
44
- const { base, restoreFrom, edited } = feedback
45
- const restore = edited.filter((c) => c.status !== "added").map((c) => c.path)
46
- const remove = edited.filter((c) => c.status === "added").map((c) => c.path)
47
- await run("re-unwind", restoreScript(base, { restore, remove }, restoreFrom), {
48
- label: "Re-unwinding your review edit",
49
- file: REVIEW,
50
- base,
51
- })
52
- requireRevert(edited, base)
53
- }
27
+ export { reUnwind }
54
28
 
55
29
  /** Plan, build and review until a review round signs off; feedback re-plans from scratch. */
56
- export const planAndBuild = async (firstBase: string): Promise<void> => {
30
+ export const planAndBuild = async (
31
+ firstBase: string,
32
+ firstLap: t.ReviewRange = "first",
33
+ ): Promise<void> => {
34
+ // GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
35
+ if (vars.flag_naive_pass === "1") return naivePlanAndBuild(firstBase)
57
36
  let base = firstBase
37
+ let lap = firstLap
58
38
  for (;;) {
59
39
  await design(base)
60
- const tier = riskTierOn() ? recordRisk() : undefined
61
- // GTD_FLAG_NAIVE_PASS: the record is pinned at the process start, so a mid-process flip never moves a process.
62
- if (vars.flag_naive_pass === "1") {
63
- await seamBuild(tier)
64
- } else {
65
- await architecture()
66
- await packages()
67
- }
68
- const outcome = await buildTail(false, base, true)
40
+ await architecture()
41
+ await packages()
42
+ const outcome = await buildTail(false, base, true, lap === "first" ? {} : { lap })
69
43
  if (outcome.verdict === "signoff") return
70
44
  await reUnwind(outcome)
71
45
  base = outcome.base
46
+ lap = "feedback"
47
+ }
48
+ }
49
+
50
+ // seam.naive / seam.judge run once: a feedback lap is design → implement on every verdict, so a
51
+ // seam a re-plan turns up is built unjudged and unrefactored.
52
+ const naivePlanAndBuild = async (firstBase: string): Promise<void> => {
53
+ await design(firstBase)
54
+ const tier = riskTierOn() ? recordRisk() : undefined
55
+ const seam = await seamBuild(firstBase)
56
+ await slice("feature", async () => {
57
+ await implement(seam.reference, tier)
58
+ await featureFeedback(
59
+ await buildTail(false, seam.base, true, { lensBase: seam.base }),
60
+ seam.base,
61
+ )
62
+ })
63
+ }
64
+
65
+ const featureFeedback = async (first: ReviewOutcome, lensBase?: string): Promise<void> => {
66
+ let outcome = first
67
+ while (outcome.verdict === "feedback") {
68
+ await reUnwind(outcome)
69
+ await design(outcome.base)
70
+ const tier = riskTierOn() ? recordRisk() : undefined
71
+ await implement(undefined, tier, true)
72
+ outcome = await buildTail(false, outcome.base, true, {
73
+ lap: "feedback",
74
+ ...(lensBase === undefined ? {} : { lensBase }),
75
+ })
72
76
  }
73
77
  }
74
78
 
75
- /** After a tail that skipped planning: stop on sign-off, re-plan on feedback. */
79
+ /** After a tail that skipped planning: stop on sign-off, re-plan on feedback. Opens no slice: slices cannot nest. */
76
80
  export const afterTail = async (outcome: ReviewOutcome): Promise<void> => {
77
81
  if (outcome.verdict === "signoff") return
82
+ if (vars.flag_naive_pass === "1") return featureFeedback(outcome)
78
83
  await reUnwind(outcome)
79
- await planAndBuild(outcome.base)
84
+ await planAndBuild(outcome.base, "feedback")
80
85
  }
81
86
 
82
87
  /** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
@@ -105,4 +105,8 @@ Feature: a naive pass the judge rules land goes straight to implement with no se
105
105
  And the commit "gtd(agent): seam.naive → seam.judge" has a body containing "Gtd-Kept:"
106
106
  And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
107
107
  And no commit body contains "Gtd-Approved:"
108
+ And the output lines of gtd "slices" start with:
109
+ """
110
+ feature
111
+ """
108
112
  And no commit body contains "Gtd-Workflow:"