@pmelab/gtd 8.6.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.
- package/README.md +367 -179
- package/dist/gtd.bundle.mjs +1234 -352
- package/package.json +2 -3
- 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
|