@pmelab/gtd 47.0.0 → 48.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.
package/dist/testkit.mjs CHANGED
@@ -6154,8 +6154,15 @@ export CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1
6154
6154
  export BASH_DEFAULT_TIMEOUT_MS=1800000
6155
6155
  export BASH_MAX_TIMEOUT_MS=7200000
6156
6156
 
6157
+ # gtd's paths, scripts and checks are relative to the repository root.
6158
+ cd "$(git rev-parse --show-toplevel)"
6159
+
6157
6160
  beat=1
6158
6161
  while :; do
6162
+ cost=
6163
+ model=
6164
+ judged_before="\${judged:-}"
6165
+ judged=
6159
6166
  # One value per invocation: \`gtd next --json=<path>\` is a pure read (peek
6160
6167
  # or dispatch, indistinguishable, always safe to call again), so nothing is
6161
6168
  # lost by reading \`kind\`/\`idle\` as two separate calls rather than one
@@ -6183,14 +6190,22 @@ while :; do
6183
6190
  # \`gtd judge run\` and land the verdict via \`gtd judge answer\`. POSIX sh has
6184
6191
  # no pipefail, so each step is captured first and only piped on when it
6185
6192
  # succeeded; if \`gtd judge run\` fails or \`gtd judge answer\` rejects the
6186
- # verdict, its stderr reaches you and we fall through to the human hand-off.
6193
+ # verdict, its stderr reaches you and the gate is handed to you — on the
6194
+ # opening beat too, so it never lands unanswered — as it is when the gate
6195
+ # we just answered comes straight back.
6187
6196
  message)
6188
- if [ -n "$(gtd next --json=judge)" ] &&
6189
- verdict="$(gtd judge --json | gtd judge run)" &&
6190
- script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
6191
- printf '%s\\n' "$script" | sh
6192
- beat=$((beat + 1))
6193
- continue
6197
+ if [ -n "$(gtd next --json=judge)" ]; then
6198
+ state="$(gtd next --json=state)"
6199
+ if [ "$state" != "$judged_before" ] &&
6200
+ verdict="$(gtd judge --json | gtd judge run)" &&
6201
+ script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
6202
+ printf '%s\\n' "$script" | sh
6203
+ judged="$state"
6204
+ beat=$((beat + 1))
6205
+ continue
6206
+ fi
6207
+ gtd next
6208
+ exit 0
6194
6209
  fi
