@pmelab/gtd 2.0.0 → 2.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
@@ -67,6 +67,9 @@ gtd next --json # ask who's up and what they should do
67
67
  See [The reference loop driver](#the-reference-loop-driver) for a full script
68
68
  implementing this protocol, and [`skills/loop/SKILL.md`](skills/loop/SKILL.md)
69
69
  for the agent-facing instructions that follow the same pinned contract.
70
+ `gtd-loop`, installed alongside `gtd` (see below), is the packaged, ready-to-run
71
+ implementation of that same script for anyone who doesn't want to drive the loop
72
+ by hand.
70
73
 
71
74
  ## Installation
72
75
 
@@ -113,6 +116,12 @@ to cwd, so it refuses with a clear error if invoked from a subdirectory.
113
116
  `--jsn`) is rejected with a usage error rather than silently ignored, so a
114
117
  mistyped flag can never degrade a JSON caller to plain-text mode.
115
118
 
119
+ One nuance to "(no mutation)": `next` and `status` never author commits or
120
+ change workflow state, but while a human review is pending they do maintain the
121
+ review checkout window (closing it to read state, re-arming it on the way out —
122
+ see [Human review gate](#human-review-gate)), which transiently moves HEAD and
123
+ the index. The working tree is never touched.
124
+
116
125
  ### `gtd step` / `gtd step-agent`
117
126
 
118
127
  Both drive the **same fixpoint loop** — gather → resolve → perform the returned
@@ -397,6 +406,26 @@ side can drive — agent rests and agent-driven checkpoints — reports
397
406
  cycles, a force-approved package close) chain without human involvement until an
398
407
  actual human gate is hit.
399
408
 
409
+ `bin/gtd-loop`, installed as the `gtd-loop` binary, is the packaged
410
+ implementation of this exact script — kept in sync with it the same way
411
+ `skills/loop/SKILL.md` is. It additionally attempts `gtd step` (not just
412
+ `gtd step-agent`) every iteration, so a plain rerun after you've edited a file
413
+ at a human gate (no commit needed) picks up your edit and keeps going, and it
414
+ halts with a diagnostic if the same state and prompt repeat with no progress
415
+ (see `skills/loop/SKILL.md`'s "Stall detection").
416
+
417
+ ### Using a different agent
418
+
419
+ `gtd-loop` defaults to
420
+ `claude -p "$GTD_LOOP_PROMPT" --dangerously-skip-permissions`, but the agent
421
+ invocation is swappable: set `GTD_LOOP_AGENT_CMD` to any shell command, and it
422
+ runs with the prompt available as `$GTD_LOOP_PROMPT` in its environment. For
423
+ example, to drive a different agent CLI:
424
+
425
+ ```bash
426
+ GTD_LOOP_AGENT_CMD='my-agent-cli --prompt "$GTD_LOOP_PROMPT"' gtd-loop
427
+ ```
428
+
400
429
  ## States & subjects overview
401
430
 
402
431
  `resolve()` lands on exactly one of **16 states**: `grilling`, `grilled`,
@@ -524,12 +553,39 @@ Once `.gtd/` is fully closed, the machine writes `.gtd/REVIEW.md` and rests at
524
553
  plus routing `gtd: done`.
525
554
  - Flipping only `- [ ]` → `- [x]` checkboxes in `.gtd/REVIEW.md` — checkbox-only
526
555
  edits are also treated as clean approval.
556
+ - Deleting `.gtd/REVIEW.md` outright.
527
557
 
528
558
  Any **substantive** edit — to `.gtd/REVIEW.md` prose, or to the reviewed code
529
559
  itself — is feedback: `gtd(human): review` plus routing `gtd: review feedback`,
530
560
  `.gtd/REVIEW.md` removed, and `gtd next` re-emits a grilling prompt to the agent
531
561
  that inlines the human's finding.
532
562
 
563
+ **The review diff lives in your editor.** While the gate is pending, gtd holds
564
+ open a _review checkout window_: it saves the real head to
565
+ `refs/gtd/review-head`, then rewinds HEAD and the index to the review base with
566
+ `git reset --mixed`, leaving the working tree untouched. Every editor's standard
567
+ git integration now shows the entire reviewable diff as ordinary uncommitted
568
+ changes — SCM panel, gutter marks, per-file diffs. Review it there:
569
+
570
+ - **Edit** anything (code or `.gtd/REVIEW.md` prose) → feedback.
571
+ - **Discard a hunk** in the editor → that reversion IS the feedback: the agent
572
+ is re-grilled with it.
573
+ - **Delete a surfaced file** → reject-this-file feedback.
574
+ - Touch nothing (or tick checkboxes / delete `.gtd/REVIEW.md`) → approval.
575
+
576
+ Any gtd invocation closes the window first (restoring HEAD/index exactly, so
577
+ only your own edits remain dirty — they land as their own separate
578
+ `gtd(human): review` commit, never mixed into the reviewed work), and
579
+ `gtd next`/`gtd status` re-arm it on their way out. The mechanics are
580
+ crash-safe; details and invariants in STATES.md ("The review checkout window").
581
+
582
+ Caveats while a review is pending: don't push (the branch tip rests at the
583
+ review base — the real head is safe under `refs/gtd/review-head`); commits you
584
+ make manually survive as working-tree content and become review feedback, but
585
+ their commit message is discarded; linked `git worktree` checkouts are
586
+ unsupported. If you switch branches mid-review, gtd refuses to touch the foreign
587
+ branch and prints the manual recovery command.
588
+
533
589
  ### Squash
534
590
 
535
591
  With `squash: true` (the default), `gtd: done` is **not** a rest — the same
package/bin/gtd-loop ADDED
@@ -0,0 +1,58 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # Drives the two-beat protocol (README.md "The reference loop driver") to a
5
+ # human gate. Both mutators are attempted every iteration, refusal-tolerant:
6
+ # whichever actor is actually awaited claims the turn (captures the human's
7
+ # uncommitted edit after a halt, or the agent's own edit from the previous
8
+ # iteration), the other refuses harmlessly.
9
+ prev_state=""
10
+ prev_prompt=""
11
+
12
+ while true; do
13
+ gtd step --json >/dev/null 2>&1 || true
14
+ gtd step-agent --json >/dev/null 2>&1 || true
15
+
16
+ if ! next_json="$(gtd next --json 2>&1)"; then
17
+ echo "gtd-loop: could not determine the next step:" >&2
18
+ echo "$next_json" >&2
19
+ echo "Run \`gtd status\` to inspect the repo." >&2
20
+ exit 1
21
+ fi
22
+
23
+ actor="$(jq -r .actor <<<"$next_json")"
24
+ pending="$(jq -r .pending <<<"$next_json")"
25
+ state="$(jq -r .state <<<"$next_json")"
26
+ prompt="$(jq -r .prompt <<<"$next_json")"
27
+
28
+ if [[ "$actor" != "agent" ]]; then
29
+ echo "--- Your turn ($state) ---"
30
+ gtd next
31
+ exit 0
32
+ fi
33
+
34
+ if [[ "$pending" == "true" ]]; then
35
+ prev_state=""
36
+ prev_prompt=""
37
+ continue
38
+ fi
39
+
40
+ # Stall detection (skills/loop/SKILL.md): the same agent-owned state and
41
+ # prompt repeating means the last dispatch made no progress — stop rather
42
+ # than spin on it.
43
+ if [[ "$state" == "$prev_state" && "$prompt" == "$prev_prompt" ]]; then
44
+ echo "gtd-loop: no progress at '$state' — the agent's last turn changed nothing. Stopping to avoid spinning." >&2
45
+ gtd next >&2
46
+ exit 1
47
+ fi
48
+ prev_state="$state"
49
+ prev_prompt="$prompt"
50
+
51
+ # Swappable agent adapter: GTD_LOOP_AGENT_CMD lets any coding agent CLI
52
+ # stand in for the default, receiving the prompt via $GTD_LOOP_PROMPT.
53
+ cmd="${GTD_LOOP_AGENT_CMD:-}"
54
+ if [[ -z "$cmd" ]]; then
55
+ cmd='claude -p "$GTD_LOOP_PROMPT" --dangerously-skip-permissions'
56
+ fi
57
+ GTD_LOOP_PROMPT="$prompt" bash -c "$cmd"
58
+ done
@@ -367862,6 +367862,10 @@ var makeGitImpl = (executor, root2) => {
367862
367862
  (hash3) => /^[0-9a-f]{40}$/.test(hash3) ? Effect_exports.succeed(hash3) : Effect_exports.fail(new Error(`Invalid ref: ${ref}`))
367863
367863
  )
367864
367864
  ),
