@pmelab/gtd 64.3.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 (91) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +35 -25
  3. package/claude/hooks/assist.ts +9 -35
  4. package/claude/hooks/doors.ts +4 -4
  5. package/claude/hooks/drive.ts +10 -1
  6. package/claude/hooks/git.ts +56 -0
  7. package/claude/hooks/handoff.ts +11 -11
  8. package/claude/hooks/jobs.ts +8 -8
  9. package/claude/hooks/pr.ts +86 -26
  10. package/claude/hooks/register.tsx +35 -82
  11. package/claude/hooks/tools.ts +7 -20
  12. package/claude/hooks/wording.ts +3 -3
  13. package/dist/chunks/{flows-dnIE_qbe.mjs → flows-CmQplvh1.mjs} +146 -35
  14. package/dist/chunks/{steering-DEh_MS40.mjs → steering-CMrePUiY.mjs} +115 -17
  15. package/dist/chunks/{testkit-BUX-47TD.mjs → testkit-BJhAAVGt.mjs} +905 -99
  16. package/dist/chunks/{wire-CaPJ9Tme.mjs → wire-xTbzFc26.mjs} +12 -6
  17. package/dist/flows.mjs +1 -1
  18. package/dist/gtd.bundle.mjs +965 -163
  19. package/dist/gtd.core.mjs +150 -134
  20. package/dist/steering.mjs +1 -1
  21. package/dist/testkit/gherkin.mjs +1 -1
  22. package/dist/testkit.mjs +2 -2
  23. package/dist/web/main.css +1 -1
  24. package/dist/web/main.js +185 -18
  25. package/dist/wire.mjs +1 -1
  26. package/dist/workflow-kit.mjs +9 -3
  27. package/package.json +1 -1
  28. package/src/cli/Cli.ts +5 -5
  29. package/src/cli/History.test.ts +10 -8
  30. package/src/cli/Metrics.test.ts +2 -1
  31. package/src/cli/Program.test.ts +4 -2
  32. package/src/cli/Program.ts +105 -7
  33. package/src/edge/Edge.test.ts +37 -16
  34. package/src/edge/Edge.ts +380 -33
  35. package/src/emit/GitScript.ts +116 -15
  36. package/src/emit/index.ts +7 -0
  37. package/src/flows/config.ts +7 -1
  38. package/src/flows/helpers.ts +36 -18
  39. package/src/flows/index.test.ts +4 -0
  40. package/src/flows/index.ts +7 -0
  41. package/src/flows/runtime.ts +153 -15
  42. package/src/install/Install.ts +9 -2
  43. package/src/platform/Git.test.ts +7 -11
  44. package/src/platform/NodeSync.ts +2 -0
  45. package/src/platform/RetainedHistory.ts +3 -0
  46. package/src/platform/SyncIo.ts +2 -0
  47. package/src/platform/Workspace.test.ts +37 -0
  48. package/src/platform/Workspace.ts +36 -0
  49. package/src/platform/index.ts +1 -0
  50. package/src/replay/Replay.test.ts +9 -0
  51. package/src/replay/Replay.ts +92 -16
  52. package/src/replay/Trailers.ts +17 -0
  53. package/src/replay/Tree.ts +5 -0
  54. package/src/replay/index.ts +1 -1
  55. package/src/steering/FORMATS.md +3 -1
  56. package/src/steering/SteeringFormat.ts +13 -0
  57. package/src/steering/fixtures/review/sectioned.md +1 -1
  58. package/src/steering/fixtures/review/sectioned.read.json +0 -1
  59. package/src/steering/index.ts +1 -0
  60. package/src/steering/review.test.ts +181 -5
  61. package/src/steering/review.ts +160 -18
  62. package/src/step/LandStep.ts +12 -0
  63. package/src/step/RepoSnapshot.ts +15 -0
  64. package/src/step/index.ts +1 -0
  65. package/src/step/planStart.test.ts +4 -3
  66. package/src/step/planStart.ts +7 -0
  67. package/src/step/planStep.ts +22 -17
  68. package/src/step/startBranch.ts +54 -0
  69. package/src/twins/EmittedScriptRecognizer.ts +118 -1
  70. package/src/twins/EmittedScripts.test.ts +147 -1
  71. package/src/twins/InMemRepo.ts +140 -3
  72. package/src/twins/Layers.ts +1 -0
  73. package/src/twins/SyncIo.test.ts +19 -4
  74. package/src/twins/SyncIo.ts +13 -1
  75. package/src/wire/BeatDocument.test.ts +15 -0
  76. package/src/wire/BeatDocument.ts +8 -2
  77. package/src/wire/Demand.ts +26 -12
  78. package/src/wire/constants.ts +3 -0
  79. package/src/wire/golden/1/beat-capture.json +1 -0
  80. package/src/wire/golden/1/beat-message.json +1 -0
  81. package/src/wire/golden/1/beat-prompt.json +1 -0
  82. package/src/wire/golden/1/beat-script.json +1 -0
  83. package/src/wire/golden/1/beat-stalled.json +1 -0
  84. package/src/wire/types.ts +4 -0
  85. package/src/workflow/Workflow.ts +3 -0
  86. package/src/workflow/compile.ts +2 -0
  87. package/src/workflow/load.ts +2 -0
  88. package/src/workflow-kit/index.test.ts +1 -0
  89. package/src/workflow-kit/index.ts +1 -0
  90. package/src/workflow-kit/scenarios.ts +8 -0
  91. package/claude/hooks/ship.ts +0 -730