6195
6210
  [ "$beat" = 1 ] || {
6196
6211
  gtd next
@@ -6209,45 +6224,83 @@ while :; do
6209
6224
  model="$(gtd next --json=model)"
6210
6225
  system="$(gtd next --json=system)"
6211
6226
  validate="$(gtd next --json=validate)"
6227
+ context="$(gtd next --json=context)"
6212
6228
  log="$(gtd next --json=log)"
6229
+ # Absolute, since the hook runs wherever the agent last cd'ed to.
6230
+ case "$log" in /*) ;; *) log="$(git rev-parse --show-toplevel)/$log" ;; esac
6213
6231
  # \`model\` is a hint, not a model name — the bundled workflow ships
6214
6232
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
6215
- # with the variables \`gtd loop\` reads; unset, the hint is dropped and
6216
- # the agent runs its own default. Any other value is a real model name.
6233
+ # from your environment; unset, the hint is dropped and the agent runs
6234
+ # its own default. Any other value is a real model name.
6217
6235
  case "$model" in
6218
6236
  smart) model="\${GTD_LOOP_MODEL_SMART:-}" ;;
6219
6237
  base) model="\${GTD_LOOP_MODEL_BASE:-}" ;;
6220
6238
  esac
6221
- # The prompt embeds a full diff, so it goes to the agent over stdin,
6222
- # never as an argv positional — argv is capped (~1 MB on macOS, and
6223
- # POSIX guarantees only 4 KB, ARG_MAX), and a diff crosses that far
6224
- # sooner than you'd expect. Piping plain \`gtd next\` (not
6225
- # --json=content) means the diff is rendered once, for this input,
6226
- # rather than twice.
6227
- agent_turn() { gtd next | claude -p "$1" "$session_id" \\
6228
- \${model:+--model "$model"} \\
6229
- \${system:+--system-prompt "$system"} \\
6230
- --dangerously-skip-permissions >>"$log" 2>&1; }
6239
+ # The step's context limit, as a Claude Code hook after every tool
6240
+ # call and before compacting. Past \`hard\` it stops the turn and
6241
+ # appends why to the stop file, so we land nothing and stop too.
6242
+ soft="$(printf '%s' "$context" | sed -n 's/.*"soft":\\([0-9.]*\\).*/\\1/p')"
6243
+ hard="$(printf '%s' "$context" | sed -n 's/.*"hard":\\([0-9.]*\\).*/\\1/p')"
6244
+ window="\${GTD_LOOP_CONTEXT_WINDOW:-}"
6245
+ case "$model" in *'[1m]'* | *'[1M]'*) window="\${window:-1000000}" ;; esac
6246
+ hook="gtd context-hook\${soft:+ --soft $soft}\${hard:+ --hard $hard}\${window:+ --window $window}"
6247
+ hooks="[{\\"hooks\\":[{\\"type\\":\\"command\\",\\"command\\":\\"$hook\\"}]}]"
6248
+ settings="{\\"hooks\\":{\\"PostToolUse\\":$hooks,\\"PreCompact\\":$hooks}}"
6249
+ stop="$log.context-stop"
6250
+ rm -f "$stop"
6251
+ # One turn, its prompt on stdin. The prompt embeds a full diff, so it
6252
+ # never goes on argv — argv is capped (~1 MB on macOS, and POSIX
6253
+ # guarantees only 4 KB, ARG_MAX), and a diff crosses that far sooner
6254
+ # than you'd expect. Every turn passes the same $model/$system:
6255
+ # whether \`claude --resume\` re-applies the original session's is a
6256
+ # harness detail gtd cannot verify from outside. Its JSON envelope
6257
+ # goes to the log, and its \`total_cost_usd\` adds up into $cost. An
6258
+ # \`is_error\` envelope (max turns, an error mid-turn) or one another
6259
+ # hook stopped fails the turn even at exit 0: landing it would land
6260
+ # the failure as work. Only a turn that never ran (no envelope: the
6261
+ # session flag was refused) is worth retrying under the other flag.
6262
+ turn() {
6263
+ out="$(GTD_LOOP_CONTEXT_STOP_FILE="$stop" claude -p "$@" \\
6264
+ \${model:+--model "$model"} \\
6265
+ \${system:+--system-prompt "$system"} \\
6266
+ --settings "$settings" --output-format json \\
6267
+ --dangerously-skip-permissions 2>>"$log")" && ok=0 || ok=$?
6268
+ printf '%s\\n' "$out" >>"$log"
6269
+ case "$out" in *'"type":"result"'*) ran=true ;; *) ran=false ;; esac
6270
+ case "$out" in
6271
+ *'"is_error":true'* | *'"terminal_reason":"hook_stopped"'*) ok=1 ;;
6272
+ esac
6273
+ usd="$(printf '%s\\n' "$out" | sed -n 's/.*"total_cost_usd":\\([0-9.eE+-]*\\).*/\\1/p' | tail -n 1)"
6274
+ [ -z "$usd" ] || cost="$(awk -v a="\${cost:-0}" -v b="$usd" 'BEGIN { printf "%.6f", a + b }')"
6275
+ [ "\${cost:-}" != 0.000000 ] || cost=
6276
+ if [ -s "$stop" ]; then
6277
+ cat "$stop" >&2
6278
+ exit 1
6279
+ fi
6280
+ return "$ok"
6281
+ }
6282
+ # Plain \`gtd next\` (not --json=content) renders the diff once.
6283
+ gtd next >"$log.prompt"
6231
6284
  if [ "\${resume:-}" = true ]
6232
- then agent_turn --resume || agent_turn --session-id
6233
- else agent_turn --session-id || agent_turn --resume
6285
+ then first=--resume second=--session-id
6286
+ else first=--session-id second=--resume
6234
6287
  fi
6288
+ turn "$first" "$session_id" <"$log.prompt" ||
6289
+ { [ "$ran" = false ] && turn "$second" "$session_id" <"$log.prompt"; } || {
6290
+ echo "agent turn failed — see $log" >&2
6291
+ exit 1
6292
+ }
6235
6293
  n=0
6236
6294
  while [ -n "\${validate:-}" ] && ! fix="$(sh -c "$validate" 2>&1)"; do
6237
6295
  n=$((n + 1)) && [ "$n" -gt 3 ] && { printf '%s\\n' "$fix" >&2; exit 1; }