367865
+ readRefOption: (ref) => exec2("git", "rev-parse", "--verify", "--quiet", ref).pipe(
367866
+ Effect_exports.map((s5) => Option_exports.some(s5.trim())),
367867
+ Effect_exports.catchAll(() => Effect_exports.succeed(Option_exports.none()))
367868
+ ),
367865
367869
  topLevel: () => exec2("git", "rev-parse", "--show-toplevel").pipe(Effect_exports.map((s5) => s5.trim())),
367866
367870
  resolveDefaultBranch: () => exec2("git", "rev-parse", "--abbrev-ref", "origin/HEAD").pipe(
367867
367871
  Effect_exports.map((s5) => s5.trim()),
@@ -367995,7 +367999,20 @@ var makeGitImpl = (executor, root2) => {
367995
367999
  )
367996
368000
  );
367997
368001
  }).pipe(Effect_exports.asVoid),
367998
- softResetTo: (ref) => exec2("git", "reset", "--soft", ref).pipe(Effect_exports.asVoid)
368002
+ softResetTo: (ref) => exec2("git", "reset", "--soft", ref).pipe(Effect_exports.asVoid),
368003
+ updateRef: (ref, hash3) => exec2("git", "update-ref", ref, hash3).pipe(Effect_exports.asVoid),
368004
+ deleteRef: (ref) => exec2("git", "update-ref", "-d", ref).pipe(
368005
+ Effect_exports.asVoid,
368006
+ Effect_exports.catchAll(() => Effect_exports.void)
368007
+ ),
368008
+ mixedResetTo: (ref) => exec2("git", "reset", "--mixed", ref).pipe(Effect_exports.asVoid),
368009
+ restoreStagedFrom: (source2, paths) => paths.length === 0 ? Effect_exports.void : exec2("git", "restore", "--staged", `--source=${source2}`, "--", ...paths).pipe(
368010
+ Effect_exports.asVoid,
368011
+ // `git restore` errors when a pathspec matches nothing at either
368012
+ // side — for an optional dir like `.gtd/` that's a no-op, not a failure.
368013
+ Effect_exports.catchAll(() => Effect_exports.void)
368014
+ ),
368015
+ addIntentToAdd: () => exec2("git", "add", "--intent-to-add", ".").pipe(Effect_exports.asVoid)
367999
368016
  };
