@pmelab/gtd 23.2.0 → 24.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,324 @@
1
+ import {
2
+ changes,
3
+ changesSince,
4
+ hasThreadFor,
5
+ head,
6
+ read,
7
+ refuse,
8
+ requireReplies,
9
+ requireThreadsClosed,
10
+ type Change,
11
+ } from "../flows/index.js"
12
+ import { reviewNotes, reviewRisks, stripCodeThreads, type ReviewNote } from "../steering/index.js"
13
+
14
+ /** How a review round ended. `feedback` carries what the human edited, for the re-unwind to undo. */
15
+ export type ReviewOutcome =
16
+ | { readonly verdict: "signoff" }
17
+ | {
18
+ readonly verdict: "feedback"
19
+ /** The last round's commit, the re-plan base. */
20
+ readonly base: string
21
+ /** The commit the review file was written at; edits are restored from it. */
22
+ readonly restoreFrom: string
23
+ /** The code paths the rounds changed, with their content before. */
24
+ readonly edited: readonly Change[]
25
+ }
26
+
27
+ export type Verdict = "edit" | "question" | "nit" | "praise"
28
+
29
+ export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
30
+
31
+ /** Everything a review round decides or says, passed in: its files, steps, judge and texts. */
32
+ export interface ReviewPolicy {
33
+ /** The review steering file the reviewer writes and the human annotates. */
34
+ readonly file: string
35
+ /** The file a collecting turn folds feedback into; its presence means feedback. */
36
+ readonly feedback: string
37
+ /** Whether a path is code, as opposed to steering state. */
38
+ readonly isCode: (path: string) => boolean
39
+ /** Write `file` over everything since `base`, carrying the answers of commit `carry` when given. */
40
+ readonly reviewing: (base: string, carry?: string) => Promise<void>
41
+ readonly fixRisks: (risks: readonly ReviewRisk[]) => Promise<void>
42
+ readonly keepGreen: () => Promise<void>
43
+ readonly awaitReview: (base: string) => Promise<void>
44
+ /** The round at commit `round` left no `file`. */
45
+ readonly reviewMissing: (round: string) => Promise<void>
46
+ /** Turn the round `capture` describes into `feedback`, or change nothing. */
47
+ readonly collecting: (capture: string) => Promise<void>
48
+ /** One verdict per note, keyed by note id; a note without one is an edit. */
49
+ readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
50
+ readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
51
+ readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
52
+ /** Remove `file` at the end of a round at commit `round`. */
53
+ readonly close: (round: string) => Promise<void>
54
+ /** What a collecting turn reads. */
55
+ readonly captures: {
56
+ readonly edits: (round: string) => string
57
+ readonly notes: (round: string) => string
58
+ readonly editNotes: (
59
+ round: string,
60
+ edits: readonly ReviewNote[],
61
+ answeredAt: string | undefined,
62
+ ) => string
63
+ }
64
+ readonly refusals: {
65
+ /** `file` was deleted at the gate. */
66
+ readonly deleted: string
67
+ /** The collecting turn changed something without writing `feedback`. */
68
+ readonly unmatched: string
69
+ }
70
+ }
71
+
72
+ const collect = async (p: ReviewPolicy, capture: string): Promise<"signoff" | "feedback"> => {
73
+ await p.collecting(capture)
74
+ if (read(p.feedback) !== undefined) return "feedback"
75
+ if (changes().length === 0) return "signoff"
76
+ return refuse(p.refusals.unmatched)
77
+ }
78
+
79
+ /** A review with ticks, footnote markers and thread definitions removed: what is left is line notes. */
80
+ const notesOf = (text: string): string =>
81
+ text
82
+ .replace(/^\[\^[^\]]+\]:[^\n]*(\n(?: {2,}|\t)[^\n]*|\n(?=\s*\n(?: {2,}|\t)))*/gm, "")
83
+ .replace(/\[\^[^\]]+\]/g, "")
84
+ .replace(/\[x\]/gi, "[ ]")
85
+ .replace(/\s+/g, " ")
86
+ .trim()
87
+
88
+ /**
89
+ * The human gate and its reply rounds: a question gets an answer inside
90
+ * the review file and rests at the gate again, without a lap. Resolves the
91
+ * commit of the last collecting turn, `"missing"` when the round left no
92
+ * review, or `undefined` when no such turn ran.
93
+ */
94
+ const converse = async (
95
+ p: ReviewPolicy,
96
+ base: string,
97
+ collectedBefore?: string,
98
+ ): Promise<string | undefined> => {
99
+ let collectedAt = collectedBefore
100
+ for (;;) {
101
+ await p.awaitReview(base)
102
+ if (changes(p.file).some((c) => c.status === "deleted")) refuse(p.refusals.deleted)
103
+ if (read(p.file) === undefined) {
104
+ await p.reviewMissing(head())
105
+ return "missing"
106
+ }
107
+ requireThreadsClosed(p.file)
108
+ if (!hasThreadFor("agent", p.file)) return collectedAt
109
+ await p.collecting(p.captures.edits(head()))
110
+ collectedAt = head()
111
+ requireReplies(p.file)
112
+ if (!hasThreadFor("human", p.file)) return collectedAt
113
+ }
114
+ }
115
+
116
+ /** What `finish` hands back to `review`: an outcome, or another lap at the gate. */
117
+ type Finish =
118
+ | ReviewOutcome
119
+ | { readonly next: "await"; readonly reviewed: string }
120
+ | { readonly next: "rereview"; readonly carry: string | undefined }
121
+
122
+ /** What a round's routing needs from `finish`: its closing step and the ways it ends. */
123
+ interface Round {
124
+ readonly round: string
125
+ readonly close: () => Promise<void>
126
+ readonly outcome: (verdict: "signoff" | "feedback") => ReviewOutcome
127
+ readonly unfolded: () => Promise<Finish>
128
+ }
129
+
130
+ /** Judge every note, then run each verdict's route: answers, then nit fixes, then the edits' lap. */
131
+ const routeNotes = async (
132
+ p: ReviewPolicy,
133
+ notes: readonly ReviewNote[],
134
+ r: Round,
135
+ ): Promise<Finish> => {
136
+ const verdicts = await p.triage(notes)
137
+ const of = (v: Verdict): ReviewNote[] => notes.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
138
+ const [edits, questions, nits] = [of("edit"), of("question"), of("nit")]
139
+ if (edits.length + questions.length + nits.length === 0) return r.unfolded()
140
+
141
+ let answeredAt: string | undefined
142
+ if (questions.length > 0) {
143
+ await p.answerQuestions(questions)
144
+ answeredAt = head()
145
+ }
146
+ if (nits.length > 0) {
147
+ await p.fixNits(nits)
148
+ await p.keepGreen()
149
+ }
150
+ if (edits.length > 0) {
151
+ await r.close()
152
+ return r.outcome(await collect(p, p.captures.editNotes(r.round, edits, answeredAt)))
153
+ }
154
+ if (nits.length > 0) {
155
+ await r.close()
156
+ return { next: "rereview", carry: answeredAt }
157
+ }
158
+ return { next: "await", reviewed: answeredAt! }
159
+ }
160
+
161
+ /** A code edit, not a thread-only change: lines that only add, answer or remove code threads don't count. */
162
+ const isCodeEdit = (p: ReviewPolicy, c: Change): boolean =>
163
+ p.isCode(c.path) &&
164
+ stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
165
+
166
+ /** Whether anything but thread edits and ticks landed in the review file since `commit`. */
167
+ const notedSince = (p: ReviewPolicy, commit: string): boolean =>
168
+ changesSince(commit).some(
169
+ (c) => c.path === p.file && notesOf(c.before ?? "") !== notesOf(read(p.file) ?? ""),
170
+ )
171
+
172
+ /**
173
+ * What the in-loop collecting turn left: `folded` when it wrote `feedback`
174
+ * (those folds need their lap whatever is judged later), `settled` when only
175
+ * thread edits and ticks landed after it.
176
+ */
177
+ const afterCollect = (
178
+ p: ReviewPolicy,
179
+ collectedAt: string | undefined,
180
+ ): { folded: boolean; settled: boolean } =>
181
+ // While the collecting turn is still pending its commit has no hash, so no
182
+ // range can start there; the flow rests at the close before it matters.
183
+ collectedAt === "" ? { folded: false, settled: false } : settledAfter(p, collectedAt)
184
+
185
+ const settledAfter = (
186
+ p: ReviewPolicy,
187
+ collectedAt: string | undefined,
188
+ ): { folded: boolean; settled: boolean } => ({
189
+ folded: collectedAt !== undefined && read(p.feedback) !== undefined,
190
+ settled:
191
+ collectedAt !== undefined &&
192
+ changesSince(collectedAt).every((c) => !isCodeEdit(p, c)) &&
193
+ !notedSince(p, collectedAt),
194
+ })
195
+
196
+ const finish = async (
197
+ p: ReviewPolicy,
198
+ reviewed: string,
199
+ collectedAt: string | undefined,
200
+ ): Promise<Finish> => {
201
+ // Everything the human did is read before any agent turn runs, so a nit fix
202
+ // is never counted as a hand-edit.
203
+ const round = head()
204
+ const since = changesSince(reviewed)
205
+ const edited = since.filter((c) => isCodeEdit(p, c))
206
+ const baselineText = since.get(p.file)?.before ?? ""
207
+ const record = read(p.file) ?? ""
208
+ const { folded, settled } = afterCollect(p, collectedAt)
209
+ // The review gate clears every tick before its commit, so a changed
210
+ // review file is a note, never a tick.
211
+ const noted = since.some((c) => c.path === p.file)
212
+ const close = (): Promise<void> => p.close(round)
213
+ const outcome = (verdict: "signoff" | "feedback"): ReviewOutcome =>
214
+ verdict === "signoff" ? { verdict } : { verdict, base: round, restoreFrom: reviewed, edited }
215
+ const unfolded = async (): Promise<Finish> => {
216
+ await close()
217
+ return outcome(folded ? "feedback" : "signoff")
218
+ }
219
+
220
+ const collectWith = async (capture: string): Promise<Finish> => {
221
+ await close()
222
+ return outcome(await collect(p, capture))
223
+ }
224
+ if (settled || (edited.length === 0 && !noted)) return unfolded()
225
+ if (edited.length > 0) return collectWith(p.captures.edits(round))
226
+ const notes = reviewNotes(baselineText, record)
227
+ if (notes.length === 0) return collectWith(p.captures.notes(round))
228
+ return routeNotes(p, notes, { round, close, outcome, unfolded })
229
+ }
230
+
231
+ /** 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. */
232
+ const reviewOnce = async (
233
+ p: Pick<ReviewPolicy, "file" | "reviewing" | "fixRisks" | "keepGreen">,
234
+ base: string,
235
+ carry: string | undefined,
236
+ ) => {
237
+ await p.reviewing(base, carry)
238
+ const risks = reviewRisks(read(p.file) ?? "")
239
+ if (risks.length === 0) return
240
+ await p.fixRisks(risks)
241
+ await p.keepGreen()
242
+ await p.reviewing(base, carry)
243
+ }
244
+
245
+ /**
246
+ * A reviewer writes the review over everything since `base`, a human
247
+ * reviews and signs off or comments, and each note is judged: edits go to
248
+ * another lap, questions are answered in place, nits fixed in one batch,
249
+ * praise dropped.
250
+ */
251
+ export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutcome> => {
252
+ let carry: string | undefined
253
+ for (;;) {
254
+ await reviewOnce(p, base, carry)
255
+ carry = undefined
256
+ let reviewed = head()
257
+ let collectedAt: string | undefined
258
+ for (;;) {
259
+ collectedAt = await converse(p, base, collectedAt)
260
+ if (collectedAt === "missing") break
261
+ const result = await finish(p, reviewed, collectedAt)
262
+ if (!("next" in result)) return result
263
+ if (result.next === "rereview") {
264
+ carry = result.carry
265
+ break
266
+ }
267
+ reviewed = result.reviewed
268
+ }
269
+ }
270
+ }
271
+
272
+ /** What a review round that hands its notes on decides or says, passed in. */
273
+ export interface HandOffPolicy extends Pick<
274
+ ReviewPolicy,
275
+ "file" | "reviewing" | "fixRisks" | "keepGreen" | "awaitReview" | "reviewMissing" | "close"
276
+ > {
277
+ /** Keep `file` with the human's notes for another workflow; `reviewed` holds the reviewer's own version. */
278
+ readonly handOff: (reviewed: string) => Promise<void>
279
+ /** `file` was deleted at the gate. */
280
+ readonly deleted: string
281
+ /** A thread waits on the agent at the gate, and no turn here answers it. */
282
+ readonly threadsPending: string
283
+ /** The review was edited but the edit parses to no note, so `fix` would have nothing to act on. */
284
+ readonly unparsed: string
285
+ }
286
+
287
+ /**
288
+ * A reviewer writes the review over everything since `base` and a human
289
+ * reviews it. No note signs off and closes; any note ends the round with the
290
+ * review left in place, for another workflow to read its notes — nothing is
291
+ * judged or re-planned here.
292
+ */
293
+ export const reviewToHandOff = async (base: string, p: HandOffPolicy): Promise<void> => {
294
+ for (;;) {
295
+ await reviewOnce(p, base, undefined)
296
+ const reviewed = head()
297
+ await p.awaitReview(base)
298
+ if (changes(p.file).some((c) => c.status === "deleted")) refuse(p.deleted)
299
+ const record = read(p.file)
300
+ if (record === undefined) {
301
+ await p.reviewMissing(head())
302
+ continue
303
+ }
304
+ // `fix` runs in a new process, where a code thread is no longer "changed"
305
+ // and nothing answers it — so none may be left waiting on the agent.
306
+ requireThreadsClosed(p.file)
307
+ if (hasThreadFor("agent", p.file)) refuse(p.threadsPending)
308
+ const since = changesSince(reviewed)
309
+ const before = since.get(p.file)?.before ?? record
310
+ if (notesOf(before) === notesOf(record)) {
311
+ // A hand edit in a sign-off round was never checked or reviewed.
312
+ if (since.paths.some((f) => !f.startsWith(".gtd/"))) {
313
+ await p.keepGreen()
314
+ }
315
+ await p.close(head())
316
+ return
317
+ }
318
+ // `fix` acts only on parsed notes: an edit with none (a deleted chunk,
319
+ // stray prose) would be closed away unread, so it is refused here.
320
+ if (reviewNotes(before, record).length === 0) refuse(p.unparsed)
321
+ await p.handOff(reviewed)
322
+ return
323
+ }
324
+ }
@@ -1,5 +1,4 @@
1
- import { head, human, read, scope } from "../flows/index.js"
2
- import * as t from "./text.js"
1
+ import { head, read } from "../flows/index.js"
3
2
 