6238
- # $fix IS the fix prompt, verbatim — piped for the same reason the
6239
- # agent's own turn above is. Whether \`claude --resume\` re-applies the
6240
- # original session's model/system prompt is a harness detail gtd
6241
- # cannot verify from outside, so this passes the identical
6242
- # $model/$system on both calls rather than assume it does —
6243
- # otherwise this fix turn might silently fall back to Claude Code's
6244
- # own defaults while the turn that produced the file ran under the
6245
- # workflow's own model and persona.
6246
- printf '%s' "$fix" | claude -p --resume "$session_id" \\
6247
- \${model:+--model "$model"} \\
6248
- \${system:+--system-prompt "$system"} \\
6249
- --dangerously-skip-permissions >>"$log" 2>&1
6250
- done ;;
6296
+ # $fix IS the fix prompt, verbatim.
6297
+ printf '%s' "$fix" >"$log.prompt"
6298
+ turn --resume "$session_id" <"$log.prompt" || {
6299
+ echo "fix turn failed — see $log" >&2
6300
+ exit 1
6301
+ }
6302
+ done
6303
+ rm -f "$log.prompt" ;;
6251
6304
  esac
6252
6305
 
6253
6306
  # Read every value the landing needs BEFORE piping the script to \`sh\`:
@@ -6257,7 +6310,8 @@ while :; do
6257
6310
  # before the script runs and changes the tree out from under them.
6258
6311
  settled="$(gtd land --json=settled)"
6259
6312
  idle="$(gtd land --json=idle)"
6260
- gtd land --json=script | sh
6313
+ # A prompt beat's turns record their cost, tagged with the model that ran.
6314
+ gtd land --json=script \${cost:+--cost="$cost"} \${cost:+\${model:+--model="$model"}} | sh
6261
6315
  if [ "\${settled:-}" = true ]; then
6262
6316
  [ "\${idle:-}" = true ] && gtd next
6263
6317
  exit 0
@@ -10893,7 +10947,6 @@ const runInstallCommand = (out) => gen(function* () {
10893
10947
  yield* (yield* Workspace).write(WORKFLOW_MODULE, CONFIG_SCAFFOLD);
10894
10948
  yield* (yield* Narrator).warn(`gtd install: wrote ${WORKFLOW_MODULE}, importing gtd's shipped workflows — commit it before your first \`gtd land\``);
10895
10949
  });
