@pmelab/gtd 7.11.1 → 7.11.2

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 (3) hide show
  1. package/bin/gtd +58 -26
  2. package/docs/cli.md +6 -4
  3. package/package.json +1 -1
package/bin/gtd CHANGED
@@ -8,7 +8,8 @@ set -euo pipefail
8
8
  # flag-parsing block below) -> stripped here; `--no-edit` disables the
9
9
  # loop's automatic editor launching for this run, `--edit` forces it on
10
10
  # for this run (overriding an ambient `GTD_NO_EDIT`/`--no-edit` — the two
11
- # are mutually exclusive), `--once` restricts the loop to exactly one beat
11
+ # are mutually exclusive: naming both together is a dedicated usage error,
12
+ # exit 2), `--once` restricts the loop to exactly one beat
12
13
  # (one human gate, one script check+step, or one agent prompt+step) before
13
14
  # exiting, and `--no-notify` (or `GTD_NO_NOTIFY`) suppresses the loop's
14
15
  # Herdr desktop notifications for this run while leaving its silent Herdr
@@ -183,11 +184,14 @@ parse_subject() {
183
184
  fi
184
185
  }
185
186
 
186
- # report_commits <head_before> — walks <head_before>..HEAD in order, emitting
187
- # a transition/commit line per new commit; returns non-zero (having emitted
188
- # nothing) when there were no new commits.
187
+ # report_commits — walks "$reported_head"..HEAD in order, emitting a
188
+ # transition/commit line per new commit, and advances $reported_head to HEAD
189
+ # when it reported at least one; returns non-zero (having emitted nothing,
190
+ # and leaving $reported_head unchanged) when there were no new commits. Global
191
+ # rather than per-beat so the review checkout window's HEAD rewinds between
192
+ # beats never cause an already-reported commit to replay.
189
193
  report_commits() {
190
- local head_before="$1" sha subject files reported=0
194
+ local sha subject files reported=0
191
195
  while IFS= read -r sha; do
192
196
  [[ -z "$sha" ]] && continue
193
197
  reported=1
@@ -198,7 +202,10 @@ report_commits() {
198
202
  transition) emit_transition "$S_FROM" "$S_TO" "$files" ;;
199
203
  capture | squash) emit_commit "$subject" "$files" ;;
200
204
  esac
201
- done < <(git rev-list --reverse "$head_before"..HEAD)
205
+ done < <(git rev-list --reverse "$reported_head"..HEAD)
206
+ if [[ "$reported" == 1 ]]; then
207
+ reported_head="$(git rev-parse HEAD)"
208
+ fi
202
209
  [[ "$reported" == 1 ]]
203
210
  }
204
211
 
@@ -230,15 +237,17 @@ resolve_log_path() {
230
237
  # recognised in exactly two positions — bare (e.g. `gtd --edit --once`) or
231
238
  # immediately after `loop` (e.g. `gtd loop --once`) — in any order, each at
232
239
  # most once. `--edit` and `--no-edit` are mutually exclusive (forcing and
233
- # suppressing the editor in the same run is not a coherent request);
234
- # `--no-notify` conflicts with nothing (it's orthogonal to editing and to
235
- # `--once`). A recognised, valid combination is stripped here before anything
236
- # below looks at "$@", so the bundle's own unknown-option guard never sees it;
237
- # anything else (an unknown flag, a duplicate/conflicting one, or a flag mixed
238
- # with a real subcommand) is left untouched and falls through to the existing
239
- # dispatch below, which forwards a non-flag first argument to the bundle
240
- # (whose own unknown-option guard rejects it) or rejects a `loop` invocation
241
- # carrying an unrecognised extra argument.
240
+ # suppressing the editor in the same run is not a coherent request): naming
241
+ # both, with no other unrecognised token present, is rejected right here with
242
+ # a dedicated usage error (exit 2) — it never reaches the bundle's own
243
+ # unknown-option guard. `--no-notify` conflicts with nothing (it's orthogonal
244
+ # to editing and to `--once`). A recognised, valid combination is stripped
245
+ # here before anything below looks at "$@", so the bundle's own unknown-option
246
+ # guard never sees it; anything else (an unknown flag, a duplicate flag, or a
247
+ # flag mixed with a real subcommand) is left untouched and falls through to
248
+ # the existing dispatch below, which forwards a non-flag first argument to the
249
+ # bundle (whose own unknown-option guard rejects it) or rejects a `loop`
250
+ # invocation carrying an unrecognised extra argument.
242
251
  edit_disabled=0
243
252
  edit_forced=0
244
253
  once_mode=0
@@ -255,6 +264,7 @@ else
255
264
  fi
256
265
 
257
266
  valid_combo=1
267
+ saw_unknown=0
258
268
  n_no_edit=0
259
269
  n_edit=0
260
270
  n_once=0
@@ -265,10 +275,17 @@ for a in ${loop_flag_args[@]+"${loop_flag_args[@]}"}; do
265
275
  --edit | -e) n_edit=$((n_edit + 1)) ;;