4
3
  /** Mutable: an accepted change moves the snapshot forward. */
5
4
  export interface FrozenScenarios {
@@ -93,26 +92,33 @@ const lineDiff = (a: readonly string[], b: readonly string[]): string[] => {
93
92
  return [...out, ...a.slice(i).map((l) => `- ${l}`), ...b.slice(j).map((l) => `+ ${l}`)]
94
93
  }
95
94
 
96
- export const wordingDrift = (frozen: FrozenScenarios, path: string): t.WordingDrift => ({
95
+ export interface WordingDrift {
96
+ readonly path: string
97
+ /** `- `/`+ `-prefixed, in file order. */
98
+ readonly lines: readonly string[]
99
+ }
100
+
101
+ export const wordingDrift = (frozen: FrozenScenarios, path: string): WordingDrift => ({
97
102
  path,
98
103
  lines: lineDiff(lines(frozen.texts[path]), lines(textOf(path))),
99
104
  })
100
105
 
101
- /** After a turn: stop at `scenario-wording` while wording drifted; accept moves the snapshot. */
102
- export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<void> => {
106
+ /**
107
+ * After a turn: rest at `stop` while wording drifted from `frozen`. Whatever
108
+ * still differs after it is accepted and moves the snapshot; what was
109
+ * restored stays frozen as it was.
110
+ */
111
+ export const holdWording = async (
112
+ frozen: FrozenScenarios | undefined,
113
+ stop: (at: string, drifted: readonly WordingDrift[]) => Promise<void>,
114
+ ): Promise<void> => {
103
115
  if (frozen === undefined) return
104
116
  const drifted = driftedScenarios(frozen)
105
117
  if (drifted.length === 0) return
106
- await human("scenario-wording", {
107
- message: t.scenarioWordingMessage(
108
- frozen.at,
109
- drifted.map((path) => wordingDrift(frozen, path)),
110
- ),
111
- label: "Scenario wording changed",
112
- acceptClean: true,
113
- base: frozen.at,
114
- })
115
- // Still differing is accepted; restored is rejected and stays frozen as it was.
118
+ await stop(
119
+ frozen.at,
120
+ drifted.map((path) => wordingDrift(frozen, path)),
121
+ )
116
122
  const accepted = driftedScenarios(frozen)
117
123
  if (accepted.length === 0) return
118
124
  for (const path of accepted) {
@@ -122,16 +128,3 @@ export const holdWording = async (frozen: FrozenScenarios | undefined): Promise<
122
128
  }
123
129
  frozen.at = head()
124
130
  }
125
-
126
- /** `turn`, then `holdWording`; `within` names the scope the gate rests in. */
127
- export const guarded =
128
- (
129
- frozen: FrozenScenarios | undefined,
130
- turn: () => Promise<void>,
131
- within?: string,
132
- ): (() => Promise<void>) =>
133
- async () => {
134
- await turn()
135
- if (within === undefined) await holdWording(frozen)
136
- else await scope(within, () => holdWording(frozen))
137
- }
@@ -0,0 +1,32 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { testRepo } from "../../testkit/index.js"
3
+ import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
+ import { reviewDoc } from "../shared/config.fixture.js"
5
+
6
+ describe("an ordinary feature start", () => {
7
+ it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
8
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
9
+ repo.write("TODO.md", "# Add numbers\n\nadd(a, b) returns the sum.\n")
10
+ const rest = await repo.drive({ unwind: () => undefined, "start-gate.check": () => undefined })
11
+ expect(rest).toMatchObject({
12
+ state: "design.triage",
13
+ actor: "agent",
14
+ file: ".gtd/REQUIREMENTS.md",
15
+ })
16
+ expect(repo.subjects().slice(1)).toEqual([
17
+ "gtd(human): idle → unwind",
18
+ "gtd(check): unwind → start-gate.check",
19
+ "gtd(check): start-gate.check → design.triage",
20
+ ])
21
+ })
22
+
23
+ it("refuses a pending hand-off right after idle, before unwinding", async () => {
24
+ const repo = testRepo({
25
+ workflow: CONFIG_SCAFFOLD,
26
+ files: { ".gtd/REVIEW.md": reviewDoc("note"), ".gtd/REVIEW.orig.md": reviewDoc("orig") },
27
+ })
28
+ repo.write("TODO.md", "# Add numbers\n")
29
+ await expect(repo.land()).rejects.toThrow(".gtd/REVIEW.orig.md is a pending hand-off")
30
+ expect(repo.subjects()).toEqual(["chore: initial commit"])
31
+ })
32
+ })
@@ -7,43 +7,22 @@ import {
7
7
  revertScript,
8
8
  run,
9
9
  start,
10
- type Doors,
11
- type WorkflowBase,
12
- type Summary,
13
- } from "../flows/index.js"
14
- import { baseline, gate } from "./health.js"
10
+ } from "../../flows/index.js"
11
+ import { gate } from "../shared/health.js"
12
+ import { shipped } from "../shared/options.js"
13
+ import type { FrozenScenarios } from "../shared/scenarios.js"
14
+ import { FEEDBACK, REVIEW } from "../shared/steps.js"
15
+ import { refusePendingHandOff } from "../shared/tail.js"
16
+ import * as t from "../shared/text.js"
15
17
  import { packages } from "./packages.js"
16
18
  import { architecture, design } from "./planning.js"
17
- import type { FrozenScenarios } from "./scenarios.js"
18
19
  import { buildTail, type ReviewOutcome } from "./review.js"
19
- import { ARCHITECTURE, FEEDBACK, REQUIREMENTS, REVIEW } from "./steps.js"
20
- import { shipped } from "./options.js"
21
- import * as t from "./text.js"
22
20
 
23
- // gtd's bundled workflows. Any change to the tree starts a process:
24
- // `idle` → `unwind` reverts the sketch (its intent survives in history) → a
21
+ // The ordinary start. Any change to the tree starts a process: `idle` →
22
+ // `unwind` reverts the sketch (its intent survives in history) → a
25
23
  // green-baseline gate → design, architecture and one package per concern →
26
24
  // the quality lap → human review, which signs off (the episode ends back at
27
- // `idle`) or sends a full re-plan lap. `feature` is that ordinary start;
28
- // `gtd --workflow fix` and `gtd --workflow review --var reviewBase=<commitish>`
29
- // enter the same build tail further in. Each of the three is a factory over
30
- // the settings in `./vars.ts`; its flow is exported too, for composing.
31
- //
32
- // Every part is exported for other workflows to compose; see the modules
33
- // re-exported below.
34
-
35
- export { threads, type ThreadInfo } from "../flows/index.js"
36
- export { defaults, envDefaults } from "./vars.js"
37
- export { ShippedOptions } from "./options.js"
38
- export { skills } from "./skills.js"
39
- export { access } from "./access.js"
40
- export { agentWithSkills } from "./text.js"
41
- export * from "./steps.js"
42
- export * from "./health.js"
43
- export * from "./planning.js"
44
- export * from "./packages.js"
45
- export * from "./scenarios.js"
46
- export * from "./review.js"
25
+ // `idle`) or sends a full re-plan lap.
47
26
 
48
27
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
49
28
  export const unwind = (): Promise<void> => {
@@ -97,6 +76,7 @@ export const afterTail = async (outcome: ReviewOutcome): Promise<void> => {
97
76
  /** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
98
77
  export const ordinaryStart = async (): Promise<void> => {
99
78
  await human("idle", { message: t.idleMessage(), label: "Idle", file: ".gtd/TODO.md" })
79
+ refusePendingHandOff()
100
80
  await unwind()
101
81
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
102
82
  await human("unwind-failed", {
@@ -109,36 +89,4 @@ export const ordinaryStart = async (): Promise<void> => {
109
89
  await planAndBuild(start())
110
90
  }
111
91
 
112
- /** Repair a red baseline through the build tail, as its own reviewed commit. */
113
- export const fixFlow = async (): Promise<void> => {
114
- if (await baseline("fix-precheck")) return
115
- return afterTail(await buildTail(true, start()))
116
- }
117
-
118
- /** Pure review of everything since `reviewBase`. */
119
- export const reviewFlow = async (): Promise<void> => {
120
- await gate("review-gate", t.reviewGateBlockedMessage())
121
- return afterTail(await buildTail(false, start()))
122
- }
123
-
124
92
  export const feature = shipped(ordinaryStart)
125
- export const fix = shipped(fixFlow)
126
- export const review = shipped(reviewFlow)
127
-
128
- export default feature
129
-
130
- export const summary: Summary = t.summaryPrompt
131
-
132
- export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
133
-
134
- export const base: WorkflowBase = (workflow, vars) =>
135
- workflow === "review" ? (vars.reviewBase ?? "") : undefined
136
-
137
- export const doors: Doors = {
138
- fix: { workflow: "fix" },
139
- review: {
140
- workflow: "review",
141
- args: [{ name: "base", optional: true }],
142
- vars: ({ base }) => (base === undefined ? {} : { reviewBase: base }),
143
- },
144
- }
@@ -0,0 +1,10 @@
1
+ import { feature } from "./feature.js"
2
+
3
+ export * from "./steps.js"
4
+ export * from "./planning.js"
5
+ export * from "./packages.js"
6
+ export { buildTail, type ReviewOutcome } from "./review.js"
7
+ export * from "./feature.js"
8
+ export * from "../shared/reserved.js"
9
+
10
+ export default feature
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type StepRequest } from "../flows/index.js"
2
+ import { installContext, type Change, type StepRequest } from "../../flows/index.js"
3
3
  import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
4
- import { fixtureContext } from "./text.fixture.js"
4
+ import { fixtureContext } from "../shared/text.fixture.js"
5
5
 
6
6
  afterEach(() => installContext(undefined))
7
7
 
@@ -8,9 +8,9 @@ import {
8
8
  removeScript,
9
9
  run,
10
10
  scope,
11
- } from "../flows/index.js"
12
- import { healthy } from "./health.js"
13
- import { freezeScenarios, guarded, type FrozenScenarios } from "./scenarios.js"
11
+ } from "../../flows/index.js"
12
+ import { healthy } from "../shared/health.js"
13
+ import { freezeScenarios, guarded, type FrozenScenarios } from "../shared/scenarios.js"
14
14
  import { build, fixSuite } from "./steps.js"
15
15
 
16
16
  /** Package 0: the e2e scenarios, written red. */
@@ -7,14 +7,13 @@ import {
7
7
  requireThreadsClosed,
8
8
  requireProgress,
9
9
  scope,
10
- } from "../flows/index.js"
10
+ } from "../../flows/index.js"
11
+ import { ARCHITECTURE, REQUIREMENTS } from "../shared/steps.js"
11
12
  import {
12
13
  answerProductQuestions,
13
14
  answerTechnicalQuestions,
14
- ARCHITECTURE,
15
15
  author,
16
16
  decompose,
17
- REQUIREMENTS,
18
17
  triage,
19
18
  } from "./steps.js"