368000
368017
  };
368001
368018
  var makeLiveEffect = Effect_exports.gen(function* () {
@@ -389493,6 +389510,78 @@ var buildPrompt = (result, resolveModel = builtinResolveModel, output = "plain")
389493
389510
  return raw.replace(/\n{3,}/g, "\n\n").trimEnd() + "\n";
389494
389511
  };
389495
389512
 
389513
+ // src/ReviewWindow.ts
389514
+ var REVIEW_HEAD_REF = "refs/gtd/review-head";
389515
+ var REVIEW_BASE_REF = "refs/gtd/review-base";
389516
+ var AWAITING_REVIEW_SUBJECT = ROUTING_SUBJECT["awaiting-review"];
389517
+ var subjectOf2 = (message) => message.split("\n")[0] ?? "";
389518
+ var reviewWindowBase = (history) => {
389519
+ const beforeHead = history.slice(0, -1);
389520
+ let lastDoneIdx = -1;
389521
+ for (let i = 0; i < beforeHead.length; i++) {
389522
+ if (subjectOf2(beforeHead[i].message) === ROUTING_SUBJECT.done) lastDoneIdx = i;
389523
+ }
389524
+ const currentCycle = beforeHead.slice(lastDoneIdx + 1);
389525
+ let anchor;
389526
+ let lastAwaitingReview;
389527
+ let firstGrilling;
389528
+ for (const c7 of currentCycle) {
389529
+ const parsed = parseSubject(subjectOf2(c7.message));
389530
+ if (parsed.kind === "routing" && parsed.phase === "reviewing" && parsed.param !== void 0) {
389531
+ anchor = parsed.param;
389532
+ }
389533
+ if (parsed.kind === "routing" && parsed.phase === "awaiting-review") {
389534
+ lastAwaitingReview = c7.hash;
389535
+ }
389536
+ if (parsed.kind === "turn" && parsed.gate === "grilling" && firstGrilling === void 0) {
389537
+ firstGrilling = c7.hash;
389538
+ }
389539
+ }
389540
+ return anchor ?? lastAwaitingReview ?? firstGrilling;
389541
+ };
389542
+ var closeReviewWindow = Effect_exports.gen(
389543
+ function* () {
389544
+ const git = yield* GitService;
389545
+ const savedHead = yield* git.readRefOption(REVIEW_HEAD_REF);
389546
+ if (Option_exports.isNone(savedHead)) return { closed: false };
389547
+ const base = yield* git.readRefOption(REVIEW_BASE_REF);
389548
+ if (Option_exports.isSome(base)) {
389549
+ const onReviewedBranch = yield* git.isAncestor(base.value, "HEAD");
389550
+ if (!onReviewedBranch) {
389551
+ return yield* Effect_exports.fail(
389552
+ new Error(
389553
+ "a gtd review checkout window is open but HEAD has moved off the reviewed branch \u2014 return to it, or restore manually with `git reset --mixed refs/gtd/review-head && git update-ref -d refs/gtd/review-head && git update-ref -d refs/gtd/review-base`"
389554
+ )
389555
+ );
389556
+ }
389557
+ }
389558
+ yield* git.mixedResetTo(REVIEW_HEAD_REF);
389559
+ yield* git.deleteRef(REVIEW_HEAD_REF);
389560
+ yield* git.deleteRef(REVIEW_BASE_REF);
389561
+ return { closed: true };
389562
+ }
389563
+ );
389564
+ var openReviewWindow = Effect_exports.gen(
389565
+ function* () {
389566
+ const git = yield* GitService;
389567
+ const hasCommits = yield* git.hasCommits();
389568
+ if (!hasCommits) return { opened: false };
389569
+ const subject = yield* git.lastCommitSubject();
389570
+ if (subject !== AWAITING_REVIEW_SUBJECT) return { opened: false };
389571
+ const history = yield* git.commitHistory();
389572
+ const base = reviewWindowBase(history);
389573
+ if (base === void 0) return { opened: false };
389574
+ const headHash = yield* git.resolveRef("HEAD");
389575
+ if (base === headHash) return { opened: false };
389576
+ yield* git.updateRef(REVIEW_BASE_REF, base);
389577
+ yield* git.updateRef(REVIEW_HEAD_REF, headHash);
389578
+ yield* git.mixedResetTo(base);
389579
+ yield* git.restoreStagedFrom(REVIEW_HEAD_REF, [".gtd"]);
389580
+ yield* git.addIntentToAdd();
389581
+ return { opened: true };
389582
+ }
389583
+ );
389584
+
389496
389585
  // src/State.ts
389497
389586
  var edgeActionHandlers = {
389498
389587
  captureTurn: (a5) => `capture the ${a5.actor} turn as "gtd(${a5.actor}): ${a5.gate}"`,
@@ -389807,9 +389896,13 @@ function makeProgram(opts = {}) {
389807
389896
  const git = yield* GitService;
389808
389897
  const fs9 = yield* FileSystem_exports.FileSystem;
389809
389898
  yield* assertRunningFromRepoRoot(git, fs9);
389899
+ yield* closeReviewWindow;
389810
389900
  yield* (yield* ConfigInit).ensure;
389811
389901
  const config2 = yield* ConfigService;
389812
- yield* dispatchKnownSubcommand(sub, argv, git, config2, json2, write4);
389902
+ yield* dispatchKnownSubcommand(sub, argv, git, config2, json2, write4).pipe(
389903
+ Effect_exports.tap(() => openReviewWindow),
389904
+ Effect_exports.tapError(() => openReviewWindow.pipe(Effect_exports.ignore))
389905
+ );
389813
389906
  }).pipe(
389814
389907
  json2 ? Effect_exports.catchAll(
389815
389908
  (error) => Effect_exports.sync(
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "2.0.0",
3
+ "version": "2.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": {
7
- "gtd": "dist/gtd.bundle.mjs"
7
+ "gtd": "dist/gtd.bundle.mjs",
8
+ "gtd-loop": "bin/gtd-loop"
8
9
  },
9
10
  "files": [
10
11
  "dist/",
12
+ "bin/",
11
13
  "README.md",
12
14
  "LICENSE",
13
15
  "schema.json"