266
276
  --once) n_once=$((n_once + 1)) ;;
267
277
  --no-notify) n_no_notify=$((n_no_notify + 1)) ;;
268
- *) valid_combo=0 ;;
278
+ *) saw_unknown=1 ;;
269
279
  esac
270
280
  done
271
- if ((n_no_edit > 1 || n_edit > 1 || n_once > 1 || n_no_notify > 1 || (n_no_edit >= 1 && n_edit >= 1))); then
281
+ # A pure loop-flag invocation (no unrecognised token) naming both --edit and
282
+ # --no-edit gets a dedicated usage error here, rather than falling through to
283
+ # the bundle's own unknown-option rejection below.
284
+ if [[ "$saw_unknown" == 0 ]] && ((n_edit >= 1 && n_no_edit >= 1)); then
285
+ echo "gtd: --edit and --no-edit are mutually exclusive" >&2
286
+ exit 2
287
+ fi
288
+ if ((saw_unknown == 1 || n_no_edit > 1 || n_edit > 1 || n_once > 1 || n_no_notify > 1)); then
272
289
  valid_combo=0
273
290
  fi
274
291
 
@@ -455,7 +472,7 @@ Fix the reported issue and re-run gtd-loop."
455
472
  # A new commit was captured — render it, then return normally so the
456
473
  # caller keeps driving, unless --once says this one human gate is the whole
457
474
  # run.
458
- report_commits "$head_before"
475
+ report_commits
459
476
  if [[ "$once_mode" == 1 ]]; then
460
477
  exit 0
461
478
  fi
@@ -468,6 +485,7 @@ trap 'code=$?; if [ "$code" -ne 0 ]; then
468
485
 
469
486
  prev_state=""
470
487
  prev_content=""
488
+ prev_head=""
471
489
  # Where the last "prompt" turn's `memory` scope + session id are remembered
472
490
  # across iterations and across restarts. In the git dir, never the work tree.
473
491
  memory_marker="$(worktree_git_dir 2>/dev/null || echo .git)/gtd-loop-memory"
@@ -512,6 +530,12 @@ run_agent_turn() {
512
530
  >>"${GTD_LOOP_LOG:-/dev/null}" 2>&1
513
531
  }
514
532
 
533
+ # The furthest commit already reported to the terminal by report_commits (see
534
+ # its own comment) — a global high-water mark rather than a per-beat
535
+ # head_before, so a review checkout window's HEAD rewind between beats never
536
+ # causes an already-printed transition to replay.
537
+ reported_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
538
+
515
539
  # Opening move: capture the human's pending edit so gtd-loop is the ONLY command
516
540
  # a human runs. A human acts at a gate by editing files (a plan answer, a review
517
541
  # tick, a code fix) and re-launching the loop — they should never have to run
@@ -553,7 +577,11 @@ Fix the reported issue and re-run gtd-loop."
553
577
  emit_log_pointer
554
578
  exit 1
555
579
  fi
556
- if [[ "$once_mode" == 1 && "$opening_head_before" != "$(git rev-parse HEAD 2>/dev/null || echo none)" ]]; then
580
+ # This branch reports nothing today — advance the marker past whatever was
581
+ # just (silently) captured so the first loop-body beat below doesn't newly
582
+ # print it.
583
+ reported_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
584
+ if [[ "$once_mode" == 1 && "$opening_head_before" != "$reported_head" ]]; then
557
585
  exit 0
558
586
  fi
559
587
  else
@@ -598,7 +626,6 @@ while true; do
598
626
 
599
627
  if [[ "$kind" == "script" ]]; then
600
628
  emit_action "$M_CHECK" "$label"
601
- head_before="$(git rev-parse HEAD 2>/dev/null || echo none)"
602
629
  # Execute the emitted wrapper verbatim; its exit code is deliberately
603
630
  # ignored (`|| true` under `set -e`) — the script encodes its outcome in