20
19
 
@@ -1,8 +1,13 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
- import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
4
- import * as bundled from "./index.js"
5
- import { fixtureContext } from "./text.fixture.js"
2
+ import {
3
+ installContext,
4
+ type Change,
5
+ type JudgeAnswer,
6
+ type StepRequest,
7
+ } from "../../flows/index.js"
8
+ import { review, type ReviewOutcome } from "./review.js"
9
+ import * as bundled from "../index.js"
10
+ import { fixtureContext } from "../shared/text.fixture.js"
6
11
 
7
12
  afterEach(() => installContext(undefined))
8
13
 
@@ -378,43 +383,3 @@ describe("scenario wording in review's fix turns", () => {
378
383
  expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
379
384
  })
380
385
  })
381
-
382
- describe("the quality fix loop", () => {
383
- it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
384
- const QUALITY = ".gtd/QUALITY.md"
385
- const log: string[] = []
386
- const files = new Map([
387
- [QUALITY, "- finding"],
388
- ["e2e/a.feature", "Given a"],
389
- ])
390
- let last: readonly Change[] = []
391
- installContext(
392
- fixtureContext(
393
- {},
394
- {
395
- step: (request: StepRequest) => {
396
- if (request.kind === "restart") return Promise.resolve()
397
- log.push(request.name)
398
- last = []
399
- if (request.name === "fix.quality.fixing") {
400
- files.delete(QUALITY)
401
- files.set("e2e/a.feature", "Given b")
402
- last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
403
- }
404
- return Promise.resolve()
405
- },
406
- pushScope: () => undefined,
407
- popScope: () => undefined,
408
- read: (path) => files.get(path),
409
- changes: () => last,
410
- matches: (path, pattern) => path === pattern,
411
- head: () => "h2",
412
- start: () => "h0",
413
- },
414
- ),
415
- )
416
- const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
417
- expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
418
- expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
419
- })
420
- })