@pmelab/gtd 8.1.0 → 8.2.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 CHANGED
@@ -285,20 +285,106 @@ re-launching it — its opening move captures whatever you left, so you never ru
285
285
 
286
286
  Anything richer at that boundary — opening your editor, desktop notifications,
287
287
  terminal-multiplexer status — is the job of an outer wrapper around `gtd`, not
288
- of the loop itself. Pass `--once` to restrict a run to exactly one beat — one
289
- human-gate capture, one check, or one agent turn — instead of driving all the
290
- way to idle.
288
+ of the loop itself; see
289
+ [Terminal-multiplexer status: a herdr wrapper](#terminal-multiplexer-status-a-herdr-wrapper)
290
+ below for a worked example. Pass `--once` to restrict a run to exactly one beat
291
+ — one human-gate capture, one check, or one agent turn — instead of driving all
292
+ the way to idle.
291
293
 
292
294
  Bare `gtd` prints one line per event — colored and emoji on a real terminal,
293
295
  plain ASCII under `NO_COLOR` or when piped — and redirects the noisier
294
296
  agent/check/step subprocess output to a per-repo/per-worktree log file (its path
295
- is the run's first output line, ready to `tail -f`).
297
+ is the run's first output line, ready to `tail -f`). Any execution that FAILS
298
+ also replays its last 20 lines of output inline on stderr, on top of the log
299
+ still holding the complete record: an agent turn that fails stops the loop (it
300
+ would fail identically next lap), while a check script that exits non-zero is
301
+ reported as a warning and the loop carries on, because the outcome lives in the
302
+ tree, not in the script's exit code.
296
303
 
297
304
  A workflow state can declare an optional `label:` — a human-readable display
298
305
  name surfaced in `gtd next --json`/`gtd status`. The driver uses it for its
299
306
  per-beat progress lines; an outer wrapper (a terminal multiplexer, a notifier)
300
307
  can use it the same way.
301
308
 
309
+ ### Terminal-multiplexer status: a herdr wrapper
310
+
311
+ [herdr](https://herdr.dev) shows a per-pane status in its sidebar. This wrapper
312
+ reports `gtd`'s own lifecycle to that status without any herdr-specific
313
+ knowledge in `gtd` itself: `working` while the loop drives, `blocked` when it
314
+ comes to rest on a human (a gate, a stall, a non-zero exit), and `idle`
315
+ (rendered as "done" once the pane's tab is unfocused) when a run ends without
316
+ anything owed. It needs nothing from `gtd` beyond `gtd next --json`'s `.actor`
317
+ field, which the loop's driver already reads at every beat.
318
+
319
+ Save this as `~/.local/bin/gtdh`, `chmod +x` it, and run `gtdh` in place of
320
+ `gtd` — it's a plain bash file, so it works from fish or any other shell, and it
321
+ forwards every argument (`gtdh --once`, `gtdh status`, ...).
322
+
323
+ ```bash
324
+ #!/usr/bin/env bash
325
+ # Report gtd's own lifecycle to the herdr pane it runs in: working while the
326
+ # loop drives, blocked when it rests on you, idle (shown as "done") otherwise.
327
+ # Needs nothing from gtd but `gtd next --json`. Outside herdr it is a plain
328
+ # passthrough. Requires `jq` — so does gtd's own loop driver.
329
+ set -uo pipefail
330
+
331
+ pane="${HERDR_PANE_ID:-}"
332
+
333
+ # `--agent gtd` is deliberate: herdr only lets screen detection (or a visible
334
+ # approval prompt) override a reported state when the reported label names a
335
+ # KNOWN agent. "gtd" names none, so the loop's `claude -p` turns can never move
336
+ # this pane's status.
337
+ report() { # report <state>
338
+ [ -n "$pane" ] || return 0
339
+ herdr pane report-agent "$pane" \
340
+ --source custom:gtd --agent gtd --state "$1" >/dev/null 2>&1 || true
341
+ }
342
+
343
+ report working
344
+ trap 'report blocked; exit 130' INT TERM
345
+
346
+ # HERDR_PANE_ID is unset for the loop: herdr's Claude Code hook needs it, and
347
+ # without it no `claude -p` turn reports a claude SESSION identity for this
348
+ # pane. That is load-bearing, not cosmetic — a pane that owns one rejects every
349
+ # later state report, so the report below would be dropped.
350
+ env -u HERDR_PANE_ID gtd "$@"
351
+ rc=$?
352
+
353
+ # Whose turn is it now? `gtd next --json` is mutation-free. A human actor means
354
+ # gtd is waiting on you; anything else means the run ended with nothing owed.
355
+ actor="$(gtd next --json 2>/dev/null | jq -r '.actor // ""' 2>/dev/null || true)"
356
+
357
+ if [ "$rc" -ne 0 ] || [ -z "$actor" ] || [ "$actor" = human ]; then
358
+ report blocked
359
+ else
360
+ report idle
361
+ fi
362
+
363
+ exit $rc
364
+ ```
365
+
366
+ A few things to know before relying on it:
367
+
368
+ - **Give `gtd` its own pane.** Run an interactive `claude` (or any agent whose
369
+ herdr integration reports session identity) in the same pane and that pane
370
+ owns a `herdr:claude` session until the process exits; while it does, the
371
+ wrapper's reports are silently ignored and the sidebar stops tracking `gtd`.
372
+ - **`blocked` covers the resting `idle` state too** — gtd's own `idle` is a
373
+ human gate (it waits for you to write a steering file), so a finished cycle
374
+ reads as "your turn", which is what it is.
375
+ - The status **persists after the wrapper exits** — that's the point: the
376
+ sidebar keeps showing which worktree is waiting on you. Hand the pane back to
377
+ ordinary detection with
378
+ `herdr pane release-agent "$HERDR_PANE_ID" --source custom:gtd --agent gtd`.
379
+ - A non-zero exit (a stall, a refusal, an error) reports `blocked`: herdr has no
380
+ failure state, and those all need a human.
381
+ - Ctrl-C reports `blocked` rather than leaving a stale `working`.
382
+
383
+ Optionally,
384
+ `herdr pane report-metadata "$HERDR_PANE_ID" --source custom:gtd-display --token summary=<text>`
385
+ sets a value renderable as `$summary` in an Agent sidebar row, if you want more
386
+ than the state itself.
387
+
302
388
  ## Configuration
303
389
 
304
390
  gtd reads an optional `.gtdrc` config file via
@@ -483,9 +569,11 @@ compiler's job at load time.
483
569
  per `review`-mode chunk that still has an unchecked hunk (an outline of the
484
570
  packages left to review) plus check/uncheck actions over those chunks,
485
571
  go-to-definition from a `review`-mode hunk line into the file it points at (at
486
- its `#line`), symbols over a `qa`-mode file's open questions, diagnostics for
487
- both (live as you edit), and a `gtd.openSteeringFile` command that jumps to the
488
- current state's steering file.
572
+ its `#line`), symbols over a `qa`-mode file's open questions plus "pick this
573
+ option"/"uncheck this option" code actions on each option — offered anywhere on
574
+ the option's list item, including any wrapped continuation lines, not just its
575
+ own `- [ ]` line — diagnostics for both (live as you edit), and a
576
+ `gtd.openSteeringFile` command that jumps to the current state's steering file.
489
577
 
490
578
  It is config-driven via each state's `file:`/`mode:`, and falls back to basename
491
579
  dispatch (`REVIEW.md` → `review`) with no config in sight. `qa` and `review` are
package/bin/gtd CHANGED
@@ -44,12 +44,12 @@ if [[ "$FANCY" == 1 ]]; then
44
44
  C_RESET=$'\e[0m'; C_BOLD=$'\e[1m'; C_DIM=$'\e[2m'
45
45
  C_CYAN=$'\e[36m'; C_GREEN=$'\e[32m'; C_YELLOW=$'\e[33m'; C_RED=$'\e[31m'
46
46
  M_TRANS="➡️"; M_AGENT="🤖"; M_CHECK="🔧"; M_HUMAN="✍️"
47
- M_FIX="🔁"; M_COMMIT="✅"; M_SETTLE="🎉"; M_ERROR="❌"; M_ELLIPSIS="…"
47
+ M_FIX="🔁"; M_COMMIT="✅"; M_SETTLE="🎉"; M_ERROR="❌"; M_WARN="⚠️"; M_ELLIPSIS="…"
48
48
  else
49
49
  C_RESET=""; C_BOLD=""; C_DIM=""
50
50
  C_CYAN=""; C_GREEN=""; C_YELLOW=""; C_RED=""
51
51
  M_TRANS="->"; M_AGENT="[agent]"; M_CHECK="[check]"; M_HUMAN="[you]"
52
- M_FIX="[fix]"; M_COMMIT="[commit]"; M_SETTLE="[done]"; M_ERROR="[err]"; M_ELLIPSIS="..."
52
+ M_FIX="[fix]"; M_COMMIT="[commit]"; M_SETTLE="[done]"; M_ERROR="[err]"; M_WARN="[warn]"; M_ELLIPSIS="..."
53
53
  fi
54
54
 
55
55
  # emit_file_rows <files> — render a commit's changed files as dim, indented
@@ -114,8 +114,10 @@ emit_error() {
114
114
  }
115
115
 
116
116
  # emit_log_pointer — the "see the log" line printed right after every
117
- # emit_error call, to stderr: suppression must never hide a failure, so every
118
- # error points at where the redirected subprocess output actually went.
117
+ # emit_error call, to stderr: it names the log as the OVERFLOW channel — the
118
+ # failing execution's own output is already replayed inline (see emit_block/
119
+ # emit_output_since below); the log is where the rest of it, and the full
120
+ # run's other output, lives.
119
121
  emit_log_pointer() {
120
122
  if [[ "$FANCY" == 1 ]]; then
121
123
  printf '%s📄 see %s%s\n' "$C_DIM" "$GTD_LOOP_LOG" "$C_RESET" >&2
@@ -124,6 +126,58 @@ emit_log_pointer() {
124
126
  fi
125
127
  }
126
128
 
129
+ # Suppressed subprocess output goes to $GTD_LOOP_LOG in full, but a FAILING
130
+ # execution also replays inline: mark the log's size before the call, print
131
+ # what was appended since. Bounded to the last $ERR_TAIL_LINES lines — the log
132
+ # holds the rest, and the overflow row says where.
133
+ ERR_TAIL_LINES=20
134
+
135
+ # log_offset — the log's current size in bytes (0 when it doesn't exist yet).
136
+ log_offset() {
137
+ local n=0
138
+ if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then n="$(wc -c <"$GTD_LOOP_LOG")"; fi
139
+ printf '%s' "${n// /}"
140
+ }
141
+
142
+ # emit_block <text> — replay captured subprocess output to stderr, dim and
143
+ # indented like emit_file_rows, capped at the LAST $ERR_TAIL_LINES lines with
144
+ # an overflow row naming the log. Empty <text> prints nothing. CSI escapes are
145
+ # stripped under FANCY=0 (the plain-ASCII promise), using a sed form (\e, not
146
+ # \x1b) that both BSD and GNU sed accept.
147
+ emit_block() {
148
+ local text="$1" total overflow
149
+ [[ -z "$text" ]] && return 0
150
+ if [[ "$FANCY" == 0 ]]; then
151
+ text="$(sed $'s/\e\\[[0-9;]*[a-zA-Z]//g' <<<"$text")"
152
+ fi
153
+ total="$(wc -l <<<"$text")"
154
+ total="${total// /}"
155
+ if ((total > ERR_TAIL_LINES)); then
156
+ overflow=$((total - ERR_TAIL_LINES))
157
+ printf ' %s%s (%d earlier lines in %s)%s\n' "$C_DIM" "$M_ELLIPSIS" "$overflow" "$GTD_LOOP_LOG" "$C_RESET" >&2
158
+ text="$(tail -n "$ERR_TAIL_LINES" <<<"$text")"
159
+ fi
160
+ while IFS= read -r line; do
161
+ printf ' %s%s%s\n' "$C_DIM" "$line" "$C_RESET" >&2
162
+ done <<<"$text"
163
+ }
164
+
165
+ # emit_output_since <offset> — emit_block over the log bytes appended since
166
+ # <offset>.
167
+ emit_output_since() {
168
+ local offset="$1" bytes=""
169
+ if [[ -n "${GTD_LOOP_LOG:-}" && -f "$GTD_LOOP_LOG" ]]; then
170
+ bytes="$(tail -c "+$((offset + 1))" "$GTD_LOOP_LOG" 2>/dev/null || true)"
171
+ fi
172
+ emit_block "$bytes"
173
+ }
174
+
175
+ # emit_warn <message> — a surfaced but NON-fatal failure (a check script that
176
+ # exited non-zero).
177
+ emit_warn() {
178
+ printf '%s %s%s%s\n' "$M_WARN" "$C_YELLOW" "$1" "$C_RESET" >&2
179
+ }
180
+
127
181
  # ── Git-derivation helpers (parse_subject, report_commits) ──────────────────
128
182
 
129
183
  # parse_subject <subject> — sets S_ACTOR / S_FROM / S_TO / S_KIND. Patterns are
@@ -375,9 +429,10 @@ fi
375
429
 
376
430
  while true; do
377
431
  if ! next_json="$(run_gtd next --json 2>&1)"; then
378
- echo "gtd-loop: could not determine the next step:" >&2
379
- echo "$next_json" >&2
380
- echo "Run \`gtd status\` to inspect the repo." >&2
432
+ emit_error "could not determine the next step:
433
+ $next_json
434
+ Run \`gtd status\` to inspect the repo."
435
+ emit_log_pointer
381
436
  exit 1
382
437
  fi
383
438
 
@@ -394,17 +449,29 @@ while true; do
394
449
  # (or an outer wrapper) edits and re-launches; the opening move above
395
450
  # captures what they left.
396
451
  emit_gate "$state" "$label"
397
- run_gtd next
452
+ if ! gate_out="$(run_gtd next 2>&1)"; then
453
+ emit_error "$gate_out"
454
+ emit_log_pointer
455
+ exit 1
456
+ fi
457
+ printf '%s\n' "$gate_out"
398
458
  exit 0
399
459
  fi
400
460
 
401
461
  if [[ "$kind" == "script" ]]; then
402
462
  emit_action "$M_CHECK" "$label"
403
- # Execute the emitted wrapper verbatim; its exit code is deliberately
404
- # ignored (`|| true` under `set -e`) — the script encodes its outcome in
405
- # the tree, and `gtd step` captures whatever it left there via the state's
406
- # own `on` patterns. Its own output goes to the log, not the terminal.
407
- bash -c "$content" >>"$GTD_LOOP_LOG" 2>&1 || true
463
+ # Execute the emitted wrapper verbatim; its exit code decides nothing
464
+ # (the script encodes its outcome in the tree, and `gtd step` captures
465
+ # whatever it left there via the state's own `on` patterns) but a non-zero
466
+ # exit is surfaced as a warning — its own output goes to the log, and to
467
+ # the terminal too when it exits non-zero.
468
+ script_offset="$(log_offset)"
469
+ script_status=0
470
+ bash -c "$content" >>"$GTD_LOOP_LOG" 2>&1 || script_status=$?
471
+ if ((script_status != 0)); then
472
+ emit_warn "the check script at '$state' exited $script_status — continuing; the outcome is whatever it left in the tree:"
473
+ emit_output_since "$script_offset"
474
+ fi
408
475
  if ! step_out="$(run_gtd step "$actor" 2>&1)"; then
409
476
  printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
410
477
  emit_error "$step_out"
@@ -478,24 +545,42 @@ $next_out"
478
545
  # driver-side equivalent, so the next rest is only ever handed a well-formed
479
546
  # file. gtd itself never validates at step time — this is the loop's job.
480
547
  emit_action "$M_AGENT" "$label"
481
- run_agent_turn "$content" "$resume"
548
+ turn_offset="$(log_offset)"
549
+ agent_status=0
550
+ run_agent_turn "$content" "$resume" || agent_status=$?
551
+ if ((agent_status != 0)); then
552
+ emit_error "the agent turn at '$state' failed (exit $agent_status):"
553
+ emit_output_since "$turn_offset"
554
+ emit_log_pointer
555
+ exit 1
556
+ fi
482
557
 
483
558
  fix_attempt=0
484
559
  while ! validate_out="$(run_gtd validate 2>&1)"; do
485
560
  fix_attempt=$((fix_attempt + 1))
486
561
  if ((fix_attempt > 3)); then
562
+ printf '%s\n' "$validate_out" >>"$GTD_LOOP_LOG"
487
563
  emit_error "'$state' still fails \`gtd validate\` after 3 fix attempts:
488
564
  $validate_out
489
565
  Stopping rather than stepping with a malformed steering file."
490
566
  emit_log_pointer
491
567
  exit 1
492
568
  fi
569
+ printf '%s\n' "$validate_out" >>"$GTD_LOOP_LOG"
570
+ emit_action "$M_FIX" "attempt $fix_attempt"
493
571
  # Resume the same session when there is one (the agent just wrote the file);
494
572
  # otherwise a fresh invocation still gets the findings via the prompt.
495
573
  fix_resume=0
496
574
  [[ -n "$session_id" ]] && fix_resume=1
497
- emit_action "$M_FIX" "attempt $fix_attempt"
498
- 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"
575
+ turn_offset="$(log_offset)"
576
+ agent_status=0
577
+ 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=$?
578
+ if ((agent_status != 0)); then
579
+ emit_error "the agent's fix attempt $fix_attempt at '$state' failed (exit $agent_status):"
580
+ emit_output_since "$turn_offset"
581
+ emit_log_pointer
582
+ exit 1
583
+ fi
499
584
  done
500
585
 
501
586
  if ! step_out="$(run_gtd step "$actor" 2>&1)"; then
@@ -204397,11 +204397,28 @@ const splitQuestionBlocks = (lines, start) => {
204397
204397
  /** A markdown task-list checkbox line: `- [ ]` / `- [x]` / `* [X]`, optional leading indent, optional trailing text. */
204398
204398
  const CHECKBOX_RE = /^\s*[-*]\s*\[([ xX])\]\s?(.*)$/;
204399
204399
  /**
204400
+ * The body index of the last line belonging to the list item that starts at
204401
+ * `index`: the run of following lines that are neither blank nor a checkbox of
204402
+ * their own (see the span rule in the module doc). Indentation is NOT
204403
+ * required — an unindented lazy wrap is as much part of the item as an
204404
+ * indented one.
204405
+ */
204406
+ const itemEndIndex = (body, index) => {
204407
+ let end = index;
204408
+ for (let i = index + 1; i < body.length; i += 1) {
204409
+ const line = body[i];
204410
+ if (line.trim().length === 0 || CHECKBOX_RE.test(line)) break;
204411
+ end = i;
204412
+ }
204413
+ return end;
204414
+ };
204415
+ /**
204400
204416
  * Extracts the checkbox options from a question block's body, in document
204401
204417
  * order. The LAST option is the free-text slot (`freeText: true`); its text is
204402
204418
  * normalized to `""` when it still carries the unfilled `FREE_TEXT_PLACEHOLDER`.
204403
204419
  * `bodyStart` is the absolute 0-based line index of `body[0]` (the line right
204404
- * after the `###` heading), so each option carries its true source line.
204420
+ * after the `###` heading), so each option carries its true source line and
204421
+ * span (`itemEndIndex`).
204405
204422
  */
204406
204423
  const parseOptions = (body, bodyStart) => {
204407
204424
  const raw = [];
@@ -204411,7 +204428,8 @@ const parseOptions = (body, bodyStart) => {
204411
204428
  raw.push({
204412
204429
  checked: match[1] !== " ",
204413
204430
  text: match[2].trim(),
204414
- sourceLine: bodyStart + i
204431
+ sourceLine: bodyStart + i,
204432
+ bodyIndex: i
204415
204433
  });
204416
204434
  });
204417
204435
  const lastIndex = raw.length - 1;
@@ -204422,7 +204440,8 @@ const parseOptions = (body, bodyStart) => {
204422
204440
  checked: option.checked,
204423
204441
  text: normalized,
204424
204442
  freeText,
204425
- sourceLine: option.sourceLine
204443
+ sourceLine: option.sourceLine,
204444
+ endLine: bodyStart + itemEndIndex(body, option.bodyIndex)
204426
204445
  };
204427
204446
  });
204428
204447
  };