10896
- const runLoopCommand = () => flatMap$4(ToolCommands, (tools) => tools.loop(selfGtd()));
10897
10950
  /**
10898
10951
  * Exactly one trailing newline — duplicated from `Cli.ts`'s own
10899
10952
  * `normalizeTrailingNewline` rather than imported, to avoid a circular value
@@ -11471,7 +11524,6 @@ const needsOf = (kind) => {
11471
11524
  switch (kind) {
11472
11525
  case "lsp":
11473
11526
  case "install":
11474
- case "loop":
11475
11527
  case "contextHook":
11476
11528
  case "judgeRun":
11477
11529
  case "strays": return "none";
@@ -11511,7 +11563,6 @@ const dispatchVoidCommand = (command, json, out) => {
11511
11563
  return runCheckCommand(command.mode, command.file, command.openQuestions ?? false, command.openThreads ?? false);
11512
11564
  case "uncheck": return runUncheckCommand(command.file);
11513
11565
  case "install": return runInstallCommand(out);
11514
- case "loop": return runLoopCommand();
11515
11566
  case "contextHook": return runContextHookCommand(command, out);
11516
11567
  case "summary": return runSummaryCommand(out, command.slice);
11517
11568
  case "base": return runBaseCommand(out);
@@ -12038,20 +12089,6 @@ const COMMAND_ROWS = [
12038
12089
  "else it writes nothing."
12039
12090
  ]
12040
12091
  },
12041
- {
12042
- token: "loop",
12043
- kind: "loop",
12044
- arity: "none",
12045
- details: [
12046
- "Drive this worktree's process until it rests on you: read",
12047
- "each beat, run the agent turn or script it demands, and",
12048
- "land the result. Exits 0 at a human gate, at idle and when",
12049
- "a landing settles; exits 1 on a stall or a failed turn or",
12050
- "landing. The agent CLI and the model each workflow hint",
12051
- "maps to are per-machine settings (GTD_LOOP_* variables, or",
12052
- "~/.config/gtd/loop.json); see docs/driver.md"
12053
- ]
12054
- },
12055
12092
  {
12056
12093
  token: "context-hook",
12057
12094
  kind: "contextHook",
@@ -13403,7 +13440,6 @@ const missingTool = (name) => () => fail$5(/* @__PURE__ */ new Error(`gtd ${name
13403
13440
  const missingToolCommands = {
13404
13441
  ui: missingTool("ui"),
13405
13442
  lsp: missingTool("lsp"),
13406
- loop: missingTool("loop"),
13407
13443
  contextHook: missingTool("context-hook"),
13408
13444
  judge: () => Promise.reject(/* @__PURE__ */ new Error("gtd judge run: ToolCommands has no test double wired into testLayers()"))
13409
13445
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "47.0.0",
3
+ "version": "48.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": {
@@ -1147,7 +1147,6 @@ describe("standaloneKinds / needsOf", () => {
1147
1147
  "check",
1148
1148
  "uncheck",
1149
1149
  "install",
1150
- "loop",
1151
1150
  "contextHook",
1152
1151
  "judgeRun",
1153
1152
  "strays",
@@ -1159,7 +1158,6 @@ describe("standaloneKinds / needsOf", () => {
1159
1158
  expect(needsOf("check")).toBe("fs")
1160
1159
  expect(needsOf("uncheck")).toBe("fs")
1161
1160
  expect(needsOf("install")).toBe("none")
1162
- expect(needsOf("loop")).toBe("none")
1163
1161
  expect(needsOf("contextHook")).toBe("none")
1164
1162
  expect(needsOf("judgeRun")).toBe("none")
1165
1163
  expect(needsOf("strays")).toBe("none")
package/src/cli/Cli.ts CHANGED
@@ -642,20 +642,6 @@ const COMMAND_ROWS: readonly CommandRow[] = [
642
642
  "else it writes nothing.",
643
643
  ],
644
644
  },
645
- {
646
- token: "loop",
647
- kind: "loop",
648
- arity: "none",
649
- details: [
650
- "Drive this worktree's process until it rests on you: read",
651
- "each beat, run the agent turn or script it demands, and",
652
- "land the result. Exits 0 at a human gate, at idle and when",
653
- "a landing settles; exits 1 on a stall or a failed turn or",
654
- "landing. The agent CLI and the model each workflow hint",
655
- "maps to are per-machine settings (GTD_LOOP_* variables, or",
656
- "~/.config/gtd/loop.json); see docs/driver.md",
657
- ],
658
- },
659
645
  {
660
646
  token: "context-hook",
661
647
  kind: "contextHook",
@@ -1528,7 +1514,6 @@ export const parseArgv = (argv: readonly string[]): CliPlan => {
1528
1514
  | "next"
1529
1515
  | "validate"
1530
1516
  | "install"
1531
- | "loop"
1532
1517
  | "doors"
1533
1518
  | "slices"
1534
1519
  | "base"
@@ -1685,7 +1685,6 @@ describe("runCommand — refuses in a repository with no commits", () => {
1685
1685
  check: { kind: "check", mode: "qa", file: ".gtd/TODO.md" },
1686
1686
  uncheck: { kind: "uncheck", file: ".gtd/REVIEW.md" },
1687
1687
  install: { kind: "install" },
1688
- loop: { kind: "loop" },
1689
1688
  contextHook: { kind: "contextHook" },
1690
1689
  summary: { kind: "summary" },
1691
1690
  base: { kind: "base" },
@@ -163,7 +163,6 @@ export type Command =
163
163
  }
164
164
  | { readonly kind: "uncheck"; readonly file: string }
165
165
  | { readonly kind: "install" }
166
- | { readonly kind: "loop" }
167
166
  | {
168
167
  readonly kind: "contextHook"
169
168
  readonly soft?: number
@@ -239,9 +238,6 @@ const runInstallCommand = (
239
238
  )
240
239
  })
241
240
 
242
- const runLoopCommand = (): Effect.Effect<void, Error, ToolCommands> =>
243
- Effect.flatMap(ToolCommands, (tools) => tools.loop(selfGtd()))
244
-
245
241
  /**
246
242
  * `planLanding`'s result — a preview of what `gtd land` would do, plus the
247
243
  * combined `script` the driver runs to do it. `script` is
@@ -1221,7 +1217,6 @@ export const needsOf = (kind: Command["kind"]): Needs => {
1221
1217
  switch (kind) {
1222
1218
  case "lsp":
1223
1219
  case "install":
1224
- case "loop":
1225
1220
  case "contextHook":
1226
1221
  case "judgeRun":
1227
1222
  case "strays":
@@ -1240,7 +1235,6 @@ export const standaloneKinds = (): readonly Command["kind"][] => [
1240
1235
  "check",
1241
1236
  "uncheck",
1242
1237
  "install",
1243
- "loop",
1244
1238
  "contextHook",
1245
1239
  "judgeRun",
1246
1240
  "strays",
@@ -1311,8 +1305,6 @@ const dispatchVoidCommand = (
1311
1305
  return runUncheckCommand(command.file)
1312
1306
  case "install":
1313
1307
  return runInstallCommand(out)
1314
- case "loop":
1315
- return runLoopCommand()
1316
1308
  case "contextHook":
1317
1309
  return runContextHookCommand(command, out)
1318
1310
  case "summary":
@@ -3,7 +3,7 @@ import type { Effect } from "effect"
3
3
  import type { UiSettings } from "../flows/index.js"
4
4
  import type { ContextHookRequest } from "../wire/index.js"
5
5
 
6
- /** How to run THIS gtd — a driver (the loop, the LSP) never reads a different version's state than the one it ships with. */
6
+ /** How to run THIS gtd — a tool (the LSP) never reads a different version's state than the one it ships with. */
7
7
  export type SelfGtd = readonly [string, ...string[]]
8
8
 
9
9
  export interface UiRequest {
@@ -38,7 +38,6 @@ export class ToolCommands extends Context.Tag("ToolCommands")<
38
38
  out: { readonly write: (chunk: string) => void; readonly flush: () => void },
39
39
  ) => Effect.Effect<void, Error>
40
40
  readonly lsp: (gtd: SelfGtd) => Effect.Effect<void, Error>
41
- readonly loop: (gtd: SelfGtd) => Effect.Effect<void, Error>
42
41
  /** The hook's stdout: a JSON document, or empty to let the turn go on. */
43
42
  readonly contextHook: (request: ContextHookRequest) => Effect.Effect<string, Error>
44
43
  readonly judge: (request: JudgeRequest) => Promise<string>
@@ -11,8 +11,15 @@ export CLAUDE_CODE_DISABLE_BACKGROUND_TASKS=1
11
11
  export BASH_DEFAULT_TIMEOUT_MS=1800000
12
12
  export BASH_MAX_TIMEOUT_MS=7200000
13
13
 
14
+ # gtd's paths, scripts and checks are relative to the repository root.
15
+ cd "$(git rev-parse --show-toplevel)"
16
+
14
17
  beat=1
15
18
  while :; do
19
+ cost=
20
+ model=
21
+ judged_before="\${judged:-}"
22
+ judged=
16
23
  # One value per invocation: \`gtd next --json=<path>\` is a pure read (peek
17
24
  # or dispatch, indistinguishable, always safe to call again), so nothing is
18
25
  # lost by reading \`kind\`/\`idle\` as two separate calls rather than one
@@ -40,14 +47,22 @@ while :; do
40
47
  # \`gtd judge run\` and land the verdict via \`gtd judge answer\`. POSIX sh has
41
48
  # no pipefail, so each step is captured first and only piped on when it
42
49
  # succeeded; if \`gtd judge run\` fails or \`gtd judge answer\` rejects the
43
- # verdict, its stderr reaches you and we fall through to the human hand-off.
50
+ # verdict, its stderr reaches you and the gate is handed to you — on the
51
+ # opening beat too, so it never lands unanswered — as it is when the gate
52
+ # we just answered comes straight back.
44
53
  message)
45
- if [ -n "$(gtd next --json=judge)" ] &&
46
- verdict="$(gtd judge --json | gtd judge run)" &&
47
- script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
48
- printf '%s\\n' "$script" | sh
49
- beat=$((beat + 1))
50
- continue
54
+ if [ -n "$(gtd next --json=judge)" ]; then
55
+ state="$(gtd next --json=state)"
56
+ if [ "$state" != "$judged_before" ] &&
57
+ verdict="$(gtd judge --json | gtd judge run)" &&
58
+ script="$(printf '%s\\n' "$verdict" | gtd judge answer --json=script)"; then
59
+ printf '%s\\n' "$script" | sh
60
+ judged="$state"
61
+ beat=$((beat + 1))
62
+ continue
63
+ fi
64
+ gtd next
65
+ exit 0
51
66
  fi
52
67
  [ "$beat" = 1 ] || {
53
68
  gtd next
@@ -66,45 +81,83 @@ while :; do
66
81
  model="$(gtd next --json=model)"
67
82
  system="$(gtd next --json=system)"
68
83
  validate="$(gtd next --json=validate)"
84
+ context="$(gtd next --json=context)"
69
85
  log="$(gtd next --json=log)"
86
+ # Absolute, since the hook runs wherever the agent last cd'ed to.
87
+ case "$log" in /*) ;; *) log="$(git rev-parse --show-toplevel)/$log" ;; esac
70
88
  # \`model\` is a hint, not a model name — the bundled workflow ships
71
89
  # \`smart\` and \`base\`, which \`claude --model\` rejects. Resolve them
72
- # with the variables \`gtd loop\` reads; unset, the hint is dropped and
73
- # the agent runs its own default. Any other value is a real model name.
90
+ # from your environment; unset, the hint is dropped and the agent runs
91
+ # its own default. Any other value is a real model name.
74
92
  case "$model" in
75
93
  smart) model="\${GTD_LOOP_MODEL_SMART:-}" ;;
76
94
  base) model="\${GTD_LOOP_MODEL_BASE:-}" ;;
77
95
  esac
78
- # The prompt embeds a full diff, so it goes to the agent over stdin,
79
- # never as an argv positional — argv is capped (~1 MB on macOS, and
80
- # POSIX guarantees only 4 KB, ARG_MAX), and a diff crosses that far
81
- # sooner than you'd expect. Piping plain \`gtd next\` (not
82
- # --json=content) means the diff is rendered once, for this input,
83
- # rather than twice.
84
- agent_turn() { gtd next | claude -p "$1" "$session_id" \\
85
- \${model:+--model "$model"} \\
86
- \${system:+--system-prompt "$system"} \\
87
- --dangerously-skip-permissions >>"$log" 2>&1; }
96
+ # The step's context limit, as a Claude Code hook after every tool
97
+ # call and before compacting. Past \`hard\` it stops the turn and
98
+ # appends why to the stop file, so we land nothing and stop too.
99
+ soft="$(printf '%s' "$context" | sed -n 's/.*"soft":\\([0-9.]*\\).*/\\1/p')"
100
+ hard="$(printf '%s' "$context" | sed -n 's/.*"hard":\\([0-9.]*\\).*/\\1/p')"
101
+ window="\${GTD_LOOP_CONTEXT_WINDOW:-}"
102
+ case "$model" in *'[1m]'* | *'[1M]'*) window="\${window:-1000000}" ;; esac
103
+ hook="gtd context-hook\${soft:+ --soft $soft}\${hard:+ --hard $hard}\${window:+ --window $window}"
104
+ hooks="[{\\"hooks\\":[{\\"type\\":\\"command\\",\\"command\\":\\"$hook\\"}]}]"
105
+ settings="{\\"hooks\\":{\\"PostToolUse\\":$hooks,\\"PreCompact\\":$hooks}}"
106
+ stop="$log.context-stop"
107
+ rm -f "$stop"
108
+ # One turn, its prompt on stdin. The prompt embeds a full diff, so it
109
+ # never goes on argv — argv is capped (~1 MB on macOS, and POSIX
110
+ # guarantees only 4 KB, ARG_MAX), and a diff crosses that far sooner
111
+ # than you'd expect. Every turn passes the same $model/$system:
112
+ # whether \`claude --resume\` re-applies the original session's is a
113
+ # harness detail gtd cannot verify from outside. Its JSON envelope
114
+ # goes to the log, and its \`total_cost_usd\` adds up into $cost. An
115
+ # \`is_error\` envelope (max turns, an error mid-turn) or one another
116
+ # hook stopped fails the turn even at exit 0: landing it would land
117
+ # the failure as work. Only a turn that never ran (no envelope: the
118
+ # session flag was refused) is worth retrying under the other flag.
119
+ turn() {
120
+ out="$(GTD_LOOP_CONTEXT_STOP_FILE="$stop" claude -p "$@" \\
121
+ \${model:+--model "$model"} \\
122
+ \${system:+--system-prompt "$system"} \\
123
+ --settings "$settings" --output-format json \\
124
+ --dangerously-skip-permissions 2>>"$log")" && ok=0 || ok=$?
125
+ printf '%s\\n' "$out" >>"$log"
126
+ case "$out" in *'"type":"result"'*) ran=true ;; *) ran=false ;; esac
127
+ case "$out" in
128
+ *'"is_error":true'* | *'"terminal_reason":"hook_stopped"'*) ok=1 ;;
129
+ esac
130
+ usd="$(printf '%s\\n' "$out" | sed -n 's/.*"total_cost_usd":\\([0-9.eE+-]*\\).*/\\1/p' | tail -n 1)"
131
+ [ -z "$usd" ] || cost="$(awk -v a="\${cost:-0}" -v b="$usd" 'BEGIN { printf "%.6f", a + b }')"
132
+ [ "\${cost:-}" != 0.000000 ] || cost=
133
+ if [ -s "$stop" ]; then
134
+ cat "$stop" >&2
135
+ exit 1
136
+ fi
137
+ return "$ok"
138
+ }
139
+ # Plain \`gtd next\` (not --json=content) renders the diff once.
140
+ gtd next >"$log.prompt"
88
141
  if [ "\${resume:-}" = true ]
89
- then agent_turn --resume || agent_turn --session-id
90
- else agent_turn --session-id || agent_turn --resume
142
+ then first=--resume second=--session-id
143
+ else first=--session-id second=--resume
91
144
  fi
145
+ turn "$first" "$session_id" <"$log.prompt" ||
146
+ { [ "$ran" = false ] && turn "$second" "$session_id" <"$log.prompt"; } || {
147
+ echo "agent turn failed — see $log" >&2
148
+ exit 1
149
+ }
92
150
  n=0
93
151
  while [ -n "\${validate:-}" ] && ! fix="$(sh -c "$validate" 2>&1)"; do
94
152
  n=$((n + 1)) && [ "$n" -gt 3 ] && { printf '%s\\n' "$fix" >&2; exit 1; }
95
- # $fix IS the fix prompt, verbatim — piped for the same reason the
96
- # agent's own turn above is. Whether \`claude --resume\` re-applies the
97
- # original session's model/system prompt is a harness detail gtd
98
- # cannot verify from outside, so this passes the identical
99
- # $model/$system on both calls rather than assume it does —
100
- # otherwise this fix turn might silently fall back to Claude Code's
101
- # own defaults while the turn that produced the file ran under the
102
- # workflow's own model and persona.
103
- printf '%s' "$fix" | claude -p --resume "$session_id" \\
104
- \${model:+--model "$model"} \\
105
- \${system:+--system-prompt "$system"} \\
106
- --dangerously-skip-permissions >>"$log" 2>&1
107
- done ;;
153
+ # $fix IS the fix prompt, verbatim.
154
+ printf '%s' "$fix" >"$log.prompt"
155
+ turn --resume "$session_id" <"$log.prompt" || {
156
+ echo "fix turn failed — see $log" >&2
157
+ exit 1
158
+ }
159
+ done
160
+ rm -f "$log.prompt" ;;
108
161
  esac
109
162
 
110
163
  # Read every value the landing needs BEFORE piping the script to \`sh\`:
@@ -114,7 +167,8 @@ while :; do
114
167
  # before the script runs and changes the tree out from under them.
115
168
  settled="$(gtd land --json=settled)"
116
169
  idle="$(gtd land --json=idle)"
117
- gtd land --json=script | sh
170
+ # A prompt beat's turns record their cost, tagged with the model that ran.
171
+ gtd land --json=script \${cost:+--cost="$cost"} \${cost:+\${model:+--model="$model"}} | sh
118
172
  if [ "\${settled:-}" = true ]; then
119
173
  [ "\${idle:-}" = true ] && gtd next
120
174
  exit 0
@@ -205,7 +205,6 @@ const missingTool = (name: string) => () =>
205
205
  const missingToolCommands: ToolCommands["Type"] = {
206
206
  ui: missingTool("ui"),
207
207
  lsp: missingTool("lsp"),
208
- loop: missingTool("loop"),
209
208
  contextHook: missingTool("context-hook"),
210
209
  judge: () =>
211
210
  Promise.reject(
@@ -1,4 +1,4 @@
1
- // The context-limit decision every driver shares: the loop's `gtd context-hook`
1
+ // The context-limit decision every driver shares: `gtd context-hook`
2
2
  // and the Claude Code mod both answer a hook event through `contextHook`, so
3
3
  // the two cannot drift on thresholds, wording or which tokens count.
4
4