@pmelab/gtd 2.1.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
@@ -116,6 +116,12 @@ to cwd, so it refuses with a clear error if invoked from a subdirectory.
116
116
  `--jsn`) is rejected with a usage error rather than silently ignored, so a
117
117
  mistyped flag can never degrade a JSON caller to plain-text mode.
118
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
+
119
125
  ### `gtd step` / `gtd step-agent`
120
126
 
121
127
  Both drive the **same fixpoint loop** — gather → resolve → perform the returned
@@ -547,12 +553,39 @@ Once `.gtd/` is fully closed, the machine writes `.gtd/REVIEW.md` and rests at
547
553
  plus routing `gtd: done`.
548
554
  - Flipping only `- [ ]` → `- [x]` checkboxes in `.gtd/REVIEW.md` — checkbox-only
549
555
  edits are also treated as clean approval.
556
+ - Deleting `.gtd/REVIEW.md` outright.
550
557
 
551
558
  Any **substantive** edit — to `.gtd/REVIEW.md` prose, or to the reviewed code
552
559
  itself — is feedback: `gtd(human): review` plus routing `gtd: review feedback`,
553
560
  `.gtd/REVIEW.md` removed, and `gtd next` re-emits a grilling prompt to the agent
554
561
  that inlines the human's finding.
555
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
+
556
589
  ### Squash
557
590
 
558
591
  With `squash: true` (the default), `gtd: done` is **not** a rest — the same
@@ -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,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "2.1.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": {