@pmelab/gtd 65.0.0 → 66.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 (63) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +3 -2
  3. package/claude/hooks/drive.ts +10 -1
  4. package/claude/hooks/git.ts +2 -2
  5. package/claude/hooks/pr.ts +80 -20
  6. package/claude/hooks/register.tsx +19 -4
  7. package/dist/chunks/{flows-DGs0W3HI.mjs → flows-CmQplvh1.mjs} +134 -40
  8. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  9. package/dist/chunks/{testkit-CgrLmpdJ.mjs → testkit-BJhAAVGt.mjs} +179 -34
  10. package/dist/chunks/{wire-BNS8ciNu.mjs → wire-xTbzFc26.mjs} +7 -3
  11. package/dist/flows.mjs +1 -1
  12. package/dist/gtd.bundle.mjs +430 -100
  13. package/dist/gtd.core.mjs +147 -134
  14. package/dist/steering.mjs +1 -1
  15. package/dist/testkit/gherkin.mjs +1 -1
  16. package/dist/testkit.mjs +2 -2
  17. package/dist/web/main.css +1 -1
  18. package/dist/web/main.js +185 -18
  19. package/dist/wire.mjs +1 -1
  20. package/dist/workflow-kit.mjs +9 -3
  21. package/package.json +1 -1
  22. package/src/cli/Cli.ts +1 -1
  23. package/src/cli/Program.ts +18 -2
  24. package/src/edge/Edge.ts +93 -11
  25. package/src/flows/helpers.ts +36 -18
  26. package/src/flows/index.test.ts +3 -0
  27. package/src/flows/index.ts +5 -0
  28. package/src/flows/runtime.ts +123 -16
  29. package/src/install/Install.ts +9 -2
  30. package/src/platform/Git.test.ts +7 -11
  31. package/src/platform/NodeSync.ts +2 -0
  32. package/src/platform/SyncIo.ts +2 -0
  33. package/src/platform/Workspace.test.ts +37 -0
  34. package/src/platform/Workspace.ts +36 -0
  35. package/src/replay/Replay.ts +33 -1
  36. package/src/replay/Trailers.ts +3 -0
  37. package/src/replay/Tree.ts +5 -0
  38. package/src/steering/FORMATS.md +3 -1
  39. package/src/steering/SteeringFormat.ts +13 -0
  40. package/src/steering/fixtures/review/sectioned.md +1 -1
  41. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  42. package/src/steering/index.ts +1 -0
  43. package/src/steering/review.test.ts +181 -5
  44. package/src/steering/review.ts +160 -18
  45. package/src/step/RepoSnapshot.ts +4 -0
  46. package/src/step/planStep.ts +6 -6
  47. package/src/twins/EmittedScriptRecognizer.ts +1 -1
  48. package/src/twins/InMemRepo.ts +24 -3
  49. package/src/twins/Layers.ts +1 -0
  50. package/src/twins/SyncIo.test.ts +19 -4
  51. package/src/twins/SyncIo.ts +13 -1
  52. package/src/wire/BeatDocument.test.ts +15 -0
  53. package/src/wire/BeatDocument.ts +8 -2
  54. package/src/wire/Demand.ts +4 -2
  55. package/src/wire/golden/1/beat-capture.json +1 -0
  56. package/src/wire/golden/1/beat-message.json +1 -0
  57. package/src/wire/golden/1/beat-prompt.json +1 -0
  58. package/src/wire/golden/1/beat-script.json +1 -0
  59. package/src/wire/golden/1/beat-stalled.json +1 -0
  60. package/src/workflow/Workflow.ts +2 -0
  61. package/src/workflow-kit/index.test.ts +1 -0
  62. package/src/workflow-kit/index.ts +1 -0
  63. package/src/workflow-kit/scenarios.ts +8 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "65.0.0",