604
631
  # the tree, and `gtd step` captures whatever it left there via the state's
@@ -611,7 +638,7 @@ while true; do
611
638
  exit 1
612
639
  fi
613
640
  printf '%s\n' "$step_out" >>"$GTD_LOOP_LOG"
614
- if ! report_commits "$head_before"; then
641
+ if ! report_commits; then
615
642
  herdr_report idle "$label"
616
643
  herdr_release
617
644
  emit_settled "$state"
@@ -626,9 +653,14 @@ while true; do
626
653
  fi
627
654
 
628
655
  # kind == "prompt": stall detection (skills/loop/SKILL.md) — the same
629
- # agent-owned state and content repeating means the last dispatch made no
630
- # progress; stop rather than spin on it.
631
- if [[ "$state" == "$prev_state" && "$content" == "$prev_content" ]]; then
656
+ # agent-owned state and content repeating, WITH NO COMMIT authored since the
657
+ # last time we saw this beat, means the last dispatch made no progress; stop
658
+ # rather than spin on it. The HEAD check is what distinguishes a genuine
659
+ # stall from a return lap through a human gate (e.g. plan-review ->
660
+ # planning): a static prompt looks identical to last time, but a commit
661
+ # landed in between, so HEAD has moved and this isn't a stall.
662
+ cur_head="$(git rev-parse HEAD 2>/dev/null || echo none)"
663
+ if [[ "$state" == "$prev_state" && "$content" == "$prev_content" && "$cur_head" == "$prev_head" ]]; then
632
664
  next_out="$(run_gtd next 2>&1)" || true
633
665
  emit_error "no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning.
634
666
  $next_out"
@@ -637,6 +669,7 @@ $next_out"
637
669
  fi
638
670
  prev_state="$state"
639
671
  prev_content="$content"
672
+ prev_head="$cur_head"
640
673
 
641
674
  # Agent-memory scope: compare this turn's `memory` label against the last
642
675
  # prompt turn's (persisted in $memory_marker). Same non-empty label AND a
@@ -673,7 +706,6 @@ $next_out"
673
706
  # driver-side equivalent, so the next rest is only ever handed a well-formed
674
707
  # file. gtd itself never validates at step time — this is the loop's job.
675
708
  emit_action "$M_AGENT" "$label"
676
- head_before="$(git rev-parse HEAD 2>/dev/null || echo none)"
677
709
  run_agent_turn "$content" "$resume"
678
710
 
679
711
  fix_attempt=0
@@ -704,7 +736,7 @@ Stopping rather than stepping with a malformed steering file."
704
736
  # A no-op step (nothing matched a declared pattern on a clean tree) reports
705
737
  # zero commits here — report_commits returns non-zero, and that's fine: the
706
738
  # NEXT iteration's stall check is what catches unchanged state/content.
707
- report_commits "$head_before" || true
739
+ report_commits || true
708
740
  if [[ "$once_mode" == 1 ]]; then
709
741
  exit 0
710
742
  fi
package/docs/cli.md CHANGED
@@ -113,10 +113,12 @@ each recognized bare (`gtd --no-edit`) or immediately after `loop`
113
113
  `gtd loop` are themselves equivalent. All three combine freely with each other,
114
114
  in any order (e.g. `gtd --edit --once`, `gtd loop --once --edit`), except
115
115
  `--edit`/`--no-edit` together, which conflict (forcing and suppressing the
116
- editor in the same run is not a coherent request). Any other placement (e.g.
117
- `gtd step --once`), a duplicated flag (`gtd --once --once`), or the
118
- `--edit`/`--no-edit` conflict is a usage error, surfaced via whatever guard the
119
- misplaced argument first reaches (the compiled bundle's own
116
+ editor in the same run is not a coherent request): naming both, in either
117
+ position, is rejected right in `bin/gtd` with a dedicated usage error
118
+ (`gtd: --edit and --no-edit are mutually exclusive`, exit 2) — it never reaches
119
+ the compiled bundle. Any other placement (e.g. `gtd step --once`) or a
120
+ duplicated flag (`gtd --once --once`) is still a usage error, but surfaced via
121
+ whatever guard the misplaced argument first reaches (the compiled bundle's own
120
122
  unknown-option/unknown-command rejection for a misplaced flag, or
121
123
  `gtd: 'loop' takes no arguments` for one misplaced after `loop`) — never
122
124
  silently ignored or silently resolved one way.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "7.11.1",
3
+ "version": "7.11.2",
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": {