@pmelab/gtd 8.5.0 → 9.0.0

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