3
+ "version": "66.0.0",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -501,8 +501,9 @@ and gtd moves `<b>` only when the workflow calls `ship()`, which squashes the
501
501
  process onto `<b>` as one commit (see
502
502
  [Shipping](docs/configuration.md#shipping)); what it committed after its last
503
503
  ship stays on `gtd/<b>`. `gtd abandon` switches back to `<b>` with the unshipped
504
- work uncommitted and deletes `gtd/<b>`; `gtd restore` recreates it. The closing
505
- commit records `Gtd-Branch: <b>`.
504
+ work uncommitted and deletes `gtd/<b>`; `gtd restore` recreates it, also when
505
+ `<b>` still sits on the abandoned ship commit. The closing commit records
506
+ `Gtd-Branch: <b>`.
506
507
 
507
508
  When a process finishes, the landing that closes it also keeps the whole run
508
509
  behind a ref, `refs/gtd/history/<entry>`, so a squash or a deleted branch
@@ -19,6 +19,7 @@ export type Beat = {
19
19
  system?: string
20
20
  skills?: string[]
21
21
  validate?: string
22
+ fresh?: string | null
22
23
  access?: AccessDef
23
24
  judge?: unknown
24
25
  changes?: { status: string; path: string }[]
@@ -36,6 +37,8 @@ export type Turn = {
36
37
  memory: string
37
38
  resume: boolean
38
39
  prompt: string
40
+ /** A validator retry: what a replacement subagent starts from — the original prompt plus the correction. */
41
+ fresh?: string
39
42
  label?: string
40
43
  model?: string
41
44
  system?: string
@@ -163,7 +166,12 @@ async function followUp(io: Io, b: Beat, text: string): Promise<TurnEnd> {
163
166
  const turn = turnOf(b)
164
167
  const r = await io.resume(turn.memory, text)
165
168
  if (r.ok || !r.isLost) return r
166
- return io.turn({ ...turn, resume: false, prompt: `${turn.prompt}\n\n${LOST}\n\n${text}` })
169
+ const { fresh, ...rest } = turn
170
+ return io.turn({
171
+ ...rest,
172
+ resume: false,
173
+ prompt: `${fresh ?? turn.prompt}\n\n${LOST}\n\n${text}`,
174
+ })
167
175
  }
168
176
 
169
177
  const LOST =
@@ -177,6 +185,7 @@ function turnOf(b: Beat): Turn {
177
185
  memory,
178
186
  resume: isTrue(b.session?.resume),
179
187
  prompt: b.content ?? "",
188
+ ...(b.fresh ? { fresh: b.fresh } : {}),
180
189
  label: b.label,
181
190
  model: b.model || undefined,
182
191
  system: b.system || undefined,
@@ -29,7 +29,7 @@ export async function defaultBranch(git: (...args: string[]) => Promise<string>)
29
29
  // needs --force-with-lease on an existing origin/<branch>. `fresh`: origin
30
30
  // lacks the branch, so any tracking ref is stale. Returns why the push failed,
31
31
  // or nothing.
32
- export async function pushBranch(io: GitIo, branch: string, fresh = false) {
32
+ export async function pushBranch(io: GitIo, branch: string, fresh = false, head = "HEAD") {
33
33
  const ok = async (...args: string[]) => (await io.run(["git", ...args])).code === 0
34
34
  const push = async (args: string[], failure: string) => {
35
35
  const r = await attempt(io, ["git", "push", ...args])
@@ -42,7 +42,7 @@ export async function pushBranch(io: GitIo, branch: string, fresh = false) {
42
42
  io.log(`Pushing ${branch} to origin…`)
43
43
  return push(["--set-upstream", "origin", branch], "git push failed.")
44
44
  }
45
- if (!(await ok("merge-base", "--is-ancestor", "HEAD", `origin/${branch}`))) {
45
+ if (!(await ok("merge-base", "--is-ancestor", head, `origin/${branch}`))) {
46
46
  io.log(`Pushing ${branch} to origin…`)
47
47
  return push(
48
48
  ["--force-with-lease", "origin", branch],
@@ -29,9 +29,24 @@ const PLACEHOLDER = "_your answer_"
29
29
 
30
30
  const fail = (text: string): Done => ({ ok: false, text })
31
31
 
32
+ const GTD_BRANCH = "gtd/"
33
+
34
+ // The pull request belongs to `<b>`; a process runs on `gtd/<b>`.
35
+ const userBranchOf = (branch: string) =>
36
+ branch.startsWith(GTD_BRANCH) ? branch.slice(GTD_BRANCH.length) : branch
37
+
32
38
  export async function currentPr(io: GitIo): Promise<Pr | string> {
33
- const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
34
- const v = await io.run(["gh", "pr", "view", "--json", "number,url"])
39
+ const head = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
40
+ const branch = userBranchOf(head)
41
+ // `gtd/<b>` has no pull request of its own.
42
+ const v = await io.run([
43
+ "gh",
44
+ "pr",
45
+ "view",
46
+ ...(branch === head ? [] : [branch]),
47
+ "--json",
48
+ "number,url",
49
+ ])
35
50
  if (v.code !== 0) return `gh pr view failed: ${v.err.trim()}`
36
51
  try {
37
52
  const { url } = JSON.parse(v.out) as { url: string }
@@ -226,17 +241,16 @@ export function missingReasons(review: string) {
226
241
 
227
242
  // The first commit since `since` whose review answers any of `ids`: the gate
228
243
  // that decided them. Undefined while none has landed.
229
- export async function decided(io: GitIo, since: string, ids: readonly string[]) {
244
+ export async function decided(io: GitIo, since: string, ids: readonly string[], tip = "HEAD") {
230
245
  // A reset or rebase since the import leaves nothing to find the gate in.
231
- if ((await io.run(["git", "merge-base", "--is-ancestor", since, "HEAD"])).code !== 0)
232
- return "gone"
246
+ if ((await io.run(["git", "merge-base", "--is-ancestor", since, tip])).code !== 0) return "gone"
233
247
  const log = await io.run([
234
248
  "git",
235
249
  "log",
236
250
  "--reverse",
237
251
  "--first-parent",
238
252
  "--format=%H",
239
- `${since}..HEAD`,
253
+ `${since}..${tip}`,
240
254
  ])
241
255
  if (log.code !== 0) return "gone"
242
256
  for (const commit of log.out.split("\n").filter(Boolean)) {
@@ -255,7 +269,7 @@ export type PrRest = { isReviewGate: boolean; isDone: boolean; branch: string }
255
269
  // gate drops them.
256
270
  export function prAction(pr: PrFeedback | undefined, at: PrRest) {
257
271
  if (!pr) return "none"
258
- if (at.branch !== pr.branch) return "forget"
272
+ if (userBranchOf(at.branch) !== pr.branch) return "forget"
259
273
  if (pr.importedAt === undefined) return at.isReviewGate ? "import" : at.isDone ? "forget" : "none"
260
274
  return at.isReviewGate || at.isDone ? "reply" : "none"
261
275
  }
@@ -265,7 +279,7 @@ export function prAction(pr: PrFeedback | undefined, at: PrRest) {
265
279
  export function replyText(a: Answer, fix: string | undefined) {
266
280
  if (a.choice === "wontfix") return a.text ? `Won't fix: ${a.text}` : "Won't fix."
267
281
  if (a.choice === "answer") return a.text
268
- return fix ? `Fixed in ${fix}.` : "Fixed as suggested; the fix changed no code."
282
+ return fix ? `Fixed in ${fix}.` : "Fixed as suggested."
269
283
  }
270
284
 
271
285
  const REPLY = `mutation($thread: ID!, $body: String!) {
@@ -275,32 +289,77 @@ const RESOLVE = `mutation($thread: ID!) {
275
289
  resolveReviewThread(input: { threadId: $thread }) { thread { isResolved } }
276
290
  }`
277
291
 
278
- // Pushes the branch so the commits a reply names exist on GitHub, then replies
279
- // to each answered thread and resolves it. The code commits after `gate` are
280
- // the fixes; a thread that fails is named and left open.
292
+ export type ProcessTip = { branch: string; tip: string; isShipped: boolean }
293
+
294
+ // Where the process's commits are: HEAD on `gtd/<b>`, `gtd/<b>` once HEAD is
295
+ // back on `<b>`. Without a `gtd/<b>` the process ran on `<b>` itself.
296
+ export async function processTip(io: GitIo): Promise<ProcessTip> {
297
+ const head = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
298
+ const branch = userBranchOf(head)
299
+ if (branch !== head) return { branch, tip: "HEAD", isShipped: true }
300
+ const ref = `refs/heads/${GTD_BRANCH}${branch}`
301
+ const has = (await io.run(["git", "rev-parse", "--verify", "--quiet", ref])).code === 0
302
+ return { branch, tip: has ? ref : "HEAD", isShipped: has }
303
+ }
304
+
305
+ // The oldest ship on `<b>` whose process tip holds `fix`.
306
+ async function shipOf(io: GitIo, branch: string, fix: string) {
307
+ const ships = await io.run([
308
+ "git",
309
+ "log",
310
+ "--reverse",
311
+ "--first-parent",
312
+ "--grep=^Gtd-Tip:",
313
+ "--format=%H",
314
+ `refs/heads/${branch}`,
315
+ ])
316
+ for (const ship of ships.out.split("\n").filter(Boolean)) {
317
+ const body = await io.run(["git", "show", "-s", "--format=%B", ship])
318
+ const tip = /^Gtd-Tip: ([0-9a-f]+)$/m.exec(body.out)?.[1]
319
+ if (tip && (await io.run(["git", "merge-base", "--is-ancestor", fix, tip])).code === 0)
320
+ return ship
321
+ }
322
+ return undefined
323
+ }
324
+
325
+ // Replies to each answered thread and resolves it. A process on `gtd/<b>` ships
326
+ // its fixes to `<b>`: a "fixed" reply waits for the ship carrying the fix, then
327
+ // pushes `<b>` and names that commit; a finished process without one replies
328
+ // without a link. A thread that fails is named and left open.
281
329
  export async function reply(
282
330
  io: GitIo,
283
331
  host: string,
284
332
  gate: string,
285
333
  decidedAnswers: readonly Answer[],
286
- ): Promise<Done & { isPushed: boolean }> {
287
- const branch = (await io.run(["git", "rev-parse", "--abbrev-ref", "HEAD"])).out.trim()
288
- const pushed = await pushBranch(io, branch)
289
- if (pushed) return { ...fail(pushed), isPushed: false }
334
+ isDone = true,
335
+ ): Promise<Done & { isPushed: boolean; waiting: Answer[] }> {
336
+ const { branch, tip, isShipped } = await processTip(io)
290
337
  const log = await io.run([
291
338
  "git",
292
339
  "log",
293
340
  "--reverse",
294
341
  "--first-parent",
295
342
  "--format=%H",
296
- `${gate}..HEAD`,
343
+ `${gate}..${tip}`,
297
344
  "--",
298
345
  ".",
299
346
  ":(exclude).gtd",
300
347
  ])
301
- const fix = log.out.split("\n").find(Boolean)
348
+ const code = log.out.split("\n").find(Boolean)
349
+ const ship = isShipped && code ? await shipOf(io, branch, code) : undefined
350
+ const fix = isShipped ? ship : code
351
+ const waiting = decidedAnswers.filter(
352
+ (a) => isShipped && !isDone && a.choice === "fix" && code !== undefined && !ship,
353
+ )
354
+ const ready = decidedAnswers.filter((a) => !waiting.includes(a))
355
+ if (ready.length === 0) return { ok: true, text: "", isPushed: false, waiting }
356
+ // Only a reply naming a commit needs it on GitHub.
357
+ if (fix !== undefined && ready.some((a) => a.choice === "fix")) {
358
+ const pushed = await pushBranch(io, branch, false, isShipped ? `refs/heads/${branch}` : "HEAD")
359
+ if (pushed) return { ...fail(pushed), isPushed: false, waiting }
360
+ }
302
361
  const failed: string[] = []
303
- for (const a of decidedAnswers) {
362
+ for (const a of ready) {
304
363
  const sent = await io.run([
305
364
  "gh",
306
365
  "api",
@@ -331,12 +390,13 @@ export async function reply(
331
390
  ).code === 0
332
391
  if (!resolved) failed.push(a.title)
333
392
  }
334
- const done = decidedAnswers.length - failed.length
393
+ const done = ready.length - failed.length
335
394
  const text = `Replied to and resolved ${done} review thread(s).`
336
395
  return failed.length === 0
337
- ? { ok: true, text, isPushed: true }
396
+ ? { ok: true, text, isPushed: true, waiting }
338
397
  : {
339
398
  ...fail(`${text}\nThese failed and stay open:\n${failed.map((t) => `- ${t}`).join("\n")}`),
340
399
  isPushed: true,
400
+ waiting,
341
401
  }
342
402
  }
@@ -15,6 +15,7 @@ import {
15
15
  decided,
16
16
  missingReasons,
17
17
  reply,
18
+ processTip,
18
19
  REVIEW_FILE,
19
20
  unresolvedThreads,
20
21
  withThreads,
@@ -217,7 +218,7 @@ function io($: $): Io {
217
218
  // held it): start one, as the sh driver falls back to `--session-id`.
218
219
  const spawned = await $.agent.spawn({
219
220
  subagentType: await persona($, t),
220
- prompt: t.prompt,
221
+ prompt: t.fresh ?? t.prompt,
221
222
  description: t.label || "gtd",
222
223
  model: await workerModel($, t.model),
223
224
  cwd: root,
@@ -573,10 +574,12 @@ async function replyThreads($: $, stop: Stop) {
573
574
  const pr = (await read($, run)).pr
574
575
  if (!pr?.importedAt) return
575
576
  const io = gitIo($)
577
+ const { tip } = await processTip(io)
576
578
  const found = await decided(
577
579
  io,
578
580
  pr.importedAt,
579
581
  pr.threads.map((t) => t.id),
582
+ tip,
580
583
  )
581
584
  if (found === "gone") {
582
585
  $.ui.log("gtd pr: the history moved since the review threads were added; they get no replies.")
@@ -592,12 +595,24 @@ async function replyThreads($: $, stop: Stop) {
592
595
  $.ui.status("replying to the review threads…")
593
596
  let replied
594
597
  try {
595
- replied = await reply(io, pr.host, found.gate, found.answers)
598
+ replied = await reply(io, pr.host, found.gate, found.answers, stop.kind === "done")
596
599
  } finally {
597
600
  $.ui.status(undefined)
598
601
  }
599
- for (const line of replied.text.split("\n")) $.ui.log(line)
600
- $.ui.toast(replied.text.split("\n")[0]!)
602
+ if (replied.text) {
603
+ for (const line of replied.text.split("\n")) $.ui.log(line)
604
+ $.ui.toast(replied.text.split("\n")[0]!)
605
+ }
606
+ if (replied.waiting.length > 0) {
607
+ // Unshipped fixes keep their threads; answered ones are not answered twice.
608
+ if (replied.isPushed) {
609
+ const keep = new Set(replied.waiting.map((a) => a.thread))
610
+ await update($, run, (x) =>
611
+ x.pr ? { ...x, pr: { ...x.pr, threads: x.pr.threads.filter((t) => keep.has(t.id)) } } : x,
612
+ )
613
+ }
614
+ return
615
+ }
601
616
  if (replied.isPushed) await forgetPr($)
602
617
  }
603
618
 
@@ -37,40 +37,106 @@ const ctx = () => {
37
37
  }
38
38
  return context;
39
39
  };
40
+ const validatingBy = /* @__PURE__ */ new WeakMap();
41
+ const DEFAULT_CAP = 3;
42
+ const noValidatorStep = (kind, name) => {
43
+ const validating = validatingBy.get(ctx());
44
+ if (validating !== void 0) throw new Error(`gtd: step "${name}": ${kind}() cannot run inside the validator of "${validating.name}" — a validator may only run check, run and judge steps`);
45
+ };
46
+ const noteValidatorPaths = () => {
47
+ const validating = validatingBy.get(ctx());
48
+ if (validating !== void 0) for (const c of ctx().changes()) validating.paths.add(c.path);
49
+ };
50
+ const errorList = (result) => result === void 0 ? [] : typeof result === "string" ? [result] : result;
40
51
  /** An agent turn: `prompt` is emitted, the driver lands the diff. */
41
- const agent = (name, prompt, options = {}) => ctx().step({
42
- kind: "agent",
43
- name,
44
- prompt,
45
- options
46
- });
52
+ const agent = async (name, prompt, options = {}) => {
53
+ noValidatorStep("agent", name);
54
+ const context = ctx();
55
+ const { validate, cap = DEFAULT_CAP, exhausted, correction } = options;
56
+ const base = typeof validate === "function" ? context.head() : "";
57
+ await context.step({
58
+ kind: "agent",
59
+ name,
60
+ prompt,
61
+ options
62
+ });
63
+ if (typeof validate !== "function") return;
64
+ const own = /* @__PURE__ */ new Set();
65
+ const agentPaths = /* @__PURE__ */ new Set();
66
+ let validated = base;
67
+ const validateNow = async () => {
68
+ for (const c of context.changesSince(validated)) agentPaths.add(c.path);
69
+ const turn = { get changes() {
70
+ return toChanges(context.changesSince(base).filter((c) => !own.has(c.path) || agentPaths.has(c.path)));
71
+ } };
72
+ validatingBy.set(context, {
73
+ name,
74
+ paths: own
75
+ });
76
+ try {
77
+ return errorList(await scope(`${name}.validate`, () => validate(turn)));
78
+ } finally {
79
+ validatingBy.delete(context);
80
+ validated = context.head();
81
+ }
82
+ };
83
+ let errors = await validateNow();
84
+ let retries = 0;
85
+ while (errors.length > 0) {
86
+ if (retries >= cap) {
87
+ if (exhausted !== void 0) await exhausted(errors);
88
+ else await human(`${name}.invalid`, errors.join("\n"), { acceptClean: true });
89
+ retries = 0;
90
+ } else {
91
+ retries++;
92
+ const text = correction !== void 0 ? correction(errors) : `The result did not pass validation. Fix these errors:\n\n${errors.join("\n")}\n`;
93
+ await context.step({
94
+ kind: "agent",
95
+ name,
96
+ prompt: text,
97
+ options,
98
+ original: prompt
99
+ });
100
+ }
101
+ errors = await validateNow();
102
+ }
103
+ };
47
104
  /** A human gate: the process rests until a person lands a turn. */
48
- const human = (name, messageOrOptions, options) => ctx().step({
49
- kind: "human",
50
- name,
51
- options: typeof messageOrOptions === "string" ? {
52
- ...options,
53
- message: messageOrOptions
54
- } : messageOrOptions ?? {}
55
- });
105
+ const human = (name, messageOrOptions, options) => {
106
+ noValidatorStep("human", name);
107
+ return ctx().step({
108
+ kind: "human",
109
+ name,
110
+ options: typeof messageOrOptions === "string" ? {
111
+ ...options,
112
+ message: messageOrOptions
113
+ } : messageOrOptions ?? {}
114
+ });
115
+ };
56
116
  /** A check: `body` runs at the edge and its effect on the tree is committed. */
57
- const run = (name, body, options = {}) => ctx().step({
58
- kind: "run",
59
- name,
60
- body,
61
- options
62
- });
117
+ const run = async (name, body, options = {}) => {
118
+ await ctx().step({
119
+ kind: "run",
120
+ name,
121
+ body,
122
+ options
123
+ });
124
+ noteValidatorPaths();
125
+ };
63
126
  /**
64
127
  * Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
65
128
  * with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
66
129
  * Resolves to the new commit's hash, or `undefined` when nothing shipped.
67
130
  */
68
- const ship = (name, message, options = {}) => ctx().step({
69
- kind: "ship",
70
- name,
71
- message,
72
- options
73
- });
131
+ const ship = (name, message, options = {}) => {
132
+ noValidatorStep("ship", name);
133
+ return ctx().step({
134
+ kind: "ship",
135
+ name,
136
+ message,
137
+ options
138
+ });
139
+ };
74
140
  /** A judge gate: resolves to what the judge answered, for the flow to decide on. */
75
141
  const judge = (name, spec) => {
76
142
  const { questions, evidence, ...options } = spec;
@@ -84,10 +150,16 @@ const judge = (name, spec) => {
84
150
  questions: canonical,
85
151
  evidence,
86
152
  options
153
+ }).then((judgment) => {
154
+ noteValidatorPaths();
155
+ return judgment;
87
156
  });
88
157
  };
89
158
  /** End the episode from any depth. The next episode starts the flow afresh. */
90
- const restart = () => ctx().step({ kind: "restart" });
159
+ const restart = () => {
160
+ noValidatorStep("restart", "restart");
161
+ return ctx().step({ kind: "restart" });
162
+ };
91
163
  /** Refuse the pending turn: nothing lands, the process stays where it rests. */
92
164
  const refuse = (message) => ctx().refuse(message);
93
165
  /**
@@ -359,13 +431,18 @@ const check = async (name, command, report, options = {}) => {
359
431
  const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
360
432
  /** The last step's changes outside `.gtd/`. */
361
433
  const codeChanges = () => changes().filter((c) => isCode(c.path));
434
+ /** Validator form of `requireProgress`: the agent-addressed error, or `undefined` when the turn is valid. */
435
+ const progressErrors = (file, turnChanges) => {
436
+ const change = turnChanges.get(file);
437
+ if (change?.status !== "deleted") return void 0;
438
+ if (turnChanges.some((c) => isCode(c.path))) return void 0;
439
+ if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return void 0;
440
+ return `${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file. Its content was:\n${change.before ?? ""}`;
441
+ };
362
442
  /** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
363
443
  const requireProgress = (file) => {
364
- const change = changes().get(file);
365
- if (change?.status !== "deleted") return;
366
- if (codeChanges().length > 0) return;
367
- if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return;
368
- refuse(`gtd land: feedback-progress: ${file} was deleted without addressing its instructions — implement the changes it lists (then delete it), don't just remove the file.`);
444
+ const error = progressErrors(file, changes());
445
+ if (error !== void 0) refuse(`gtd land: feedback-progress: ${error}`);
369
446
  };
370
447
  /** Refuse a turn that leaves a question in `file` unanswered. An untouched tree is silence, and allowed. */
371
448
  const requireAnswers = (file) => {
@@ -388,18 +465,32 @@ const requireThreadsClosed = (file) => {
388
465
  if (open.length === 0) return;
389
466
  refuse(`gtd land: open-threads: ${countOf(open)} thread(s) in ${file} or code comments wait on you — reply with a conclusion, or delete the thread:\n${open.join("\n")}`);
390
467
  };
468
+ /** Validator form of `requireReplies`: the agent-addressed error, or `undefined` when every thread is answered. */
469
+ const repliesErrors = (file) => {
470
+ const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")];
471
+ if (open.length === 0) return void 0;
472
+ return `${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`;
473
+ };
391
474
  /** Refuse an agent turn that leaves a thread in `file`, or a code thread in a changed file, waiting on the agent (its last entry is `H:`) or with a syntax fault. */
392
475
  const requireReplies = (file) => {
393
- const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")];
394
- if (open.length === 0) return;
395
- refuse(`gtd land: unanswered-threads: ${countOf(open)} thread(s) in ${file} or code comments still wait on the agent — append one "- A:" reply to each (one "A:" comment line, same token and indentation, directly below a code thread's last line), or fold a concluded thread and delete it:\n${open.join("\n")}`);
476
+ const error = repliesErrors(file);
477
+ if (error !== void 0) refuse(`gtd land: unanswered-threads: ${error}`);
396
478
  };
397
- /** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
398
- const requireRevert = (edited, base) => {
479
+ const revertFix = (edited, base) => {
399
480
  const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path);
400
- if (residue.length === 0) return;
481
+ if (residue.length === 0) return void 0;
401
482
  const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
402
- refuse(`gtd land: require-revert: ${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\`, then \`gtd land\` again.`);
483
+ return `${residue.join(", ")} still differ from ${base}~1 — the revert did not take. Run \`git checkout ${base}~1 -- ${quoted}\``;
484
+ };
485
+ /** Validator form of `requireRevert`: reads the tree, not the turn's changes. */
486
+ const revertErrors = (edited, base) => {
487
+ const fix = revertFix(edited, base);
488
+ return fix === void 0 ? void 0 : `${fix}.`;
489
+ };
490
+ /** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
491
+ const requireRevert = (edited, base) => {
492
+ const fix = revertFix(edited, base);
493
+ if (fix !== void 0) refuse(`gtd land: require-revert: ${fix}, then \`gtd land\` again.`);
403
494
  };
404
495
  const SHAPES = [
405
496
  "land",
@@ -687,10 +778,12 @@ var flows_exports = /* @__PURE__ */ __exportAll({
687
778
  moveScript: () => moveScript,
688
779
  numeric: () => numeric,
689
780
  openQuestions: () => openQuestions,
781
+ progressErrors: () => progressErrors,
690
782
  quote: () => quote,
691
783
  read: () => read,
692
784
  refuse: () => refuse,
693
785
  removeScript: () => removeScript,
786
+ repliesErrors: () => repliesErrors,
694
787
  requireAnswers: () => requireAnswers,
695
788
  requireProgress: () => requireProgress,
696
789
  requireReplies: () => requireReplies,
@@ -698,6 +791,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
698
791
  requireThreadsClosed: () => requireThreadsClosed,
699
792
  restart: () => restart,
700
793
  restoreScript: () => restoreScript,
794
+ revertErrors: () => revertErrors,
701
795
  revertScript: () => revertScript,
702
796
  run: () => run,
703
797
  scope: () => scope,
@@ -715,4 +809,4 @@ var flows_exports = /* @__PURE__ */ __exportAll({
715
809
  writeScript: () => writeScript,
716
810
  wrote: () => wrote
717
811
  });
718
- export { run as $, FLOWS_PROTOCOL as A, head as B, moveScript as C, revertScript as D, restoreScript as E, changesSince as F, mark as G, installContext as H, codeThreads as I, openQuestions as J, markedOptions as K, envSettings as L, accessFor as M, agent as N, showScript as O, changes as P, restart as Q, envSettingsOf as R, checkScript as S, removeScript as T, judge as U, human as V, kept as W, read as X, protocolMismatch as Y, refuse as Z, requireRevert as _, defineWorkflow as a, ship as at, wrote as b, check as c, threads as ct, hasThreadFor as d, scope as et, judgeDiffShape as f, requireReplies as g, requireProgress as h, configure as i, settingsOf as it, FlowsProtocolError as j, writeScript as k, codeChanges as l, workflow as lt, requireAnswers as m, configProblems as n, sections as nt, isFactoryWorkflow as o, skillsFor as ot, numeric as p, matches as q, defineConfig as r, settings as rt, answered as s, start as st, flows_exports as t, sectionBodies as tt, displayPath as u, requireThreadsClosed as v, quote as w, applyScript as x, seamGate as y, glob as z };
812
+ export { read as $, revertScript as A, envSettings as B, wrote as C, quote as D, moveScript as E, accessFor as F, installContext as G, glob as H, agent as I, mark as J, judge as K, changes as L, writeScript as M, FLOWS_PROTOCOL as N, removeScript as O, FlowsProtocolError as P, protocolMismatch as Q, changesSince as R, seamGate as S, checkScript as T, head as U, envSettingsOf as V, human as W, matches as X, markedOptions as Y, openQuestions as Z, requireProgress as _, defineWorkflow as a, sections as at, requireThreadsClosed as b, check as c, ship as ct, hasThreadFor as d, threads as dt, refuse as et, judgeDiffShape as f, workflow as ft, requireAnswers as g, repliesErrors as h, configure as i, sectionBodies as it, showScript as j, restoreScript as k, codeChanges as l, skillsFor as lt, progressErrors as m, configProblems as n, run as nt, isFactoryWorkflow as o, settings as ot, numeric as p, kept as q, defineConfig as r, scope as rt, answered as s, settingsOf as st, flows_exports as t, restart as tt, displayPath as u, start as ut, requireReplies as v, applyScript as w, revertErrors as x, requireRevert as y, codeThreads as z };