@pmelab/gtd 23.1.0 → 24.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -6
  3. package/dist/gtd.bundle.mjs +795 -1051
  4. package/dist/workflows.mjs +1952 -1725
  5. package/package.json +2 -1
  6. package/skills/authoring/SKILL.md +5 -4
  7. package/src/flows/factory.ts +5 -1
  8. package/src/flows/runtime.ts +3 -0
  9. package/src/workflow-kit/health.test.ts +30 -0
  10. package/src/workflow-kit/health.ts +86 -0
  11. package/src/workflow-kit/index.ts +7 -0
  12. package/src/workflow-kit/quality.test.ts +17 -0
  13. package/src/workflow-kit/quality.ts +45 -0
  14. package/src/workflow-kit/review.ts +324 -0
  15. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  16. package/src/workflows/feature/feature.test.ts +38 -0
  17. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  18. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  19. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  20. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  21. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  22. package/src/workflows/feature/review.ts +106 -0
  23. package/src/workflows/feature/steps.test.ts +82 -0
  24. package/src/workflows/feature/steps.ts +92 -0
  25. package/src/workflows/fix/fix.test.ts +94 -0
  26. package/src/workflows/fix/fix.ts +52 -0
  27. package/src/workflows/index.ts +44 -2
  28. package/src/workflows/review/review.test.ts +43 -0
  29. package/src/workflows/review/review.ts +31 -0
  30. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  31. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  32. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  33. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  34. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  35. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  36. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  37. package/src/workflows/shared/scenarios.ts +35 -0
  38. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  39. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  40. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  41. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  42. package/src/workflows/shared/tail.test.ts +205 -0
  43. package/src/workflows/shared/tail.ts +135 -0
  44. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  45. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  46. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  47. package/src/workflows/review.ts +0 -362
  48. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  49. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  50. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "23.1.0",
3
+ "version": "24.0.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  "dist/",
24
24
  "src/flows/",
25
25
  "src/workflows/",
26
+ "src/workflow-kit/",
26
27
  "README.md",
27
28
  "LICENSE",
28
29
  ".claude-plugin/plugin.json",
@@ -145,10 +145,11 @@ the pending landing — call it right after the step whose turn you reject.
145
145
  process waits (the bundled one is `human("idle", …)`).
146
146
  - `gtd --workflow <name>` starts a process on the exported workflow `<name>` (an
147
147
  unknown name is a usage error); `gtd door <name> [args]` starts one through a
148
- `doors` entry `{ workflow, args?, vars? }` (`gtd doors` lists them; bundled:
149
- `fix`, `review [base]`; yours merge over them). An
150
- `export const base = (workflow, vars) => commitish | undefined` fixes a
151
- started process's diff base (blank = default-branch merge-base).
148
+ `doors` entry `{ workflow, args?, vars? }` (`gtd doors` lists them). A
149
+ factory's build may return `doors` without `workflow`; exporting the bundled
150
+ `fix` and `review` brings `fix` and `review [base]`, and yours merge over
151
+ them. An `export const base = (workflow, vars) => commitish | undefined` fixes
152
+ a started process's diff base (blank = default-branch merge-base).
152
153
  `--var <name>=<value>` only pins process settings: names the workflow's
153
154
  `defaults` declare — never an environment setting.
154
155
 
@@ -1,5 +1,5 @@
1
1
  import type { StandardSchemaV1 } from "@standard-schema/spec"
2
- import type { Flow } from "./runtime.js"
2
+ import type { Flow, WorkflowBase, WorkflowDoors } from "./runtime.js"
3
3
 
4
4
  // Registered, so a factory made by any copy of this file is recognised by the
5
5
  // engine's copy — the same reason the replay context is.
@@ -11,6 +11,10 @@ export interface WorkflowBuild {
11
11
  readonly defaults?: Readonly<Record<string, string>> | undefined
12
12
  /** Environment settings this workflow declares, over its file's `envDefaults` export. */
13
13
  readonly envDefaults?: Readonly<Record<string, string>> | undefined
14
+ /** Doors onto this workflow. Two workflows building the same door is a load error unless the config's `doors` export declares that name; a declared door always wins. */
15
+ readonly doors?: WorkflowDoors | undefined
16
+ /** This workflow's diff base; the config's `base` export wins when it answers, `undefined` falls through to this. */
17
+ readonly base?: WorkflowBase | undefined
14
18
  }
15
19
 
16
20
  interface FactoryInternals {
@@ -434,3 +434,6 @@ export interface Door {
434
434
 
435
435
  /** A workflow module's reserved `doors` export. */
436
436
  export type Doors = Readonly<Record<string, Door>>
437
+
438
+ /** Doors a workflow factory declares on itself: each starts whatever name the config exports the workflow under. */
439
+ export type WorkflowDoors = Readonly<Record<string, Omit<Door, "workflow">>>
@@ -0,0 +1,30 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { escalation, untilSettled } from "./health.js"
3
+
4
+ describe("escalation", () => {
5
+ it("describes and stops until `limit` rounds are spent, then reports exhaustion", async () => {
6
+ const log: string[] = []
7
+ const steps = {
8
+ limit: 2,
9
+ describe: async () => void log.push("describe"),
10
+ stop: async () => void log.push("stop"),
11
+ exhausted: async () => void log.push("exhausted"),
12
+ }
13
+ const count = { rounds: 0 }
14
+ for (let i = 0; i < 3; i++) await escalation(count, steps)
15
+ expect(log).toEqual(["describe", "stop", "describe", "stop", "exhausted"])
16
+ expect(count.rounds).toBe(2)
17
+ })
18
+ })
19
+
20
+ describe("untilSettled", () => {
21
+ it("reruns while pending and resolves the first run that counts", async () => {
22
+ const results = [false, false, true]
23
+ let pending = 2
24
+ const green = await untilSettled(
25
+ async () => results.shift()!,
26
+ () => pending-- > 0,
27
+ )
28
+ expect(green).toBe(true)
29
+ })
30
+ })
@@ -0,0 +1,86 @@
1
+ import { read, scope } from "../flows/index.js"
2
+
3
+ /** How many escalation rounds a run of red checks has spent — reset once the suite goes green. */
4
+ export interface EscalationCount {
5
+ rounds: number
6
+ }
7
+
8
+ export interface EscalationSteps {
9
+ /** Rounds a person narrows before `exhausted` replaces them. */
10
+ readonly limit: number
11
+ readonly describe: () => Promise<void>
12
+ readonly stop: () => Promise<void>
13
+ readonly exhausted: () => Promise<void>
14
+ }
15
+
16
+ /** Hand a red suite that repeated attempts could not fix to a person, until `limit` rounds are spent. */
17
+ export const escalation = async (count: EscalationCount, steps: EscalationSteps): Promise<void> => {
18
+ if (count.rounds >= steps.limit) return steps.exhausted()
19
+ count.rounds++
20
+ await steps.describe()
21
+ await steps.stop()
22
+ }
23
+
24
+ /** Run `check` again while `pending()` holds — a run then does not count — and resolve the first run that does. */
25
+ export const untilSettled = async (
26
+ check: () => Promise<boolean>,
27
+ pending: () => boolean,
28
+ ): Promise<boolean> => {
29
+ for (;;) {
30
+ const green = await check()
31
+ if (!pending()) return green
32
+ }
33
+ }
34
+
35
+ export interface Health {
36
+ /** One counted run of the suite; resolves `true` when green. */
37
+ readonly check: () => Promise<boolean>
38
+ /** Where `check` leaves a red run's output. */
39
+ readonly report: string
40
+ readonly fixCap: number
41
+ readonly fixesSoFar?: number
42
+ /** Whether two consecutive red reports are the same failure; `true` escalates at once. */
43
+ readonly sameFailure: (previous: string, current: string) => Promise<boolean>
44
+ /** Shared with other callers in the same run of red checks. */
45
+ readonly escalations: EscalationCount
46
+ readonly escalation: EscalationSteps
47
+ }
48
+
49
+ // check() stamps the report with the commit it ran at, so equal failures differ there.
50
+ const STAMP = /\n<!-- gtd check [0-9a-f]+ -->\n?$/
51
+ const stripStamp = (text: string): string => text.replace(STAMP, "\n")
52
+
53
+ /**
54
+ * Loop until the suite is green: check, and on red run `fix` — escalating
55
+ * first once `fixCap` fixes are spent, or `sameFailure` calls the failure
56
+ * identical to the previous round's.
57
+ */
58
+ export const healthy = async (fix: () => Promise<void>, health: Health): Promise<void> => {
59
+ let fixes = health.fixesSoFar ?? 0
60
+ let previous: string | undefined
61
+ for (;;) {
62
+ if (await health.check()) {
63
+ health.escalations.rounds = 0
64
+ return
65
+ }
66
+ const current = stripStamp(read(health.report) ?? "")
67
+ const stuck = previous !== undefined && (await health.sameFailure(previous, current))
68
+ previous = current
69
+ if (stuck || fixes >= health.fixCap) {
70
+ await escalation(health.escalations, health.escalation)
71
+ fixes = 0
72
+ }
73
+ fixes++
74
+ await fix()
75
+ }
76
+ }
77
+
78
+ /** Hold the process in scope `name` until `passes` resolves `true`, resting at `blocked` between tries. */
79
+ export const gate = (
80
+ name: string,
81
+ passes: () => Promise<boolean>,
82
+ blocked: () => Promise<void>,
83
+ ): Promise<void> =>
84
+ scope(name, async () => {
85
+ while (!(await passes())) await blocked()
86
+ })
@@ -0,0 +1,7 @@
1
+ // Building blocks the shipped workflows share. Policy-free: every skill list,
2
+ // lens, prompt, step, path and threshold arrives as a parameter, so a new
3
+ // workflow composes these without inheriting the shipped one's choices.
4
+ export * from "./health.js"
5
+ export * from "./quality.js"
6
+ export * from "./scenarios.js"
7
+ export * from "./review.js"
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { lensesOf, lensScopes } from "./quality.js"
3
+
4
+ describe("lensesOf", () => {
5
+ it("splits on commas, trims, drops blanks", () => {
6
+ expect(lensesOf(" a ,, b,")).toEqual(["a", "b"])
7
+ })
8
+ })
9
+
10
+ describe("lensScopes", () => {
11
+ it("keys one entry per lens under the given scope", () => {
12
+ expect(lensScopes("x.quality", ["a", "b"], (lens) => [lens])).toEqual({
13
+ "x.quality.a": ["a"],
14
+ "x.quality.b": ["b"],
15
+ })
16
+ })
17
+ })
@@ -0,0 +1,45 @@
1
+ import { read } from "../flows/index.js"
2
+
3
+ /** Split a comma-separated lens list: trimmed, blanks dropped. */
4
+ export const lensesOf = (value: string): readonly string[] =>
5
+ value
6
+ .split(",")
7
+ .map((lens) => lens.trim())
8
+ .filter((lens) => lens.length > 0)
9
+
10
+ /** One `<scope>.<lens>` entry per lens — the per-lens part of a skills or access export. */
11
+ export const lensScopes = <T>(
12
+ scope: string,
13
+ lenses: readonly string[],
14
+ entry: (lens: string) => T,
15
+ ): Record<string, T> => Object.fromEntries(lenses.map((lens) => [`${scope}.${lens}`, entry(lens)]))
16
+
17
+ /** One `review` turn per lens, each appending its findings to `findings`; resolves `"findings"` when that file has any. */
18
+ export const qualityLap = async (
19
+ lenses: readonly string[],
20
+ review: (lens: string) => Promise<void>,
21
+ findings: string,
22
+ ): Promise<"clean" | "findings"> => {
23
+ for (const lens of lenses) await review(lens)
24
+ return (read(findings) ?? "").length > 0 ? "findings" : "clean"
25
+ }
26
+
27
+ export interface FindingsFix {
28
+ /** One fix turn; it removes `findings` once every finding is resolved. */
29
+ readonly fix: () => Promise<void>
30
+ readonly findings: string
31
+ readonly cap: number
32
+ /** Runs once `cap` turns left findings. */
33
+ readonly escalate: () => Promise<void>
34
+ }
35
+
36
+ /** Resolves `true` once `findings` is gone, `false` when `cap` turns escalated instead. */
37
+ export const fixFindings = async (f: FindingsFix): Promise<boolean> => {
38
+ for (let turns = 0; turns < f.cap; turns++) {
39
+ await f.fix()
40
+ // Not `changes()`: a wording gate after the fix would be the last step.
41
+ if (read(f.findings) === undefined) return true
42
+ }
43
+ await f.escalate()
44
+ return false
45
+ }
@@ -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
- }