@@ -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,
@@ -25,9 +26,8 @@ import type { Judgment } from "./judge"
25
26
  import { catchFrom, throwTo } from "./handoff"
26
27
  import { jobs } from "./jobs"
27
28
  import { documents, readDocument } from "./schema"
28
- import { READ_ONLY_GIT, ship } from "./ship"
29
29
  import { withRequirements } from "./sketch"
30
- import type { Shipped, ShipIo } from "./ship"
30
+ import type { Done, GitIo } from "./git"
31
31
  import { collectAnswer } from "./delegate"
32
32
  import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
33
33
  import type { JobName } from "./tools"
@@ -72,7 +72,7 @@ let isDriving = false
72
72
  // bumped by every stop, so a queued continue that was mid-check sees it
73
73
  let stopCount = 0
74
74
  // subagents answering into a file: what each may touch (see tool.call)
75
- const delegates = new Map<string, { file: string; mayRun: RegExp | undefined }>()
75
+ const delegates = new Map<string, { file: string }>()
76
76
  const ours = new Set<string>()
77
77
  // What each turn agent may read and write; reset on every spawn and resume,
78
78
  // since the steering file can differ per step.
@@ -218,7 +218,7 @@ function io($: $): Io {
218
218
  // held it): start one, as the sh driver falls back to `--session-id`.
219
219
  const spawned = await $.agent.spawn({
220
220
  subagentType: await persona($, t),
221
- prompt: t.prompt,
221
+ prompt: t.fresh ?? t.prompt,
222
222
  description: t.label || "gtd",
223
223
  model: await workerModel($, t.model),
224
224
  cwd: root,
@@ -295,8 +295,7 @@ async function wake($: $) {
295
295
  const r = await read($, run)
296
296
  const stop = r.stop
297
297
  if (!stop || !r.rest) return
298
- const branch = stop.kind === "done" ? await shippable($) : undefined
299
- if (!wakes(stop, branch)) return
298
+ if (!wakes(stop)) return
300
299
  const questions = stop.isJudge ? await openQuestions($) : undefined
301
300
  const text = wakePrompt({
302
301
  stop,
@@ -304,7 +303,6 @@ async function wake($: $) {
304
303
  notes: r.notes,
305
304
  isRepeat: r.isRepeat,
306
305
  questions,
307
- branch,
308
306
  })
309
307
  await update($, run, (x) => ({ ...x, notes: undefined }))
310
308
  // Not from inside the hook that started the run: a prompt submitted there
@@ -321,16 +319,6 @@ async function openQuestions($: $) {
321
319
  }
322
320
  }
323
321
 
324
- // The branch a finished process can open its pull request from, if any.
325
- async function shippable($: $) {
326
- const branch = (
327
- await $.process.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], { cwd: root })
328
- ).stdout.trim()
329
- if (["main", "master", "HEAD"].includes(branch)) return undefined
330
- const summary = await $.process.run(["gtd", "summary"], { cwd: root })
331
- return summary.exitCode === 0 ? branch : undefined
332
- }
333
-
334
322
  // A new process starts from the requirements written to the steering file an
335
323
  // idle rest names; the opening beat captures them like a hand-edit.
336
324
  async function begin($: $, requirements: string) {
@@ -440,8 +428,8 @@ function startJob(
440
428
  $: $,
441
429
  job: JobName,
442
430
  isInTurn: boolean,
443
- work: () => Promise<Shipped>,
444
- then?: (outcome: Shipped) => Promise<void>,
431
+ work: () => Promise<Done>,
432
+ then?: (outcome: Done) => Promise<void>,
445
433
  ) {
446
434
  const answer = background.start(job, work, true, async (outcome) => {
447
435
  if (then) await then(outcome)
@@ -457,7 +445,7 @@ function startJob(
457
445
  }
458
446
 
459
447
  async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
460
- const refused = decideJob(await read($, run), "handoff")
448
+ const refused = decideJob(await read($, run))
461
449
  if (refused) return refused
462
450
  return startJob(
463
451
  $,
@@ -474,7 +462,7 @@ async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
474
462
  content: b.content,
475
463
  isIdle: isTrue(b.idle),
476
464
  }
477
- return throwTo(shipIo($), rest, target, new Date().toISOString())
465
+ return throwTo(gitIo($), rest, target, new Date().toISOString())
478
466
  },
479
467
  async (thrown) => {
480
468
  if (!thrown.ok) return
@@ -490,7 +478,7 @@ async function throwNow($: $, target: string | undefined, isInTurn: boolean) {
490
478
 
491
479
  async function enterNow($: $, door: string, args: string[]) {
492
480
  if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
493
- const entered = await enter(shipIo($), door, args)
481
+ const entered = await enter(gitIo($), door, args)
494
482
  if (entered.ok) await start($)
495
483
  return entered.text
496
484
  }
@@ -511,7 +499,7 @@ async function prNow($: $) {
511
499
  if (r.pr && !isReviewGate(r.stop)) {
512
500
  return "Review threads from an earlier /gtd pr wait for the review's first review gate."
513
501
  }
514
- const io = shipIo($)
502
+ const io = gitIo($)
515
503
  const pr = await currentPr(io)
516
504
  if (typeof pr === "string") return pr
517
505
  const threads = await unresolvedThreads(io, pr)
@@ -585,11 +573,13 @@ async function prRest($: $, stop: Stop) {
585
573
  async function replyThreads($: $, stop: Stop) {
586
574
  const pr = (await read($, run)).pr
587
575
  if (!pr?.importedAt) return
588
- const io = shipIo($)
576
+ const io = gitIo($)
577
+ const { tip } = await processTip(io)
589
578
  const found = await decided(
590
579
  io,
591
580
  pr.importedAt,
592
581
  pr.threads.map((t) => t.id),
582
+ tip,
593
583
  )
594
584
  if (found === "gone") {
595
585
  $.ui.log("gtd pr: the history moved since the review threads were added; they get no replies.")
@@ -605,12 +595,24 @@ async function replyThreads($: $, stop: Stop) {
605
595
  $.ui.status("replying to the review threads…")
606
596
  let replied
607
597
  try {
608
- replied = await reply(io, pr.host, found.gate, found.answers)
598
+ replied = await reply(io, pr.host, found.gate, found.answers, stop.kind === "done")
609
599
  } finally {
610
600
  $.ui.status(undefined)
611
601
  }
612
- for (const line of replied.text.split("\n")) $.ui.log(line)
613
- $.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
+ }
614
616
  if (replied.isPushed) await forgetPr($)
615
617
  }
616
618
 
@@ -618,7 +620,7 @@ async function replyThreads($: $, stop: Stop) {
618
620
  // shown to the catcher first, never landed unseen.
619
621
  async function catchNow($: $, ref: string) {
620
622
  if ((await read($, run)).isRunning) return "Stop the loop first: /gtd stop."
621
- const caught = await catchFrom(shipIo($), ref)
623
+ const caught = await catchFrom(gitIo($), ref)
622
624
  if (!caught.ok) return caught.text
623
625
  await findRoot($)
624
626
  const b = await docs($).next()
@@ -636,27 +638,11 @@ async function catchNow($: $, ref: string) {
636
638
  return caught.text
637
639
  }
638
640
 
639
- // Ship's text turns need git to read the process, so they run as a subagent.
640
- // Its answer is a file, not its hand-back: a hand-back never reaches a mod.
641
- // `mayRun`: the only shell commands the subagent may run; none when absent.
642
641
  type Delegate = {
643
642
  name: string
644
643
  description: string
645
644
  system: string
646
645
  tools: string[]
647
- mayRun?: RegExp
648
- }
649
-
650
- const SHIPPER: Delegate = {
651
- name: "shipper",
652
- description:
653
- "Writes commit messages and pull requests for gtd's ship, spawned by the gtd mod only",
654
- system:
655
- "You write commit messages and pull-request descriptions for gtd's ship command. Read the repository with git and the file tools as the instructions ask. Never modify the repository: no commits, no checkouts, no pushes.",
656
- tools: ["Bash", "Read", "Grep", "Glob", "Write", "Skill"],
657
- // It reads commit messages anyone on the branch wrote: read-only git, and
658
- // no shell syntax to chain anything else onto it.
659
- mayRun: READ_ONLY_GIT,
660
646
  }
661
647
 
662
648
  // gtd's llm judge persona; writing its answer file is its one tool.
@@ -693,7 +679,7 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
693
679
  const id = spawned.agentId
694
680
  if (!id) return undefined
695
681
  ours.add(id)
696
- delegates.set(id, { file, mayRun: who.mayRun })
682
+ delegates.set(id, { file })
697
683
  // A timed-out agent may still be running: it keeps its tool limits until its
698
684
  // turn ends, where turn.complete drops them.
699
685
  return collectAnswer({
@@ -704,11 +690,6 @@ async function delegate($: $, who: Delegate, prompt: string, model: string, deli
704
690
  })
705
691
  }
706
692
 
707
- async function writer($: $, prompt: string) {
708
- const model = (await $.env.get("GTD_PLANNERMODEL")) ?? "opus"
709
- return delegate($, SHIPPER, prompt, model, "Your final answer, exactly as you would print it.")
710
- }
711
-
712
693
  // Where `gtd judge run` would pick its llm, a judge gate is put to a small
713
694
  // model as that provider does, but as a subagent of this session. Any doubt leaves the
714
695
  // gate to the person, which routes the workflow its cautious way.
@@ -729,39 +710,19 @@ async function judgeInSession($: $, j: Judgment) {
729
710
  }
730
711
  }
731
712
 
732
- function shipIo($: $): ShipIo {
713
+ function gitIo($: $): GitIo {
733
714
  return {
734
715
  run: async (argv, stdin) => {
735
716
  const r = await $.process.run(argv, { cwd: root, stdin, timeoutMs: TEN_MINUTES })
736
717
  return { code: r.exitCode, out: r.stdout, err: r.stderr }
737
718
  },
738
- complete: (prompt) => writer($, prompt),
739
719
  log: (line) => {
740
720
  for (const l of line.split("\n")) $.ui.log(l)
741
721
  if (background.busy()) $.ui.status(line.split("\n")[0]!)
742
722
  },
743
- today: () => new Date().toISOString().slice(0, 10),
744
723
  }
745
724
  }
746
725
 
747
- async function shipNow($: $, isDry: boolean, isInTurn: boolean) {
748
- const refused = decideJob(await read($, run), "ship")
749
- if (refused) return refused
750
- return startJob(
751
- $,
752
- "ship",
753
- isInTurn,
754
- async () => {
755
- $.ui.status(isDry ? "previewing the pull request…" : "opening the pull request…")
756
- return ship(shipIo($), isDry)
757
- },
758
- async (shipped) => {
759
- // a failed ship leaves the run as it was; a dry run changes nothing
760
- if (shipped.ok && !isDry) await dismiss($)
761
- },
762
- )
763
- }
764
-
765
726
  // Turns a missing CLI or a wrong directory into an instruction, not a stack trace.
766
727
  // The plugin is gtd's own npm package, so it runs the gtd it ships and the mod
767
728
  // and the CLI never disagree. A checkout without a build falls back to PATH.
@@ -986,8 +947,6 @@ async function command($: $, arg: string): Promise<string | undefined> {
986
947
  return rest[0]
987
948
  ? catchNow($, rest[0])
988
949
  : "Name the pull request or branch: /gtd catch <number|branch>."
989
- case "ship":
990
- return shipNow($, rest[0] === "-n" || rest[0] === "--dry-run", false)
991
950
  case "":
992
951
  return resume($)
993
952
  default:
@@ -997,7 +956,7 @@ async function command($: $, arg: string): Promise<string | undefined> {
997
956
 
998
957
  // A door wins over the bare `pr` verb; anything else is requirements.
999
958
  async function routed($: $, verb: string, rest: string[], arg: string) {
1000
- if (doorFor(await doors(shipIo($)), verb, rest)) return enterNow($, verb, rest)
959
+ if (doorFor(await doors(gitIo($)), verb, rest)) return enterNow($, verb, rest)
1001
960
  return verb === "pr" && rest.length === 0 ? prNow($) : begin($, arg)
1002
961
  }
1003
962
 
@@ -1067,12 +1026,9 @@ function subagentDenial(agentId: string, e: Record<string, unknown>) {
1067
1026
  if (denial) return denial
1068
1027
  const delegated = delegates.get(agentId)
1069
1028
  if (!delegated) return undefined
1070
- if (e.tool === "Write" && e.file_path !== delegated.file) {
1029
+ if ((e.tool === "Write" && e.file_path !== delegated.file) || e.tool === "Bash") {
1071
1030
  return `Write only your answer, to ${delegated.file}.`
1072
1031
  }
1073
- if (e.tool === "Bash" && !delegated.mayRun?.test(String(e.command).trim())) {
1074
- return "Only read-only git commands, without shell syntax, are allowed here."
1075
- }
1076
1032
  return undefined
1077
1033
  }
1078
1034
 
@@ -1111,7 +1067,7 @@ export const register: Register = (on) => {
1111
1067
  name: "gtd",
1112
1068
  description: "Drive gtd until it rests on you; pass requirements to start a new process",
1113
1069
  argumentHint:
1114
- "[requirements | <door> [args] | stop | status | ui | ship [-n] | pr | throw [@user] | catch <pr|branch>]",
1070
+ "[requirements | <door> [args] | stop | status | ui | pr | throw [@user] | catch <pr|branch>]",
1115
1071
  immediate: true,
1116
1072
  })
1117
1073
  return next(e)
@@ -1191,9 +1147,6 @@ export const register: Register = (on) => {
1191
1147
  on("tool.call", { tool: "mcp__gtd__handoff" }, async ($, e) =>
1192
1148
  answer(await throwNow($, typeof e.to === "string" && e.to ? e.to : undefined, true), true),
1193
1149
  )
1194
- on("tool.call", { tool: "mcp__gtd__ship" }, async ($, e) =>
1195
- answer(await shipNow($, e.dryRun === true, true), true),
1196
- )
1197
1150
  on("tool.call", { tool: "mcp__gtd__browser" }, async ($) => answer(await browse($), true))
1198
1151
 
1199
1152
  on("agent.offer", ($, e, next) => (e.agent.startsWith("gtd:") ? { isOffered: false } : next(e)))
@@ -10,7 +10,6 @@ export const TOOL = {
10
10
  continue: "mcp__gtd__continue",
11
11
  note: "mcp__gtd__note",
12
12
  handoff: "mcp__gtd__handoff",
13
- ship: "mcp__gtd__ship",
14
13
  browser: "mcp__gtd__browser",
15
14
  } as const
16
15
 
@@ -44,12 +43,6 @@ export const TOOLS = [
44
43
  "Hands the gtd process to someone else: pushes the branch and opens or refreshes a draft pull request assigned to them. Call it only when the person asks. `to`: their GitHub username; omit it to leave the process for anyone.",
45
44
  inputSchema: { type: "object", properties: { to: { type: "string" } } },
46
45
  },
47
- {
48
- name: "ship",
49
- description:
50
- "Combines the finished gtd process into one commit, pushes it and opens or updates its pull request. Call it only after the person agreed. `dryRun` previews without writing anything.",
51
- inputSchema: { type: "object", properties: { dryRun: { type: "boolean" } } },
52
- },
53
46
  {
54
47
  name: "browser",
55
48
  description:
@@ -71,21 +64,21 @@ export type ContinueIo = {
71
64
  // the rest's own `gtd validate` script, run; ok when there is nothing to check
72
65
  validate(): Promise<{ ok: boolean; out: string }>
73
66
  judgment(): Promise<Judgment | undefined>
74
- // a ship or hand-off running or queued: it writes the tree the driver would
67
+ // a hand-off running or queued: it writes the tree the driver would
75
68
  busy?: JobName
76
69
  }
77
70
 
78
- export type JobName = "ship" | "handoff"
71
+ export type JobName = "handoff"
79
72
 
80
- export const JOB_NAME: Record<JobName, string> = { ship: "ship", handoff: "hand-off" }
73
+ export const JOB_NAME: Record<JobName, string> = { handoff: "hand-off" }
81
74
 
82
75
  const busyText = (job: JobName) => `A ${JOB_NAME[job]} is still running: wait for its report.`
83
76
 
84
- // Only these leave the tree alone (ship and throw refuse for themselves); any
77
+ // Only these leave the tree alone (throw refuses for itself); any
85
78
  // other verb, unknown ones included, may switch branch or write .gtd/. `stop`
86
79
  // is not here: `command` takes the bare word itself, and `stop <words>` is a
87
80
  // requirement. `ui` is not: its page writes steering files.
88
- const WHILE_BUSY = new Set(["status", "throw", "ship"])
81
+ const WHILE_BUSY = new Set(["status", "throw"])
89
82
 
90
83
  export function decideCommand(arg: string, busy: JobName | undefined): string | undefined {
91
84
  if (!busy) return undefined
@@ -94,13 +87,9 @@ export function decideCommand(arg: string, busy: JobName | undefined): string |
94
87
  }
95
88
 
96
89
  // Pure: a refused job leaves the run, and so `continue`, as it was.
97
- export function decideJob(run: Run, job: JobName): string | undefined {
90
+ export function decideJob(run: Run): string | undefined {
98
91
  if (run.isRunning) return "gtd is running a step: wait for it to rest."
99
92
  if (run.pending) return "A continue is already waiting for this turn to end."
100
- const kind = run.stop?.kind
101
- if (job === "ship" && (kind === "gate" || kind === "stalled" || kind === "error")) {
102
- return "gtd still waits on the person at this step: answer it with continue first."
103
- }
104
93
  return undefined
105
94
  }
106
95
 
@@ -116,9 +105,7 @@ export async function decideContinue(
116
105
  if (io.busy) return { refuse: busyText(io.busy) }
117
106
  const stop = run.stop
118
107
  if (!stop) return { refuse: "Nothing is waiting: gtd has no rest open. /gtd drives it." }
119
- if (stop.kind === "done") {
120
- return { refuse: `The process is finished: ${TOOL.ship} opens its pull request.` }
121
- }
108
+ if (stop.kind === "done") return { refuse: "The process is finished: nothing waits here." }
122
109
  if (rest !== undefined && rest !== run.rest) {
123
110
  return {
124
111
  refuse: "gtd has moved on from that step; it no longer waits there. Nothing was changed.",
@@ -2,7 +2,7 @@
2
2
 
3
3
  import type { Stop } from "../types"
4
4
  import type { Beat } from "./drive"
5
- import type { Shipped } from "./ship"
5
+ import type { Done } from "./git"
6
6
  import { JOB_NAME } from "./tools"
7
7
  import type { JobName } from "./tools"
8
8
 
@@ -61,8 +61,8 @@ export function pushText(stop: Stop, repo: string, url?: string) {
61
61
 
62
62
  const capitalised = (s: string) => s[0]!.toUpperCase() + s.slice(1)
63
63
 
64
- export const jobHeadline = (job: JobName, outcome: Shipped) =>
64
+ export const jobHeadline = (job: JobName, outcome: Done) =>
65
65
  `${outcome.ok ? "✔" : "✘"} ${capitalised(JOB_NAME[job])} ${outcome.ok ? "finished" : "failed"}`
66
66
 
67
- export const jobPushText = (job: JobName, outcome: Shipped, repo: string) =>
67
+ export const jobPushText = (job: JobName, outcome: Done, repo: string) =>
68
68
  `${repo}: ${JOB_NAME[job]} ${outcome.ok ? "finished" : "failed"}. ${outcome.text}`.trim()
@@ -37,29 +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
+ };
126
+ /**
127
+ * Squashes HEAD's tree, with `.gtd/` as the user's branch has it, into one commit on the user's branch
128
+ * with `message`, and moves that branch there; HEAD stays on `gtd/<b>`.
129
+ * Resolves to the new commit's hash, or `undefined` when nothing shipped.
130
+ */
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
+ };
63
140
  /** A judge gate: resolves to what the judge answered, for the flow to decide on. */
64
141
  const judge = (name, spec) => {
65
142
  const { questions, evidence, ...options } = spec;
@@ -73,10 +150,16 @@ const judge = (name, spec) => {
73
150
  questions: canonical,
74
151
  evidence,
75
152
  options
153
+ }).then((judgment) => {
154
+ noteValidatorPaths();
155
+ return judgment;
76
156
  });
77
157
  };
78
158
  /** End the episode from any depth. The next episode starts the flow afresh. */
79
- const restart = () => ctx().step({ kind: "restart" });
159
+ const restart = () => {
160
+ noValidatorStep("restart", "restart");
161
+ return ctx().step({ kind: "restart" });
162
+ };
80
163
  /** Refuse the pending turn: nothing lands, the process stays where it rests. */
81
164
  const refuse = (message) => ctx().refuse(message);
82
165
  /**
@@ -348,13 +431,18 @@ const check = async (name, command, report, options = {}) => {
348
431
  const isCode = (path) => path !== ".gtd" && !path.startsWith(".gtd/");
349
432
  /** The last step's changes outside `.gtd/`. */
350
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
+ };
351
442
  /** Refuse a turn whose only change deletes `file` — unless it says it found nothing to do. */
352
443
  const requireProgress = (file) => {
353
- const change = changes().get(file);
354
- if (change?.status !== "deleted") return;
355
- if (codeChanges().length > 0) return;
356
- if ((change.before ?? "").trim().startsWith("NOTHING ACTIONABLE")) return;
357
- 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}`);
358
446
  };
359
447
  /** Refuse a turn that leaves a question in `file` unanswered. An untouched tree is silence, and allowed. */
360
448
  const requireAnswers = (file) => {
@@ -377,18 +465,32 @@ const requireThreadsClosed = (file) => {
377
465
  if (open.length === 0) return;
378
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")}`);
379
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
+ };
380
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. */
381
475
  const requireReplies = (file) => {
382
- const open = [...threadLines(file, "agent"), ...codeThreadLines("agent")];
383
- if (open.length === 0) return;
384
- 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}`);
385
478
  };
386
- /** Refuse a turn that did not undo the code edits `edited` recorded, back to their content before `base`. */
387
- const requireRevert = (edited, base) => {
479
+ const revertFix = (edited, base) => {
388
480
  const residue = edited.filter((c) => read(c.path) !== c.before).map((c) => c.path);
389
- if (residue.length === 0) return;
481
+ if (residue.length === 0) return void 0;
390
482
  const quoted = residue.map((path) => `'${path.replace(/'/g, "'\\''")}'`).join(" ");
391
- 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.`);
392
494
  };
393
495
  const SHAPES = [
394
496
  "land",
@@ -551,7 +653,8 @@ const CONFIG_KEYS = [
551
653
  "modes",
552
654
  "ui",
553
655
  "skills",
554
- "access"
656
+ "access",
657
+ "historyRemote"
555
658
  ];
556
659
  const MODE_KEYS = ["format", "validate"];
557
660
  const UI_KEYS = [
@@ -622,6 +725,10 @@ const configProblems = (value) => {
622
725
  ...unknownKeys(value, CONFIG_KEYS, []),
623
726
  ...value.modes === void 0 ? [] : recordOf(value.modes, ["modes"], "modes", modeProblems),
624
727
  ...value.ui === void 0 ? [] : uiProblems(value.ui, ["ui"]),
728
+ ...value.historyRemote === void 0 || typeof value.historyRemote === "string" && value.historyRemote.trim() !== "" ? [] : [{
729
+ path: ["historyRemote"],
730
+ message: "must be a non-empty remote name"
731
+ }],
625
732
  ...value.skills === void 0 ? [] : recordOf(value.skills, ["skills"], "skill-name arrays", (v, path) => isStringArray(v) ? [] : [{
626
733
  path,
627
734
  message: "must be an array of skill names"
@@ -671,10 +778,12 @@ var flows_exports = /* @__PURE__ */ __exportAll({
671
778
  moveScript: () => moveScript,
672
779
  numeric: () => numeric,
673
780
  openQuestions: () => openQuestions,
781
+ progressErrors: () => progressErrors,
674
782
  quote: () => quote,
675
783
  read: () => read,
676
784
  refuse: () => refuse,
677
785
  removeScript: () => removeScript,
786
+ repliesErrors: () => repliesErrors,
678
787
  requireAnswers: () => requireAnswers,
679
788
  requireProgress: () => requireProgress,
680
789
  requireReplies: () => requireReplies,
@@ -682,6 +791,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
682
791
  requireThreadsClosed: () => requireThreadsClosed,
683
792
  restart: () => restart,
684
793
  restoreScript: () => restoreScript,
794
+ revertErrors: () => revertErrors,
685
795
  revertScript: () => revertScript,
686
796
  run: () => run,
687
797
  scope: () => scope,
@@ -690,6 +800,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
690
800
  sections: () => sections,
691
801
  settings: () => settings,
692
802
  settingsOf: () => settingsOf,
803
+ ship: () => ship,
693
804
  showScript: () => showScript,
694
805
  skillsFor: () => skillsFor,
695
806
  start: () => start,
@@ -698,4 +809,4 @@ var flows_exports = /* @__PURE__ */ __exportAll({
698
809
  writeScript: () => writeScript,
699
810
  wrote: () => wrote
700
811
  });
701
- 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, skillsFor as at, wrote as b, check as c, workflow 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, requireAnswers as m, configProblems as n, sections as nt, isFactoryWorkflow as o, start as ot, numeric as p, matches as q, defineConfig as r, settings as rt, answered as s, threads 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 };