@pmelab/gtd 8.6.0 → 9.1.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 +389 -179
  2. package/dist/gtd.bundle.mjs +1246 -352
  3. package/package.json +2 -3
  4. package/bin/gtd +0 -878
package/bin/gtd DELETED
@@ -1,878 +0,0 @@
1
- #!/usr/bin/env bash
2
- set -euo pipefail
3
-
4
- # This file is the single published `bin` entry: `bin/gtd`. Before anything
5
- # else, it dispatches on $1:
6
- # - `--once` (bare, or immediately after `loop`) -> stripped here; restricts
7
- # the loop to exactly one beat (one human-gate capture, one script
8
- # check+step, or one agent prompt+step) before exiting. Dispatch then
9
- # continues on the remaining arguments as if it had never been given.
10
- # - no arguments, or exactly `loop` with no further arguments -> fall through
11
- # into the loop body below (the loop protocol comment further down).
12
- # - `loop` with an unrecognised extra argument -> usage error, exit 2.
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`).
20
- # The loop's whole job is to drive the process until it rests at a
21
- # NON-AUTONOMOUS state (a human gate) or settles. Everything beyond that —
22
- # editors, desktop notifications, terminal-multiplexer status — is an OUTER
23
- # wrapper's responsibility, deliberately not this script's.
24
- # `run_gtd` is the ONE place that knows how to invoke "the bundle": by default
25
- # it resolves `dist/gtd.bundle.mjs` relative to this script's own location
26
- # (not cwd), but `GTD_BIN` (a dev/test override for the whole invocation
27
- # command, e.g. `GTD_BIN="node $PWD/dev/run.mjs"`) takes precedence when set.
28
- # Every call the loop body makes below goes through `run_gtd` too, so the loop
29
- # always drives the exact same build/source it was launched from — no
30
- # dependence on a `gtd` binary being on PATH.
31
- # Follow symlinks first: npm's global install exposes this script as a
32
- # symlink in the node prefix's bin/, and dist/ only exists next to the REAL
33
- # script inside the package.
34
- SCRIPT_SOURCE="${BASH_SOURCE[0]}"
35
- while [[ -L "$SCRIPT_SOURCE" ]]; do
36
- SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_SOURCE")" &>/dev/null && pwd)"
37
- SCRIPT_SOURCE="$(readlink "$SCRIPT_SOURCE")"
38
- [[ "$SCRIPT_SOURCE" != /* ]] && SCRIPT_SOURCE="$SCRIPT_DIR/$SCRIPT_SOURCE"
39
- done
40
- SCRIPT_DIR="$(cd -- "$(dirname -- "$SCRIPT_SOURCE")" &>/dev/null && pwd)"
41
-
42
- run_gtd() {
43
- if [[ -n "${GTD_BIN:-}" ]]; then
44
- eval "$GTD_BIN" '"$@"'
45
- else
46
- node "$SCRIPT_DIR/../dist/gtd.bundle.mjs" "$@"
47
- fi
48
- }
49
-
50
- # ── Rendering primitives (capability detection, palette/markers, emitters) ──
51
- # `FANCY=1` only when stdout (fd 1) is a real terminal AND `NO_COLOR` is unset
52
- # — any pipe/redirect/non-tty stdout, or `NO_COLOR` set to any value, gives
53
- # plain ASCII with no escape codes, per the NO_COLOR convention
54
- # (https://no-color.org).
55
- if [ -t 1 ] && [ -z "${NO_COLOR:-}" ]; then FANCY=1; else FANCY=0; fi
56
-
57
- if [[ "$FANCY" == 1 ]]; then
58
- C_RESET=$'\e[0m'; C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'
59
- C_CYAN=$'\e[36m'; C_GREEN=$'\e[32m'; C_YELLOW=$'\e[33m'; C_RED=$'\e[31m'
60
- M_TRANS="➡️"; M_AGENT="🤖"; M_CHECK="🔧"; M_HUMAN="✍️"
61
- M_FIX="🔁"; M_COMMIT="✅"; M_SETTLE="🎉"; M_ERROR="❌"; M_WARN="⚠️"; M_ELLIPSIS="…"
62
- else
63
- C_RESET=""; C_BOLD=""; C_DIM=""
64
- C_CYAN=""; C_GREEN=""; C_YELLOW=""; C_RED=""
65
- M_TRANS="->"; M_AGENT="[agent]"; M_CHECK="[check]"; M_HUMAN="[you]"
66
- M_FIX="[fix]"; M_COMMIT="[commit]"; M_SETTLE="[done]"; M_ERROR="[err]"; M_WARN="[warn]"; M_ELLIPSIS="..."
67
- fi
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
-
82
- # emit_file_rows <files> — render a commit's changed files as dim, indented
83
- # rows BELOW the transition/commit line: at most 3, then a "… (N more)" row
84
- # when there are more. <files> is a newline-separated list (git diff-tree
85
- # --name-only output); empty input prints nothing.
86
- emit_file_rows() {
87
- local files="$1" f shown=0 total=0
88
- [[ -z "$files" ]] && return 0
89
- while IFS= read -r f; do [[ -n "$f" ]] && total=$((total + 1)); done <<<"$files"
90
- while IFS= read -r f; do
91
- [[ -z "$f" ]] && continue
92
- if ((shown >= 3)); then
93
- printf ' %s%s (%d more)%s\n' "$C_DIM" "$M_ELLIPSIS" "$((total - 3))" "$C_RESET"
94
- break
95
- fi
96
- printf ' %s%s%s\n' "$C_DIM" "$f" "$C_RESET"
97
- shown=$((shown + 1))
98
- done <<<"$files"
99
- }
100
-
101
- # emit_transition <from> <to> <files> — a captured state-to-state move. The
102
- # "→" separator is literal (not FANCY-dependent) — the same separator the
103
- # engine's commit grammar itself uses, per the package spec.
104
- emit_transition() {
105
- local from="$1" to="$2" files="$3"
106
- printf '%s %s%s → %s%s\n' \
107
- "$M_TRANS" "$C_BOLD$C_CYAN" "$from" "$to" "$C_RESET"
108
- emit_file_rows "$files"
109
- }
110
-
111
- # emit_action <marker> <label> — the heartbeat printed before a turn runs.
112
- emit_action() {
113
- local marker="$1" label="$2"
114
- printf '%s %s%s\n' "$marker" "$label" "$C_RESET"
115
- }
116
-
117
- # emit_commit <subject> <files> — a non-transition capture (a bare-subject
118
- # self-loop, or the squash's agent-authored final commit).
119
- emit_commit() {
120
- local subject="$1" files="$2"
121
- printf '%s %s%s%s\n' "$M_COMMIT" "$C_GREEN" "$subject" "$C_RESET"
122
- emit_file_rows "$files"
123
- }
124
-
125
- # emit_gate <state> <message> — the human-instruction line at a message rest.
126
- emit_gate() {
127
- local message="$2"
128
- printf '%s %s%s%s\n' "$M_HUMAN" "$C_BOLD$C_YELLOW" "$message" "$C_RESET"
129
- }
130
-
131
- # emit_settled <state> — the terminal "nothing left to do" signal.
132
- emit_settled() {
133
- printf '%s %ssettled — nothing left to do%s\n' "$M_SETTLE" "$C_GREEN" "$C_RESET"
134
- }
135
-
136
- # emit_error <message> — refusals/stalls/failures, to stderr; <message> may be
137
- # multi-line and is passed through verbatim.
138
- emit_error() {
139
- local message="$1"
140
- printf '%s %s%s%s\n' "$M_ERROR" "$C_RED" "$message" "$C_RESET" >&2
141
- }
142
-
143
- # emit_log_pointer — the "see the log" line printed right after every
144
- # emit_error call, to stderr: it names the log as the OVERFLOW channel — the
145
- # failing execution's own output is already replayed inline (see emit_block/
146
- # emit_output_since below); the log is where the rest of it, and the full
147
- # run's other output, lives.
148
- emit_log_pointer() {
149
- if [[ "$FANCY" == 1 ]]; then
150
- printf '%s📄 see %s%s\n' "$C_DIM" "$GTD_LOOP_LOG" "$C_RESET" >&2
151
- else
152
- printf '%ssee %s%s\n' "$C_DIM" "$GTD_LOOP_LOG" "$C_RESET" >&2
153
- fi
154
- }
155
-
156
- # Suppressed subprocess output goes to $GTD_LOOP_LOG in full, but a FAILING
157
- # execution also replays inline: mark the log's size before the call, print
158
- # what was appended since. Bounded to the last $ERR_TAIL_LINES lines — the log
159
- # holds the rest, and the overflow row says where.
160
- ERR_TAIL_LINES=20
161
-
162
- # log_offset — the log's current size in bytes (0 when it doesn't exist yet).
163
- log_offset() {
164
- local n=0
165
- if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then n="$(wc -c <"$GTD_LOOP_LOG")"; fi
166
- printf '%s' "${n// /}"
167
- }
168
-
169
- # emit_block <text> — replay captured subprocess output to stderr, dim and
170
- # indented like emit_file_rows, capped at the LAST $ERR_TAIL_LINES lines with
171
- # an overflow row naming the log. Empty <text> prints nothing. CSI escapes are
172
- # stripped under FANCY=0 (the plain-ASCII promise), using a sed form (\e, not
173
- # \x1b) that both BSD and GNU sed accept.
174
- emit_block() {
175
- local text="$1" total overflow
176
- [[ -z "$text" ]] && return 0
177
- if [[ "$FANCY" == 0 ]]; then
178
- text="$(sed $'s/\e\\[[0-9;]*[a-zA-Z]//g' <<<"$text")"
179
- fi
180
- total="$(wc -l <<<"$text")"
181
- total="${total// /}"
182
- if ((total > ERR_TAIL_LINES)); then
183
- overflow=$((total - ERR_TAIL_LINES))
184
- printf ' %s%s (%d earlier lines in %s)%s\n' "$C_DIM" "$M_ELLIPSIS" "$overflow" "$GTD_LOOP_LOG" "$C_RESET" >&2
185
- text="$(tail -n "$ERR_TAIL_LINES" <<<"$text")"
186
- fi
187
- while IFS= read -r line; do
188
- printf ' %s%s%s\n' "$C_DIM" "$line" "$C_RESET" >&2
189
- done <<<"$text"
190
- }
191
-
192
- # emit_output_since <offset> — emit_block over the log bytes appended since
193
- # <offset>.
194
- emit_output_since() {
195
- local offset="$1" bytes=""
196
- if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then
197
- bytes="$(tail -c "+$((offset + 1))" "$GTD_LOOP_LOG" 2>/dev/null || true)"
198
- fi
199
- emit_block "$bytes"
200
- }
201
-
202
- # A resume that failed because the session it names no longer exists — claude
203
- # prints "No conversation found with session ID: <uuid>". Loose on purpose: any
204
- # adapter whose CLI says something session-shaped gets the same recovery
205
- # instead of stopping the loop.
206
- SESSION_GONE_RE='no conversation found with session id|no such session|session (id )?[^ ]*( was)? not found|session [^ ]* does not exist'
207
-
208
- # session_gone_since <offset> — true when the log bytes appended since <offset>
209
- # carry the missing-session signature above. Blind (returns false) when no log
210
- # is configured, so recovery is best-effort, never a hard dependency.
211
- session_gone_since() {
212
- local offset="$1" bytes=""
213
- if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then
214
- bytes="$(tail -c "+$((offset + 1))" "$GTD_LOOP_LOG" 2>/dev/null || true)"
215
- fi
216
- [[ -n "$bytes" ]] && grep -qiE "$SESSION_GONE_RE" <<<"$bytes"
217
- }
218
-
219
- # emit_warn <message> — a surfaced but NON-fatal failure (a check script that
220
- # exited non-zero).
221
- emit_warn() {
222
- printf '%s %s%s%s\n' "$M_WARN" "$C_YELLOW" "$1" "$C_RESET" >&2
223
- }
224
-
225
- # ── Git-derivation helpers (parse_subject, report_commits) ──────────────────
226
-
227
- # parse_subject <subject> — sets S_ACTOR / S_FROM / S_TO / S_KIND. Patterns are
228
- # held in variables (rather than inlined) so bash's [[ =~ ]] doesn't need the
229
- # literal parens/spaces quoted inline.
230
- parse_subject() {
231
- local subject="$1"
232
- local transition_re='^gtd\(([^)]+)\): (.+) → (.+)$'
233
- local capture_re='^gtd\(([^)]+)\): (.+)$'
234
- S_ACTOR="" S_FROM="" S_TO="" S_KIND=""
235
- if [[ "$subject" =~ $transition_re ]]; then
236
- S_KIND=transition
237
- S_ACTOR="${BASH_REMATCH[1]}"
238
- S_FROM="${BASH_REMATCH[2]}"
239
- S_TO="${BASH_REMATCH[3]}"
240
- elif [[ "$subject" =~ $capture_re ]]; then
241
- S_KIND=capture
242
- S_ACTOR="${BASH_REMATCH[1]}"
243
- S_TO="${BASH_REMATCH[2]}"
244
- else
245
- S_KIND=squash
246
- S_TO="$subject"
247
- fi
248
- }
249
-
250
- # report_commits — walks "$reported_head"..HEAD in order, emitting a
251
- # transition/commit line per new commit, and advances $reported_head to HEAD
252
- # when it reported at least one; returns non-zero (having emitted nothing,
253
- # and leaving $reported_head unchanged) when there were no new commits. Global
254
- # rather than per-beat so the review checkout window's HEAD rewinds between
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.
259
- report_commits() {
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
267
- while IFS= read -r sha; do
268
- [[ -z "$sha" ]] && continue
269
- reported=1
270
- subject="$(git log -1 --format=%s "$sha")"
271
- files="$(git diff-tree --no-commit-id --name-only -r "$sha")"
272
- parse_subject "$subject"
273
- case "$S_KIND" in
274
- transition) emit_transition "$S_FROM" "$S_TO" "$files" ;;
275
- capture | squash) emit_commit "$subject" "$files" ;;
276
- esac
277
- done < <(git rev-list --reverse "$range")
278
- if [[ "$reported" == 1 ]]; then
279
- reported_head="$(git rev-parse HEAD)"
280
- fi
281
- [[ "$reported" == 1 ]]
282
- }
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
-
401
- # ── Log file (resolve_log_path) ─────────────────────────────────────────────
402
-
403
- # worktree_git_dir — the git dir of the working tree gtd is operating in,
404
- # derived purely from the cwd. GIT_DIR/GIT_WORK_TREE are scrubbed for this one
405
- # call so an inherited GIT_DIR (e.g. leaked from a parent git process, a hook,
406
- # or another worktree's shell) can never point our per-worktree state at a
407
- # DIFFERENT worktree — that would let two concurrently looping worktrees
408
- # collide on one log/memory file. A linked worktree's git-dir is its own
409
- # private .git/worktrees/<name>, so this is per-repository AND per-worktree.
410
- worktree_git_dir() {
411
- env -u GIT_DIR -u GIT_WORK_TREE -u GIT_COMMON_DIR -u GIT_INDEX_FILE \
412
- git rev-parse --git-dir
413
- }
414
-
415
- # resolve_log_path — echoes the loop's logfile path: $GTD_LOOP_LOG verbatim if
416
- # set, else "$(worktree_git_dir)/gtd-loop.log".
417
- resolve_log_path() {
418
- if [[ -n "${GTD_LOOP_LOG:-}" ]]; then
419
- echo "$GTD_LOOP_LOG"
420
- else
421
- echo "$(worktree_git_dir)/gtd-loop.log"
422
- fi
423
- }
424
-
425
- # --once governs only the loop driver, recognised in exactly two positions —
426
- # bare (`gtd --once`) or immediately after `loop` (`gtd loop --once`) — at
427
- # most once. When recognised it is stripped here before anything below looks
428
- # at "$@", so the bundle's own unknown-option guard never sees it; anything
429
- # else (an unknown flag, a duplicate --once, or --once mixed with a real
430
- # subcommand) is left untouched and falls through to the existing dispatch
431
- # below, which forwards a non-flag first argument to the bundle (whose own
432
- # unknown-option guard rejects it) or rejects a `loop` invocation carrying an
433
- # unrecognised extra argument.
434
- once_mode=0
435
-
436
- if [[ "${1:-}" == "loop" ]]; then
437
- loop_form=1
438
- loop_flag_args=("${@:2}")
439
- else
440
- loop_form=0
441
- loop_flag_args=("$@")
442
- fi
443
-
444
- valid_combo=1
445
- n_once=0
446
- for a in ${loop_flag_args[@]+"${loop_flag_args[@]}"}; do
447
- case "$a" in
448
- --once) n_once=$((n_once + 1)) ;;
449
- *) valid_combo=0 ;;
450
- esac
451
- done
452
- ((n_once > 1)) && valid_combo=0
453
-
454
- if [[ "$valid_combo" == 1 && "$n_once" == 1 ]]; then
455
- once_mode=1
456
- if [[ "$loop_form" == 1 ]]; then
457
- set -- loop
458
- else
459
- set --
460
- fi
461
- fi
462
-
463
- if [[ $# -gt 0 && "$1" != "loop" ]]; then
464
- if is_write_command "${1:-}"; then
465
- run_gtd_command "$@"
466
- exit $?
467
- fi
468
- run_gtd "$@"
469
- exit $?
470
- fi
471
- if [[ "${1:-}" == "loop" && $# -gt 1 ]]; then
472
- echo "gtd: 'loop' takes no arguments" >&2
473
- exit 2
474
- fi
475
- # otherwise ($# -eq 0, or exactly "loop" with no further args, --once already
476
- # stripped above): fall through into the loop body below.
477
-
478
- # Drives the v3 pattern-machine loop: each iteration asks
479
- # `gtd next --json` who's up and what they should do, dispatching on `kind`:
480
- # - "message" (a human rest) -> the non-autonomous halt: print the gate and
481
- # exit 0. Acting on it (opening an editor, notifying, whatever) is an
482
- # outer wrapper's job; this loop only ever drives the AUTONOMOUS states.
483
- # - "script" (a check rest) -> execute `content` (the emitted wrapper
484
- # script) verbatim via bash — exit code deliberately ignored, the outcome
485
- # lives in the tree — then `gtd step <actor>` to capture it; zero new
486
- # commits = green with nothing owed (the terminal signal at idle).
487
- # - "prompt" (an agent rest) -> feed `content` to the agent, run the
488
- # self-validation gate (`gtd validate`, re-prompting on findings — see the
489
- # gate's own comment below), then `gtd step <actor>` ourselves — the
490
- # harness owns ending the turn, not the prompt text.
491
- # `.model` (optional, opaque) is exported as $GTD_LOOP_MODEL for the agent
492
- # adapter invoked at a "prompt" rest; the default adapter appends `--model
493
- # "$GTD_LOOP_MODEL"` only when it's non-empty.
494
- #
495
- # `.memory` (optional, opaque) is the agent-memory scope label. gtd never interprets it; the driver does:
496
- # consecutive "prompt" turns emitting the SAME label continue ONE agent
497
- # session (so the agent still remembers its previous turn), and a changed
498
- # label — or the first turn, or an unset label — starts fresh. That is what
499
- # lets a loop retain memory within itself (grilling ↔ answer, fixing ↔
500
- # checking) and clear it at a phase boundary. The previous prompt turn's scope
501
- # and its agent session id are persisted in the git dir (NOT the working tree,
502
- # so `gtd status` and the pending diff never see them), so retention survives
503
- # across restarts — a grilling loop that halts at its human gate every lap
504
- # still resumes one planning session. The scope, session id, and a
505
- # resume-or-fresh flag are exported as $GTD_LOOP_MEMORY / $GTD_LOOP_SESSION_ID
506
- # / $GTD_LOOP_MEMORY_RESUME; the default adapter turns them into
507
- # `--resume`/`--session-id`. An adapter that ignores them keeps working. A
508
- # remembered session that has since vanished (the retention window elapsed, a
509
- # wiped `~/.claude/projects`, a machine change) degrades to a fresh session
510
- # with a warning rather than failing the turn — see run_agent_turn's own
511
- # comment.
512
-
513
- # new_session_id — mints an opaque session id for a fresh memory scope
514
- # (uuidgen, falling back to the kernel's random uuid, or empty when neither is
515
- # available — the default adapter then omits --session-id entirely).
516
- new_session_id() {
517
- uuidgen 2>/dev/null || cat /proc/sys/kernel/random/uuid 2>/dev/null || echo ""
518
- }
519
-
520
- # scope_of <key> — the scope part of a "<scope>#<hash>" memory key: everything
521
- # before the first "#". Used to decide which existing table row a fresh entry
522
- # into a scope displaces (see write_scope_session).
523
- scope_of() {
524
- printf '%s' "${1%%#*}"
525
- }
526
-
527
- # lookup_scope_session <key> — print the session id remembered for EXACTLY
528
- # this key (a row whose first field string-equals it), or fail with no output
529
- # if no row matches (a lookup miss -> the caller starts fresh). $memory_marker
530
- # absent counts as no rows at all.
531
- lookup_scope_session() {
532
- local key="$1" row_key row_sid
533
- [[ -f "$memory_marker" ]] || return 1
534
- while read -r row_key row_sid; do
535
- if [[ "$row_key" == "$key" ]]; then
536
- printf '%s' "$row_sid"
537
- return 0
538
- fi
539
- done <"$memory_marker"
540
- return 1
541
- }
542
-
543
- # write_scope_session <key> <session_id> — replace the ONE row for this key's
544
- # SCOPE (scope_of) with a fresh row for <key>, leaving every OTHER scope's row
545
- # in the table untouched. A scope has at most one live conversation at any
546
- # moment, so an existing row for this scope is dead the instant a new entry
547
- # into it begins — this is the entire pruning story, no process concept
548
- # needed (see the module's own memory-scope doc comment below). The table is
549
- # rewritten via a sibling temp file + rename, so a crash mid-write never
550
- # leaves a half-written table.
551
- write_scope_session() {
552
- local key="$1" session_id="$2" scope tmp row_key row_sid
553
- scope="$(scope_of "$key")"
554
- tmp="$(mktemp "${memory_marker}.XXXXXX")"
555
- if [[ -f "$memory_marker" ]]; then
556
- while read -r row_key row_sid; do
557
- [[ "$(scope_of "$row_key")" == "$scope" ]] && continue
558
- printf '%s %s\n' "$row_key" "$row_sid" >>"$tmp"
559
- done <"$memory_marker"
560
- fi
561
- printf '%s %s\n' "$key" "$session_id" >>"$tmp"
562
- mv "$tmp" "$memory_marker"
563
- }
564
-
565
- prev_state=""
566
- prev_content=""
567
- prev_head=""
568
- # Where each scope's remembered agent session is persisted across iterations
569
- # and across restarts, as a TABLE — one "<key> <session_id>" row per scope
570
- # (keys contain no whitespace, so plain field-splitting is safe) — never a
571
- # single last-label value: with machine-scoped memory, a scope's key can
572
- # legitimately reappear AFTER the loop has run turns in a completely
573
- # different (or nested) scope — e.g. build.fix -> into a nested health-gate
574
- # child's own agent turn -> back to build.fix, still the same builder
575
- # conversation. "The last label" can't represent that; a table where each
576
- # scope owns exactly one row can. In the git dir, never the work tree.
577
- memory_marker="$(worktree_git_dir 2>/dev/null || echo .git)/gtd-loop-memory"
578
-
579
- # Truncated once here (not every iteration), so the file always reflects the
580
- # current run but survives after the loop exits.
581
- log_path="$(resolve_log_path)"
582
- : >"$log_path"
583
- export GTD_LOOP_LOG="$log_path"
584
- if [[ "$FANCY" == 1 ]]; then
585
- printf '%s📄 log: %s (tail -f to follow)%s\n' "$C_DIM" "$log_path" "$C_RESET"
586
- else
587
- printf '%slog: %s (tail -f to follow)%s\n' "$C_DIM" "$log_path" "$C_RESET"
588
- fi
589
-
590
- # Swappable agent adapter: GTD_LOOP_AGENT_CMD lets any coding agent CLI stand
591
- # in for the default, receiving the prompt via $GTD_LOOP_PROMPT, the state's
592
- # opaque model hint (if any) via $GTD_LOOP_MODEL, and the memory scope via
593
- # $GTD_LOOP_MEMORY / $GTD_LOOP_SESSION_ID / $GTD_LOOP_MEMORY_RESUME ("1" when
594
- # this turn should continue the prior session). An adapter that ignores any of
595
- # these keeps working — they're exported alongside $GTD_LOOP_PROMPT, never
596
- # required. The default adapter maps the memory signal onto claude's own
597
- # session flags: --resume to continue the scope's session, --session-id to pin
598
- # a fresh one so the next same-scope turn can resume it. $1 is the prompt, $2
599
- # the resume flag; the memory scope ($memory/$session_id) is read from the
600
- # enclosing iteration, so a same-turn re-prompt (the validate gate) resumes the
601
- # same session.
602
- invoke_agent() {
603
- local prompt="$1" do_resume="$2" cmd="${GTD_LOOP_AGENT_CMD:-}"
604
- if [[ -z "$cmd" ]]; then
605
- cmd='claude -p "$GTD_LOOP_PROMPT"'
606
- [[ -n "$model" ]] && cmd="$cmd"' --model "$GTD_LOOP_MODEL"'
607
- if [[ "$do_resume" == "1" ]]; then
608
- cmd="$cmd"' --resume "$GTD_LOOP_SESSION_ID"'
609
- elif [[ -n "$session_id" ]]; then
610
- cmd="$cmd"' --session-id "$GTD_LOOP_SESSION_ID"'
611
- fi
612
- cmd="$cmd"' --dangerously-skip-permissions'
613
- fi
614
- GTD_LOOP_PROMPT="$prompt" GTD_LOOP_MODEL="$model" GTD_LOOP_MEMORY="$memory" \
615
- GTD_LOOP_SESSION_ID="$session_id" GTD_LOOP_MEMORY_RESUME="$do_resume" bash -c "$cmd" \
616
- >>"${GTD_LOOP_LOG:-/dev/null}" 2>&1
617
- }
618
-
619
- # run_agent_turn <prompt> <resume> — invoke_agent, with one recovery: a
620
- # resumed turn (do_resume == 1) that fails AND whose output carries the
621
- # missing-session signature (session_gone_since) means the CURRENT scope's
622
- # remembered session no longer exists on disk. Rather than failing the turn
623
- # (and every turn after it, since the dead id would just be resumed again),
624
- # mint a fresh session and rewrite ONLY this scope's row in $memory_marker
625
- # (write_scope_session — every other scope's row survives untouched) — the
626
- # self-healing part, so a later launch doesn't repeat this — and retry the
627
- # SAME prompt once with resume off. $resume and $session_id are the enclosing
628
- # iteration's globals; recovery rewrites them too, so a later validate-gate
629
- # re-prompt in the same turn resumes the newly pinned session. Blind to the
630
- # failure mode when session_gone_since can't tell (no log configured): a
631
- # genuine agent failure then correctly propagates.
632
- run_agent_turn() {
633
- local prompt="$1" do_resume="$2" offset status=0
634
- offset="$(log_offset)"
635
- invoke_agent "$prompt" "$do_resume" && return 0
636
- status=$?
637
- if [[ "$do_resume" == "1" ]] && session_gone_since "$offset"; then
638
- emit_warn "the remembered agent session for '$memory' is gone — continuing in a fresh session (this turn starts without its previous context)"
639
- session_id="$(new_session_id)"
640
- resume=0
641
- write_scope_session "$memory" "$session_id"
642
- invoke_agent "$prompt" 0
643
- return $?
644
- fi
645
- return $status
646
- }
647
-
648
- # The furthest commit already reported to the terminal by report_commits (see
649
- # its own comment) — a global high-water mark rather than a per-beat
650
- # head_before, so a review checkout window's HEAD rewind between beats never
651
- # causes an already-printed transition to replay.
652
- reported_head="$(git rev-parse --verify --quiet HEAD || echo none)"
653
-
654
- # Opening move: capture the human's pending edit so gtd-loop is the ONLY command
655
- # a human runs. A human acts at a gate by editing files (a plan answer, a review
656
- # tick, a code fix) and re-launching the loop — they should never have to run
657
- # `gtd step human` by hand first. So before driving, if the machine currently
658
- # rests at a human gate, step the human once to commit whatever they left, then
659
- # fall into the loop below from the resulting state.
660
- #
661
- # We peek with the pure, mutation-free `gtd next --json` and step ONLY when it
662
- # reports a human rest (`kind == "message"`): at an agent/check rest
663
- # `gtd step human` would refuse out-of-turn (non-zero, aborting under `set -e`),
664
- # and a mid-process restart must simply resume driving, not fail. A clean gate is
665
- # a harmless no-op; a genuine refusal there (a malformed steering file, or an
666
- # edit no `on` pattern accepts) is surfaced and stops the loop rather than being
667
- # driven past. --once treats a captured commit here as the run's one beat and
668
- # exits; a clean/no-op gate captures nothing, so it isn't a beat — it falls
669
- # into the loop below exactly as it would without --once.
670
- if ! peek_json="$(run_gtd next --json 2>&1)"; then
671
- emit_error "could not determine the next step:
672
- $peek_json
673
- Run \`gtd status\` to inspect the repo."
674
- emit_log_pointer
675
- exit 1
676
- fi
677
- peek_kind="$(jq -r .kind <<<"$peek_json")"
678
- if [[ "$peek_kind" == "message" ]]; 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
681
- emit_error "could not capture your changes at the human gate:
682
- $step_out
683
- Fix the reported issue and re-run gtd-loop."
684
- emit_log_pointer
685
- exit 1
686
- fi
687
- # This branch reports nothing — advance the marker past whatever was just
688
- # (silently) captured so the first loop-body beat below doesn't newly
689
- # print it.
690
- reported_head="$(git rev-parse --verify --quiet HEAD || echo none)"
691
- if [[ "$once_mode" == 1 && "$opening_head_before" != "$reported_head" ]]; then
692
- exit 0
693
- fi
694
- fi
695
-
696
- while true; do
697
- if ! next_json="$(run_gtd next --json 2>&1)"; then
698
- emit_error "could not determine the next step:
699
- $next_json
700
- Run \`gtd status\` to inspect the repo."
701
- emit_log_pointer
702
- exit 1
703
- fi
704
-
705
- state="$(jq -r .state <<<"$next_json")"
706
- actor="$(jq -r .actor <<<"$next_json")"
707
- kind="$(jq -r .kind <<<"$next_json")"
708
- content="$(jq -r .content <<<"$next_json")"
709
- model="$(jq -r '.model // empty' <<<"$next_json")"
710
- memory="$(jq -r '.memory // empty' <<<"$next_json")"
711
- label="$(jq -r '.label // .state' <<<"$next_json")"
712
-
713
- if [[ "$kind" == "message" ]]; then
714
- # The non-autonomous halt: print the gate, hand off, exit 0. The human
715
- # (or an outer wrapper) edits and re-launches; the opening move above
716
- # captures what they left.
717
- emit_gate "$state" "$label"
718
- if ! gate_out="$(run_gtd next 2>&1)"; then
719
- emit_error "$gate_out"
720
- emit_log_pointer
721
- exit 1
722
- fi
723
- printf '%s\n' "$gate_out"
724
- exit 0
725
- fi
726
-
727
- if [[ "$kind" == "script" ]]; then
728
- emit_action "$M_CHECK" "$label"
729
- # Execute the emitted wrapper verbatim; its exit code decides nothing
730
- # (the script encodes its outcome in the tree, and `gtd step` captures
731
- # whatever it left there via the state's own `on` patterns) but a non-zero
732
- # exit is surfaced as a warning — its own output goes to the log, and to
733
- # the terminal too when it exits non-zero.
734
- script_offset="$(log_offset)"
735
- script_status=0
736
- bash -c "$content" >>"$GTD_LOOP_LOG" 2>&1 || script_status=$?
737
- if ((script_status != 0)); then
738
- emit_warn "the check script at '$state' exited $script_status — continuing; the outcome is whatever it left in the tree:"
739
- emit_output_since "$script_offset"
740
- fi
741
- if ! step_out="$(run_gtd_command step "$actor" 2>&1)"; then
742
- printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
743
- emit_error "$step_out"
744
- emit_log_pointer
745
- exit 1
746
- fi
747
- printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
748
- if ! report_commits; then
749
- emit_settled "$state"
750
- exit 0
751
- fi
752
- prev_state=""
753
- prev_content=""
754
- if [[ "$once_mode" == 1 ]]; then
755
- exit 0
756
- fi
757
- continue
758
- fi
759
-
760
- # kind == "prompt": stall detection — the same
761
- # agent-owned state and content repeating, WITH NO COMMIT authored since the
762
- # last time we saw this beat, means the last dispatch made no progress; stop
763
- # rather than spin on it. The HEAD check is what distinguishes a genuine
764
- # stall from a return lap back into the same state (e.g. checking ->
765
- # fixing): a static prompt looks identical to last time, but a commit
766
- # landed in between, so HEAD has moved and this isn't a stall.
767
- cur_head="$(git rev-parse --verify --quiet HEAD || echo none)"
768
- if [[ "$state" == "$prev_state" && "$content" == "$prev_content" && "$cur_head" == "$prev_head" ]]; then
769
- next_out="$(run_gtd next 2>&1)" || true
770
- emit_error "no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning.
771
- $next_out"
772
- emit_log_pointer
773
- exit 1
774
- fi
775
- prev_state="$state"
776
- prev_content="$content"
777
- prev_head="$cur_head"
778
-
779
- # Agent-memory scope: look this turn's `memory` key up in the per-scope
780
- # table (lookup_scope_session — exact string equality against a row's full
781
- # key, "<scope>#<hash>", not just its scope part). A hit -> resume that
782
- # session; a miss -> start fresh with a new session id, then write ONLY this
783
- # key's scope's row (write_scope_session), leaving every other scope's row
784
- # untouched — a scope can legitimately go quiet and reappear later (see the
785
- # $memory_marker declaration's own comment) without losing its place. An
786
- # absent/empty key carries no constraint (harness default) and leaves the
787
- # table completely untouched — it may hold live sessions for OTHER scopes
788
- # this turn knows nothing about, so it's neither read nor written here.
789
- resume=0
790
- session_id=""
791
- if [[ -n "$memory" ]]; then
792
- if found_session_id="$(lookup_scope_session "$memory")"; then
793
- resume=1
794
- session_id="$found_session_id"
795
- else
796
- session_id="$(new_session_id)"
797
- fi
798
- write_scope_session "$memory" "$session_id"
799
- fi
800
-
801
- # Run the agent turn (see run_agent_turn's own comment for the adapter
802
- # contract). Then the self-validation gate: a producing state may declare a
803
- # `file:`/`mode:` pair (e.g. the default workflow's grilling/reviewing),
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.
813
- emit_action "$M_AGENT" "$label"
814
- turn_offset="$(log_offset)"
815
- agent_status=0
816
- run_agent_turn "$content" "$resume" || agent_status=$?
817
- if ((agent_status != 0)); then
818
- emit_error "the agent turn at '$state' failed (exit $agent_status):"
819
- emit_output_since "$turn_offset"
820
- emit_log_pointer
821
- exit 1
822
- fi
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
-
833
- fix_attempt=0
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
838
- fix_attempt=$((fix_attempt + 1))
839
- if ((fix_attempt > 3)); then
840
- printf '%s\n' "$validate_out" >>"$GTD_LOOP_LOG"
841
- emit_error "'$state' still fails its own validation script after 3 fix attempts:
842
- $validate_out
843
- Stopping rather than stepping with a malformed steering file."
844
- emit_log_pointer
845
- exit 1
846
- fi
847
- printf '%s\n' "$validate_out" >>"$GTD_LOOP_LOG"
848
- emit_action "$M_FIX" "attempt $fix_attempt"
849
- # Resume the same session when there is one (the agent just wrote the file);
850
- # otherwise a fresh invocation still gets the findings via the prompt.
851
- fix_resume=0
852
- [[ -n "$session_id" ]] && fix_resume=1
853
- turn_offset="$(log_offset)"
854
- agent_status=0
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=$?
856
- if ((agent_status != 0)); then
857
- emit_error "the agent's fix attempt $fix_attempt at '$state' failed (exit $agent_status):"
858
- emit_output_since "$turn_offset"
859
- emit_log_pointer
860
- exit 1
861
- fi
862
- done
863
-
864
- if ! step_out="$(run_gtd_command step "$actor" 2>&1)"; then
865
- printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
866
- emit_error "$step_out"
867
- emit_log_pointer
868
- exit 1
869
- fi
870
- printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
871
- # A no-op step (nothing matched a declared pattern on a clean tree) reports
872
- # zero commits here — report_commits returns non-zero, and that's fine: the
873
- # NEXT iteration's stall check is what catches unchanged state/content.
874
- report_commits || true
875
- if [[ "$once_mode" == 1 ]]; then
876
- exit 0
877
- fi
878
- done