@pmelab/gtd 8.5.0 → 8.6.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 (4) hide show
  1. package/README.md +135 -5
  2. package/bin/gtd +183 -20
  3. package/dist/gtd.bundle.mjs +900 -308
  4. package/package.json +1 -1
package/README.md CHANGED
@@ -227,13 +227,26 @@ Commands:
227
227
  next Print the resolved rest's rendered script/prompt/message (no mutation)
228
228
  status Print the resolved rest's state/actor and which declared
229
229
  pattern (if any) each pending change matches (no mutation)
230
- validate Format and validate the steering file the resolved rest
231
- declares, with its mode's commands (its file:/mode:);
232
- exits non-zero with the findings when it is invalid
230
+ validate Print the script that formats (when declared) then
231
+ validates the resolved rest's steering file, using its
232
+ mode's commands (its file:/mode:), instead of running it —
233
+ a driver runs the script and reads the findings from its
234
+ own exit code/output. Always exits 0; --json emits
235
+ {state, file?, mode?, script} (script is "" when there is
236
+ nothing to validate; plain text prints "nothing to
237
+ validate" in that case)
233
238
  lsp Start the LSP server for .gtd/ steering files (stdio)
234
239
  visualize Serve an interactive diagram of the active workflow on a
235
240
  local web server (--port <n>, --no-open; --json prints the
236
241
  model and exits)
242
+ check <mode> <file>
243
+ Read <file> and run the built-in steering format named
244
+ <mode> (see `gtd validate`'s modes: qa, review) over its
245
+ contents, printing each finding one per line and exiting
246
+ non-zero when there are any. Resolves no workflow state and
247
+ reads no config — standalone, runnable from any directory
248
+ with <mode>/<file> given explicitly. This is what a
249
+ workflow's emitted validation script invokes as a leaf step
237
250
  version Print version and exit
238
251
  help Print this help and exit
239
252
 
@@ -469,6 +482,123 @@ Optionally,
469
482
  sets a value renderable as `$summary` in an Agent sidebar row, if you want more
470
483
  than the state itself.
471
484
 
485
+ ## Writing your own driver
486
+
487
+ `bin/gtd` is gtd's own reference driver, not a privileged one — the engine
488
+ itself is a supported public surface, and anything below holds for any driver
489
+ you write against it. gtd decides and prints; it never touches git itself. The
490
+ four commands that change anything — `gtd step [<actor>]` (including
491
+ `--entry <state>`), `gtd abandon`, and `gtd restore` — perform no git write when
492
+ run. Instead, under `--json`, each one's output object carries two extra string
493
+ fields, `required` and `optional`: bash scripts for YOU to execute. (Plain-text
494
+ output, without `--json`, never shows these — it prints only the human-facing
495
+ result line, e.g. `committed: <subject>`, so a plain-text caller never sees a
496
+ script at all.) A driver that only prints gtd's plain-text output is not driving
497
+ anything; to actually land a turn, run `gtd ... --json`, pull `required`/
498
+ `optional` out with `jq`, and execute them.
499
+
500
+ - **`required`** is everything that decides what lands in git — closing an open
501
+ review checkout window, the resting state's own steering-mode
502
+ `format:`/`validate:` commands, then the commit or squash itself (`gtd step`
503
+ and `gtd --entry <state>`), or the ref update and reset that undo a process
504
+ (`gtd abandon`, `gtd restore`). Run this one. Skipping it means the turn never
505
+ lands.
506
+ - **`optional`** is presentation only: re-opening the review checkout window
507
+ (the `<<<<<<< HEAD` diff view) after a `gtd step` lands at a
508
+ `reviewWindow: true` state, so an editor's diff view has something to show.
509
+ Skip it and you lose nothing but that view — the workflow is still driven
510
+ correctly. `gtd abandon`/`gtd restore` always emit `optional: ""` (there is no
511
+ window to reopen after either).
512
+
513
+ Either field may be the empty string `""`, meaning "nothing to do here" — a
514
+ no-op `gtd step` (a clean tree matching no `on` pattern) emits
515
+ `required: ""`/`optional: ""` and exits zero; run nothing and move on. Both
516
+ scripts are self-contained (each carries its own precondition assert and retry
517
+ helper) and safe to run standalone, in sequence, or not at all — paste either
518
+ into a terminal and it does exactly what it says.
519
+
520
+ ### Failure taxonomy and recovery
521
+
522
+ Two different things can go wrong, and they mean different things:
523
+
524
+ - **`gtd` itself exits non-zero.** Nothing was attempted — this is a refusal (a
525
+ guard rejected the turn) or a usage error. No script was ever produced.
526
+ - **An emitted script exits non-zero when YOU run it.** Something may have
527
+ partially happened — e.g. a `gtd_retry`-wrapped git write landed but a later
528
+ step in the same script failed.
529
+
530
+ Recovery is the same in both cases: **re-invoke `gtd`.** It re-reads the real
531
+ repository state fresh every time — never a cached plan — and emits whatever
532
+ still needs to happen from there. This works because every emitted script opens
533
+ by asserting its own precondition
534
+ (`[ "$(git rev-parse --verify --quiet HEAD 2>/dev/null)" = <expected> ] || { ...; exit 1; }`,
535
+ and the same shape for a review window's saved ref — see `src/Emit.ts`'s
536
+ `headAssertion`/`reviewWindowAssertion`), so a script generated against a
537
+ repository state that has since moved refuses loudly instead of corrupting
538
+ anything. In a repository with no commits yet, `<expected>` is the empty string
539
+ — the deciding read's own convention for "no commits yet" — so the first
540
+ workflow commit is allowed to land instead of being blocked by an unborn `HEAD`.
541
+ **Emitted scripts are re-runnable**: this is the single most important property
542
+ for a driver's recovery logic. Re-running a script that already fully applied is
543
+ a no-op (its git writes are `--allow-empty` commits, idempotent ref updates, and
544
+ tolerant staged-restore calls), and re-running one that only partially applied
545
+ resumes correctly, because the precondition either still holds (nothing landed
546
+ yet — safe to retry verbatim) or gtd's next invocation reads the new real state
547
+ and emits a fresh script for what remains. A driver never needs its own
548
+ retry/resume logic beyond "if the script failed, ask gtd again."
549
+
550
+ ### Prerequisites
551
+
552
+ - **`jq`** — to pull the `required`/`optional` strings back out of gtd's
553
+ `--json` output.
554
+ - **`gtd` on `PATH`** — a mode's seeded `validate:` command (the one the
555
+ compiler fills in for the built-in `qa`/`review` formats) is literally the
556
+ string `gtd check <mode> '<file>'`, invoked by NAME from inside an emitted
557
+ script, not by absolute path (see `src/SteeringFormats.ts`'s
558
+ `seededValidateCommand`). This is a deliberate trade: a readable, overridable,
559
+ copy-pasteable command in exchange for depending on shell name resolution at
560
+ the moment the script runs. The sharp edge: if the `gtd` binary you invoked to
561
+ GENERATE the script differs from the `gtd` that resolves on `PATH` when the
562
+ script later RUNS (a locally-built dev binary vs. a globally-installed
563
+ release, say), you can get version skew between the two — the command that
564
+ validates may not be the command that decided. Keep the two in sync (one `gtd`
565
+ on `PATH`, consistently) if you care about that gap.
566
+
567
+ ### The normalization-only contract on `format:`
568
+
569
+ A mode's `format:` command may reformat a steering file — whitespace, wrapping,
570
+ reordering — but must NEVER change what a step-capture guard would decide. gtd's
571
+ guards (the review sign-off check, the feedback-progress check, the
572
+ answer-completeness check — `src/StepGuards.ts`) decide ONCE, against whichever
573
+ bytes are on disk at the moment `gtd step` runs, which may be before OR after an
574
+ emitted script's own `format:` line has run (the script runs `format:` then
575
+ `validate:` then the commit — see `src/SteeringMode.ts`'s
576
+ `renderSteeringCommands` — but gtd's decision and the driver's script execution
577
+ are different processes at different times, so there's no guaranteed ordering
578
+ between "gtd decided" and "the script formatted"). That's only safe because
579
+ every built-in guard judges content, not incidental formatting (e.g. it
580
+ normalizes `[ ]`/`[x]` checkboxes before comparing). If you plug in your own
581
+ `format:` command, the same rule binds it: a formatter that also changes meaning
582
+ — ticking a box, stripping a paragraph — makes the guard's decision and the
583
+ file's actual content disagree, and gtd will not catch that for you.
584
+
585
+ ### Built-in steering formats are ordinary modes
586
+
587
+ `qa` and `review` are gtd's two built-in steering-file formats (parsed and
588
+ validated in-process because `gtd lsp` needs the same parsers for live
589
+ diagnostics), but their `validate:` is not hardcoded or hidden: the compiler
590
+ SEEDS every workflow's `modes:` map with `qa`/`review` entries whose `validate:`
591
+ is the same `gtd check <mode> '<file>'` string described above
592
+ (`src/PatternConfig.ts`, `src/SteeringFormats.ts`'s `seededValidateCommand`).
593
+ That seeded command is visible in `gtd visualize`'s compiled model and in the
594
+ editor JSON schema like any other mode, and it's overridable the same way any
595
+ mode is — declare `modes: { qa: { validate: "your-own-command" } }` (in the
596
+ workflow or in `.gtdrc`) and your command displaces the seed; declaring only a
597
+ `format:` for `qa`/`review` composes with the seeded `validate:` rather than
598
+ replacing it. There is no special-cased built-in behavior a driver needs to know
599
+ about beyond the ordinary mode-resolution rules already documented under
600
+ [The `workflow:` key](#the-workflow-key).
601
+
472
602
  ## Configuration
473
603
 
474
604
  gtd reads an optional `.gtdrc` config file via
@@ -799,8 +929,8 @@ but `gtd lsp` never runs a shell command per keystroke, so live diagnostics
799
929
  become one `Information` notice pointing at `gtd validate` instead of the
800
930
  built-in findings. Any OTHER mode name — `prose` (used by the simple flow's plan
801
931
  file), or a project's own declared name — has no built-in format, so it gets no
802
- live editor support at all: `gtd validate` and the `gtd step` gate still format
803
- and validate it like any other mode.
932
+ live editor support at all: `gtd validate`'s emitted script and the `gtd step`
933
+ gate still format and validate it like any other mode.
804
934
 
805
935
  ## Development
806
936
 
package/bin/gtd CHANGED
@@ -10,8 +10,13 @@ set -euo pipefail
10
10
  # - no arguments, or exactly `loop` with no further arguments -> fall through
11
11
  # into the loop body below (the loop protocol comment further down).
12
12
  # - `loop` with an unrecognised extra argument -> usage error, exit 2.
13
- # - anything else -> hand off to the bundle via `run_gtd`, forwarding all
14
- # arguments unchanged (e.g. `bin/gtd status`, `bin/gtd step human`).
13
+ # - anything else -> a WRITE subcommand (`step`, the `--entry` forms,
14
+ # `abandon`, `restore` — see `is_write_command`) routes through
15
+ # `run_gtd_command`, which actually performs the bundle's emitted
16
+ # `required`/`optional` scripts (the bundle itself only ever PRINTS them
17
+ # now); everything else (`status`, `next`, `validate`, `visualize`, `lsp`,
18
+ # `check`, `init`) hands off straight to the bundle via `run_gtd`,
19
+ # forwarding all arguments unchanged (e.g. `bin/gtd status`).
15
20
  # The loop's whole job is to drive the process until it rests at a
16
21
  # NON-AUTONOMOUS state (a human gate) or settles. Everything beyond that —
17
22
  # editors, desktop notifications, terminal-multiplexer status — is an OUTER
@@ -61,6 +66,19 @@ else
61
66
  M_FIX="[fix]"; M_COMMIT="[commit]"; M_SETTLE="[done]"; M_ERROR="[err]"; M_WARN="[warn]"; M_ELLIPSIS="..."
62
67
  fi
63
68
 
69
+ # `jq` used to be a loop-only dependency (the loop's own `next --json`
70
+ # parsing); now every write subcommand's `--json` response (parsed by
71
+ # `run_gtd_command`, below) carries the `required`/`optional` scripts this
72
+ # script must execute for ANY invocation, not just `loop`. Checked once, up
73
+ # front, before any dispatch below ever reaches for `jq` — a plain
74
+ # `echo`/`exit` rather than `emit_error` since that helper (and the
75
+ # fancy/plain markers it relies on) isn't the point; keeping the check
76
+ # dependency-free is.
77
+ if ! command -v jq >/dev/null 2>&1; then
78
+ echo "gtd: jq is required (used to parse the bundle's --json output) — install it and retry" >&2
79
+ exit 1
80
+ fi
81
+
64
82
  # emit_file_rows <files> — render a commit's changed files as dim, indented
65
83
  # rows BELOW the transition/commit line: at most 3, then a "… (N more)" row
66
84
  # when there are more. <files> is a newline-separated list (git diff-tree
@@ -234,9 +252,18 @@ parse_subject() {
234
252
  # when it reported at least one; returns non-zero (having emitted nothing,
235
253
  # and leaving $reported_head unchanged) when there were no new commits. Global
236
254
  # rather than per-beat so the review checkout window's HEAD rewinds between
237
- # beats never cause an already-reported commit to replay.
255
+ # beats never cause an already-reported commit to replay. $reported_head ==
256
+ # "none" (the repo had no commits at all when it was last captured) can't
257
+ # form a "none"..HEAD range — `git rev-list` would fail resolving "none" as a
258
+ # revision — so that case walks the whole history from the root instead.
238
259
  report_commits() {
239
260
  local sha subject files reported=0
261
+ local range
262
+ if [[ "$reported_head" == none ]]; then
263
+ range="HEAD"
264
+ else
265
+ range="$reported_head..HEAD"
266
+ fi
240
267
  while IFS= read -r sha; do
241
268
  [[ -z "$sha" ]] && continue
242
269
  reported=1
@@ -247,13 +274,130 @@ report_commits() {
247
274
  transition) emit_transition "$S_FROM" "$S_TO" "$files" ;;
248
275
  capture | squash) emit_commit "$subject" "$files" ;;
249
276
  esac
250
- done < <(git rev-list --reverse "$reported_head"..HEAD)
277
+ done < <(git rev-list --reverse "$range")
251
278
  if [[ "$reported" == 1 ]]; then
252
279
  reported_head="$(git rev-parse HEAD)"
253
280
  fi
254
281
  [[ "$reported" == 1 ]]
255
282
  }
256
283
 
284
+ # ── Write-command execution (is_write_command, run_gtd_command) ────────────
285
+ # The bundle no longer performs a WRITE subcommand's git effect itself: its
286
+ # `--json` response for `step`/the `--entry` forms/`abandon`/`restore` now
287
+ # carries `required` (a bash script that performs the command's whole
288
+ # effect — commit/reset/etc, including any review-window close/open
289
+ # management) and `optional` (presentation-only, e.g. re-opening the review
290
+ # checkout window — safe to skip), alongside whichever keys that command's
291
+ # JSON always carried. This script is the ONE driver for every invocation
292
+ # (not just the loop's), so it — not the bundle — must run those scripts.
293
+
294
+ # is_write_command <token> — true for exactly the tokens whose command
295
+ # mutates the repo: `step` (with or without `--entry` trailing), `abandon`,
296
+ # `restore`, and the bare `--entry` short form (`gtd --entry <state>`, where
297
+ # `--entry` itself is $1 since there is no subcommand token in front of it).
298
+ # Everything else (next/status/validate/visualize/lsp/check/init, or an
299
+ # unrecognised token) is read-only or has its own arity/usage errors and is
300
+ # left to the existing plain `run_gtd "$@"` forwarding.
301
+ # `--entry` takes both spellings (`--entry <state>` and `--entry=<state>`), so
302
+ # the bare short form has to be recognised either way.
303
+ is_write_command() {
304
+ case "${1:-}" in
305
+ step | abandon | restore | --entry | --entry=*) return 0 ;;
306
+ *) return 1 ;;
307
+ esac
308
+ }
309
+
310
+ # run_gtd_command <subcommand-and-args...> — the ONE execution path for every
311
+ # write subcommand, used both by the direct dispatch below and by the loop
312
+ # body's own step calls (so there is one execution path rather than two, not
313
+ # a loop-only one and a separate direct one). Fetches the bundle's `--json`
314
+ # response for "$@" (forcing --json on regardless of whether the caller's own
315
+ # arguments already carry it — repeating `--json` is harmless, see
316
+ # `Cli.ts`'s arity-0 flag handling), runs `.required` via `bash -c` (aborting
317
+ # — returning its exit code — before `.optional` ever runs, mirroring `set
318
+ # -e`), then `.optional` via `bash -c` (a non-zero exit there is only ever a
319
+ # warning: it is presentation-only, e.g. re-opening the review checkout
320
+ # window, per its own "safe to skip" billing). On success it prints, on
321
+ # stdout, the plain human-facing line in TODAY's exact wording — the same
322
+ # text `runStepCommand`/`runEntryCommand`/`runAbandonCommand`/
323
+ # `runRestoreCommand` printed themselves before this rewrite moved the git
324
+ # write into `required`. A direct caller lets that line stream straight to
325
+ # the terminal; the loop instead captures it (`$(... 2>&1)`, the file's
326
+ # established idiom) and logs it, since its own on-screen presentation is
327
+ # `report_commits`. `abandon`/`restore` have no `subject` in their JSON (never
328
+ # did — the original handlers computed it from git AFTER writing), so this
329
+ # recomputes it from HEAD after `.required` has run; every other field used
330
+ # below (`state`, `from`, `head`, `to`) is one of the contract's own preserved
331
+ # "existing keys". On failure (the JSON fetch itself — a refusal/usage error —
332
+ # or `.required`), the combined fetch output is sent to stderr and the
333
+ # function returns non-zero; a failing `.required`'s own output streams
334
+ # (or is captured by the caller) exactly as it happened, unredirected here.
335
+ run_gtd_command() {
336
+ local subcmd="${1:-}" json_out required optional opt_status
337
+ local state subject head to from
338
+
339
+ if ! json_out="$(run_gtd "$@" --json 2>&1)"; then
340
+ printf '%s' "$json_out" >&2
341
+ return 1
342
+ fi
343
+
344
+ # `// empty` guards a command whose JSON doesn't (yet, or ever) carry these
345
+ # keys for a given branch — e.g. `gtd abandon`'s "nothing to abandon" no-op
346
+ # reply has neither key at all — so a missing/null field reads as "" (jq's
347
+ # own `null` stringifies to the 4-character text "null" otherwise, which
348
+ # would misread as a non-empty script).
349
+ required="$(jq -r '.required // empty' <<<"$json_out")"
350
+ optional="$(jq -r '.optional // empty' <<<"$json_out")"
351
+
352
+ if [[ -n "$required" ]]; then
353
+ bash -c "$required" || return $?
354
+ fi
355
+
356
+ if [[ -n "$optional" ]]; then
357
+ opt_status=0
358
+ bash -c "$optional" || opt_status=$?
359
+ if ((opt_status != 0)); then
360
+ emit_warn "the presentation-only follow-up after '$subcmd' failed (exit $opt_status) — continuing"
361
+ fi
362
+ fi
363
+
364
+ case "$subcmd" in
365
+ abandon)
366
+ if [[ -z "$required" ]]; then
367
+ state="$(jq -r '.state' <<<"$json_out")"
368
+ printf 'no gtd process is underway (resting at "%s") — nothing to abandon\n' "$state"
369
+ else
370
+ from="$(jq -r '.from' <<<"$json_out")"
371
+ state="$(jq -r '.state' <<<"$json_out")"
372
+ head="$(jq -r '.head' <<<"$json_out")"
373
+ subject="$(git log -1 --format=%s HEAD)"
374
+ printf 'abandoned the process resting at "%s" — HEAD is back at %s ("%s"), resting at "%s".\n' \
375
+ "$from" "${head:0:7}" "$subject" "$state"
376
+ printf 'Everything the process produced is kept as uncommitted changes (`git status`); discard them with `git checkout -- . && git clean -fd .gtd` for a clean tree.\n'
377
+ fi
378
+ ;;
379
+ restore)
380
+ to="$(jq -r '.to' <<<"$json_out")"
381
+ state="$(jq -r '.state' <<<"$json_out")"
382
+ subject="$(git log -1 --format=%s HEAD)"
383
+ printf 'restored the retained history — HEAD is back at %s ("%s"), resting at "%s". Resume with the loop, or `git reset` to any earlier turn to restart from there.\n' \
384
+ "${to:0:7}" "$subject" "$state"
385
+ ;;
386
+ *)
387
+ # `step` (subject nullable — a no-op prints "nothing to do") and the
388
+ # `--entry` forms (subject never null — always "committed: ...") share
389
+ # this one branch: the null-check alone tells them apart.
390
+ subject="$(jq -r '.subject' <<<"$json_out")"
391
+ if [[ -n "$subject" && "$subject" != "null" ]]; then
392
+ printf 'committed: %s\n' "$subject"
393
+ else
394
+ state="$(jq -r '.state' <<<"$json_out")"
395
+ printf 'nothing to do at "%s"\n' "$state"
396
+ fi
397
+ ;;
398
+ esac
399
+ }
400
+
257
401
  # ── Log file (resolve_log_path) ─────────────────────────────────────────────
258
402
 
259
403
  # worktree_git_dir — the git dir of the working tree gtd is operating in,
@@ -317,6 +461,10 @@ if [[ "$valid_combo" == 1 && "$n_once" == 1 ]]; then
317
461
  fi
318
462
 
319
463
  if [[ $# -gt 0 && "$1" != "loop" ]]; then
464
+ if is_write_command "${1:-}"; then
465
+ run_gtd_command "$@"
466
+ exit $?
467
+ fi
320
468
  run_gtd "$@"
321
469
  exit $?
322
470
  fi
@@ -501,7 +649,7 @@ run_agent_turn() {
501
649
  # its own comment) — a global high-water mark rather than a per-beat
502
650
  # head_before, so a review checkout window's HEAD rewind between beats never
503
651
  # causes an already-printed transition to replay.
504
- reported_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
652
+ reported_head="$(git rev-parse --verify --quiet HEAD || echo none)"
505
653
 
506
654
  # Opening move: capture the human's pending edit so gtd-loop is the ONLY command
507
655
  # a human runs. A human acts at a gate by editing files (a plan answer, a review
@@ -528,8 +676,8 @@ Run \`gtd status\` to inspect the repo."
528
676
  fi
529
677
  peek_kind="$(jq -r .kind <<<"$peek_json")"
530
678
  if [[ "$peek_kind" == "message" ]]; then
531
- opening_head_before="$(git rev-parse HEAD 2>/dev/null || echo none)"
532
- if ! step_out="$(run_gtd step human 2>&1)"; then
679
+ opening_head_before="$(git rev-parse --verify --quiet HEAD || echo none)"
680
+ if ! step_out="$(run_gtd_command step human 2>&1)"; then
533
681
  emit_error "could not capture your changes at the human gate:
534
682
  $step_out
535
683
  Fix the reported issue and re-run gtd-loop."
@@ -539,7 +687,7 @@ Fix the reported issue and re-run gtd-loop."
539
687
  # This branch reports nothing — advance the marker past whatever was just
540
688
  # (silently) captured so the first loop-body beat below doesn't newly
541
689
  # print it.
542
- reported_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
690
+ reported_head="$(git rev-parse --verify --quiet HEAD || echo none)"
543
691
  if [[ "$once_mode" == 1 && "$opening_head_before" != "$reported_head" ]]; then
544
692
  exit 0
545
693
  fi
@@ -590,7 +738,7 @@ Run \`gtd status\` to inspect the repo."
590
738
  emit_warn "the check script at '$state' exited $script_status — continuing; the outcome is whatever it left in the tree:"
591
739
  emit_output_since "$script_offset"
592
740
  fi
593
- if ! step_out="$(run_gtd step "$actor" 2>&1)"; then
741
+ if ! step_out="$(run_gtd_command step "$actor" 2>&1)"; then
594
742
  printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
595
743
  emit_error "$step_out"
596
744
  emit_log_pointer
@@ -616,7 +764,7 @@ Run \`gtd status\` to inspect the repo."
616
764
  # stall from a return lap back into the same state (e.g. checking ->
617
765
  # fixing): a static prompt looks identical to last time, but a commit
618
766
  # landed in between, so HEAD has moved and this isn't a stall.
619
- cur_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
767
+ cur_head="$(git rev-parse --verify --quiet HEAD || echo none)"
620
768
  if [[ "$state" == "$prev_state" && "$content" == "$prev_content" && "$cur_head" == "$prev_head" ]]; then
621
769
  next_out="$(run_gtd next 2>&1)" || true
622
770
  emit_error "no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning.
@@ -653,12 +801,15 @@ $next_out"
653
801
  # Run the agent turn (see run_agent_turn's own comment for the adapter
654
802
  # contract). Then the self-validation gate: a producing state may declare a
655
803
  # `file:`/`mode:` pair (e.g. the default workflow's grilling/reviewing),
656
- # making its output's format checkable. `gtd validate` is a no-op (exit 0)
657
- # when the resolved rest has nothing to validate, so run it after EVERY agent
658
- # turn; on findings, re-prompt the SAME agent session to fix them (up to a
659
- # cap) before stepping — the plain-`gtd next` self-validation instruction's
660
- # driver-side equivalent, so the next rest is only ever handed a well-formed
661
- # file. gtd itself never validates at step time — this is the loop's job.
804
+ # making its output's format checkable. `gtd validate --json` always exits
805
+ # 0 now — it hands back the SCRIPT that formats/checks the file (`.script`,
806
+ # empty when the resolved rest has nothing to validate) rather than running
807
+ # it itself, so this driver runs that script after EVERY agent turn and
808
+ # re-prompts the SAME agent session on ITS non-zero exit (up to a cap)
809
+ # before stepping — the plain-`gtd next` self-validation instruction's
810
+ # driver-side equivalent, so the next rest is only ever handed a
811
+ # well-formed file. gtd itself never validates at step time — this is the
812
+ # loop's job.
662
813
  emit_action "$M_AGENT" "$label"
663
814
  turn_offset="$(log_offset)"
664
815
  agent_status=0
@@ -670,12 +821,24 @@ $next_out"
670
821
  exit 1
671
822
  fi
672
823
 
824
+ if ! validate_json="$(run_gtd validate --json 2>&1)"; then
825
+ emit_error "could not resolve the validation script at '$state':
826
+ $validate_json
827
+ Stopping rather than stepping with a possibly-malformed steering file."
828
+ emit_log_pointer
829
+ exit 1
830
+ fi
831
+ validate_script="$(jq -r '.script' <<<"$validate_json")"
832
+
673
833
  fix_attempt=0
674
- while ! validate_out="$(run_gtd validate 2>&1)"; do
834
+ while [[ -n "$validate_script" ]]; do
835
+ validate_status=0
836
+ validate_out="$(bash -c "$validate_script" 2>&1)" || validate_status=$?
837
+ ((validate_status == 0)) && break
675
838
  fix_attempt=$((fix_attempt + 1))
676
839
  if ((fix_attempt > 3)); then
677
840
  printf '%s\n' "$validate_out" >>"$GTD_LOOP_LOG"
678
- emit_error "'$state' still fails \`gtd validate\` after 3 fix attempts:
841
+ emit_error "'$state' still fails its own validation script after 3 fix attempts:
679
842
  $validate_out
680
843
  Stopping rather than stepping with a malformed steering file."
681
844
  emit_log_pointer
@@ -689,7 +852,7 @@ Stopping rather than stepping with a malformed steering file."
689
852
  [[ -n "$session_id" ]] && fix_resume=1
690
853
  turn_offset="$(log_offset)"
691
854
  agent_status=0
692
- run_agent_turn "$content"$'\n\n'"Your last turn does not pass \`gtd validate\`. Fix these format violations, then finish:"$'\n'"$validate_out" "$fix_resume" || agent_status=$?
855
+ run_agent_turn "$content"$'\n\n'"Your last turn does not pass its own validation script. Fix these format violations, then finish:"$'\n'"$validate_out" "$fix_resume" || agent_status=$?
693
856
  if ((agent_status != 0)); then
694
857
  emit_error "the agent's fix attempt $fix_attempt at '$state' failed (exit $agent_status):"
695
858
  emit_output_since "$turn_offset"
@@ -698,7 +861,7 @@ Stopping rather than stepping with a malformed steering file."
698
861
  fi
699
862
  done
700
863
 
701
- if ! step_out="$(run_gtd step "$actor" 2>&1)"; then
864
+ if ! step_out="$(run_gtd_command step "$actor" 2>&1)"; then
702
865
  printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
703
866
  emit_error "$step_out"
704
867
  emit_log_pointer