@@ -204704,7 +204723,7 @@ const questionSymbols = (content) => {
204704
204723
  const children = question.options.map((option) => ({
204705
204724
  name: `${option.checked ? "[x]" : "[ ]"} ${option.text || "your answer"}`,
204706
204725
  kind: import_main.SymbolKind.Boolean,
204707
- range: lineRange(lines, option.sourceLine),
204726
+ range: spanRange(lines, option.sourceLine, option.endLine),
204708
204727
  selectionRange: lineRange(lines, option.sourceLine)
204709
204728
  }));
204710
204729
  return {
@@ -204886,11 +204905,12 @@ const optionCodeAction = (uri, content, question, option) => {
204886
204905
  };
204887
204906
  };
204888
204907
  /**
204889
- * Code actions for a `qa`-mode file: on an open question's option line, "pick
204890
- * this option" (radio semantics — check it and uncheck every sibling in the
204891
- * same question, so exactly one stays ticked) or "uncheck this option" when it
204892
- * is already the chosen one. No action off an option line, or on an
204893
- * answered-section (prose) question.
204908
+ * Code actions for a `qa`-mode file: anywhere on an open question's option's
204909
+ * list item — its `- [ ]` line or any of its wrapped continuation lines (see
204910
+ * `QuestionOption.endLine`) — "pick this option" (radio semantics — check it
204911
+ * and uncheck every sibling in the same question, so exactly one stays
204912
+ * ticked) or "uncheck this option" when it is already the chosen one. No
204913
+ * action off an option's span, or on an answered-section (prose) question.
204894
204914
  */
204895
204915
  const questionCodeActions = (uri, content, range) => {
204896
204916
  const { questions } = parseOpenQuestions(content);
@@ -204898,7 +204918,7 @@ const questionCodeActions = (uri, content, range) => {
204898
204918
  const actions = [];
204899
204919
  for (const question of questions) {
204900
204920
  if (question.status !== "open") continue;
204901
- const option = question.options.find((o) => o.sourceLine === cursorLine);
204921
+ const option = question.options.find((o) => cursorLine >= o.sourceLine && cursorLine <= o.endLine);
204902
204922
  if (!option) continue;
204903
204923
  const action = optionCodeAction(uri, content, question, option);
204904
204924
  if (action) actions.push(action);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "8.1.0",
3
+ "version": "8.2.0",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {