@pmelab/gtd 9.0.0 → 9.1.1

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
@@ -79,25 +79,27 @@ carrying one piece of content (a script, a prompt, a message, or a squash commit
79
79
  template), with an ordered set of change-patterns routing to the next state.
80
80
 
81
81
  The loop is one beat, repeated: run `gtd next --json` and dispatch on `kind` —
82
- `"message"` means it's a human's move (stop and hand off); `"capture"` means a
83
- human gate the human already acted on (land it immediately); `"script"` means
84
- the driver runs `content` itself, then lands it; `"prompt"` means feed `content`
85
- to your agent — using the accompanying `session.id`/`session.resume` to continue
86
- or start that agent conversation (see "Driving the loop" below) — then run
87
- `gtd land` once it's done. gtd itself never executes anything — the driver owns
88
- running scripts. Every `gtd next` call is strictly mutation-free, safe to poll
89
- or peek at any time: `session.id`/`session.resume` are DERIVED, never stored, so
90
- looking is free — nothing distinguishes a peek from a dispatch, and there is no
91
- separate claiming form at all. A `prompt` beat whose turn changes nothing still
92
- lands: `gtd land` commits an EMPTY `gtd(<actor>): <state>` attempt instead of
93
- silently doing nothing, so the fruitless dispatch is visible in history rather
94
- than invisible. `"kind": "stalled"` is derived from that history — HEAD is an
95
- empty attempt at the resting state and the tree is clean — so every
96
- `gtd next --json` call reports it, and it stays reported on a repeat (there's no
97
- marker to consume) until something actually changes. The fix for a repeated
98
- stall is either a better prompt, a `retry:` cap on the state that redirects to
99
- an escalation state after N fruitless attempts, or — if the state can
100
- legitimately finish with nothing to change — declaring a `C` (clean-tree)
82
+ `"message"` means it's a human's move (stop and hand off, unless it's the run's
83
+ opening beat, which the human triggered by re-running the driver: land that one,
84
+ since changing nothing is itself a declared outcome at some gates); `"capture"`
85
+ means a human gate the human already acted on (land it immediately); `"script"`
86
+ means the driver runs `content` itself, then lands it; `"prompt"` means feed
87
+ `content` to your agent — using the accompanying `session.id`/`session.resume`
88
+ to continue or start that agent conversation (see "Driving the loop" below) —
89
+ then run `gtd land` once it's done. gtd itself never executes anything — the
90
+ driver owns running scripts. Every `gtd next` call is strictly mutation-free,
91
+ safe to poll or peek at any time: `session.id`/`session.resume` are DERIVED,
92
+ never stored, so looking is free — nothing distinguishes a peek from a dispatch,
93
+ and there is no separate claiming form at all. A `prompt` beat whose turn
94
+ changes nothing still lands: `gtd land` commits an EMPTY `gtd(<actor>): <state>`
95
+ attempt instead of silently doing nothing, so the fruitless dispatch is visible
96
+ in history rather than invisible. `"kind": "stalled"` is derived from that
97
+ history — HEAD is an empty attempt at the resting state and the tree is clean —
98
+ so every `gtd next --json` call reports it, and it stays reported on a repeat
99
+ (there's no marker to consume) until something actually changes. The fix for a
100
+ repeated stall is either a better prompt, a `retry:` cap on the state that
101
+ redirects to an escalation state after N fruitless attempts, or — if the state
102
+ can legitimately finish with nothing to change — declaring a `C` (clean-tree)
101
103
  pattern on it.
102
104
 
103
105
  An `on` edge may also carry a short imperative `action` (e.g. `Accept plan`)
@@ -141,22 +143,28 @@ landing script opens a **review checkout window**: HEAD is rewound to the review
141
143
  base with the working tree untouched, so the whole reviewable change shows up as
142
144
  ordinary uncommitted changes in your editor's normal git integration (and files
143
145
  added during the process show up as ordinary untracked files, so discarding one
144
- deletes it). The next landing's own script closes the window before it commits.
145
- Tick a box as you review each hunk (ticking just records "I read this"), and
146
- leave a **comment** to request changes: a note on a line, an inline
147
- `// TODO`-style comment in the code, or a direct code edit. Any comment sends a
148
- build + re-review round — an agent first turns your comments into an explicit
149
- instruction list, then a build turn implements it (a re-review then covers only
150
- the follow-through, and a hand-edit is treated as your own fix the agent
151
- completes without reverting your lines; a comment can't be silently dropped — a
152
- build turn that addresses nothing is refused). For the simple flow, the build
153
- turn that follows through on feedback and the turn that drafts the final squash
154
- message both resume the same session that built the feature in the first place,
155
- since the review tail is nested inside that build identity rather than sitting
156
- beside it. Ticking every box with no comment is the sign-off, which collapses
157
- the whole process into one commit (a **squash finale** whose message an agent
158
- drafts). Landing with a box still unticked and no comment is refused (finish
159
- reviewing first), as is deleting `.gtd/REVIEW.md`.
146
+ deletes it — an untracked file you leave alone is not a pending change, and only
147
+ actually removing it from disk counts as a deletion). The next landing's own
148
+ script closes the window before it commits. Tick a box as you review each hunk
149
+ (ticking just records "I read this"), and leave a **comment** to request
150
+ changes: a note on a line, an inline `// TODO`-style comment in the code, or a
151
+ direct code edit. Any comment sends a build + re-review round — an agent first
152
+ turns your comments into an explicit instruction list, then a build turn
153
+ implements it (a re-review then covers only the follow-through, and a hand-edit
154
+ is treated as your own fix the agent completes without reverting your lines; a
155
+ comment can't be silently dropped — a build turn that addresses nothing is
156
+ refused). For the simple flow, the build turn that follows through on feedback
157
+ and the turn that drafts the final squash message both resume the same session
158
+ that built the feature in the first place, since the review tail is nested
159
+ inside that build identity rather than sitting beside it. Ticking every box with
160
+ no comment is the sign-off, which collapses the whole process into one commit (a
161
+ **squash finale** whose message an agent drafts). Landing with a box still
162
+ unticked and no comment is refused (finish reviewing first), as is deleting
163
+ `.gtd/REVIEW.md`. Both refusals hold wherever the review doc lives: repointing
164
+ `reviewFile` out of `.gtd/` (say to `REVIEW.md` at the repo root) changes
165
+ nothing about them — your own edit to the review doc is never mistaken for a
166
+ code comment, and the doc's pre-turn copy is read at the review window's saved
167
+ head rather than at the rewound `HEAD`.
160
168
 
161
169
  The same review tail also has a direct entry point —
162
170
  `gtd --entry review-gate.check --var reviewBase=<commitish>` starts a brand new
@@ -367,8 +375,19 @@ autonomous states (agent turns, check runs) and stops at the first
367
375
  non-autonomous one: reaching a human gate it prints the gate's message and
368
376
  exits. You act by editing files (answer a plan question, tick a review box, fix
369
377
  code) and re-running it — your pending edit arrives as the loop's first beat
370
- (`kind: "capture"`, landed immediately), so you never run `gtd land` by hand; a
371
- mid-process restart simply resumes driving from whatever beat is actually next.
378
+ (`kind: "capture"`, landed immediately), so you never run `gtd land` by hand.
379
+
380
+ Some gates accept by INACTION instead: the bundled template's `plan.await-plan`
381
+ routes its `"C"` (clean-tree) pattern onward, so changing nothing there means
382
+ "accept the plan". That reaches the driver as `kind: "message"`, which no
383
+ inspection can tell apart from a gate you have not read yet — so the driver
384
+ treats its OPENING beat as yours either way and lands it: you re-ran it while
385
+ resting there, and that invocation IS the decision. Every later beat halts as
386
+ usual, because a gate the driver produced mid-run is one you have not seen.
387
+ Landing an opening beat at a gate with no `"C"` pattern is harmless — a benign
388
+ no-op (see `gtd land`'s exit codes below) — and the gate then prints on the next
389
+ beat. A mid-process restart simply resumes driving from whatever beat is
390
+ actually next.
372
391
 
373
392
  Anything richer at that boundary — opening your editor, desktop notifications,
374
393
  terminal-multiplexer status — is the job of an outer wrapper around the driver,
@@ -642,13 +661,17 @@ gtd_land() {
642
661
  return 0
643
662
  }
644
663
 
664
+ beat=1
645
665
  while :; do
646
666
  next="$(gtd next --json)" || exit 1
647
667
  kind="$(jq -r .kind <<<"$next")"
648
668
  log="$(jq -r .log <<<"$next")"
649
669
  case "$kind" in
650
670
  stalled) jq -r .content <<<"$next" >&2; exit 1 ;;
651
- message) jq -r .content <<<"$next"; exit 0 ;;
671
+ # you re-ran us resting here: you either edited something or accepted by
672
+ # editing nothing, so land the opening beat either way. Later beats are
673
+ # gates we just produced and you have not read yet — hand off.
674
+ message) [ "$beat" = 1 ] || { jq -r .content <<<"$next"; exit 0; } ;;
652
675
  capture) ;; # the human already acted — just land it
653
676
  script) bash -c "$(jq -r .content <<<"$next")" >>"$log" 2>&1 || true ;;
654
677
  prompt)
@@ -668,6 +691,7 @@ while :; do
668
691
  done ;;
669
692
  esac
670
693
  gtd_land || exit 1
694
+ beat=$((beat + 1))
671
695
  done
672
696
  ```
673
697
 
@@ -675,11 +699,15 @@ Line by line it is the protocol described above: no opening move at all — a
675
699
  human's pending edit arrives as the `capture` beat, and a stray `gtd land`
676
700
  outside a beat you acted on would author an empty attempt at a clean prompt rest
677
701
  on purpose; one `gtd next --json` beat document read per loop (`kind: "stalled"`
678
- guarding against a spinning agent); `message` halts, `capture` lands a human's
679
- already-made edit outright, `script` runs in the driver, `prompt` goes to the
680
- agent with the document's own `session.id`/`session.resume` mapped onto the
681
- agent's session flags — trying `resume`'s hinted flag first and falling back to
682
- the other on failure, since `session.id` is derived, not remembered (see
702
+ guarding against a spinning agent); `message` halts unless it is the opening
703
+ beat, which the human's own re-invocation authored and which therefore lands
704
+ like any other decision (see [Driving the loop](#driving-the-loop) above — this
705
+ is the one place the driver, not gtd, decides, because "has the human read this
706
+ gate" is run-scoped knowledge gtd deliberately does not keep); `capture` lands a
707
+ human's already-made edit outright, `script` runs in the driver, `prompt` goes
708
+ to the agent with the document's own `session.id`/`session.resume` mapped onto
709
+ the agent's session flags — trying `resume`'s hinted flag first and falling back
710
+ to the other on failure, since `session.id` is derived, not remembered (see
683
711
  [Driving the loop](#driving-the-loop) above) — and its embedded `.validate`
684
712
  script's output re-prompted verbatim on failure (the driver owns only the retry
685
713
  cap); and every landed turn executed — and reported — by the emitted scripts
@@ -771,6 +799,13 @@ normalizes `[ ]`/`[x]` checkboxes before comparing). If you plug in your own
771
799
  — ticking a box, stripping a paragraph — makes the guard's decision and the
772
800
  file's actual content disagree, and gtd will not catch that for you.
773
801
 
802
+ One case never runs your `format:` (or `validate:`) at all: a step whose diff
803
+ DELETES the state's own `file:`. Deleting it is a legitimate outcome — a review
804
+ sign-off's whole diff is the review doc's deletion — and there is nothing left
805
+ to format. Emitting the command anyway would make such a step unlandable, since
806
+ `format:` is the first line of a `set -euo pipefail` script and a formatter like
807
+ `prettier --write` exits non-zero on a path that is not there.
808
+
774
809
  ### Built-in steering formats are ordinary modes
775
810
 
776
811
  `qa` and `review` are gtd's two built-in steering-file formats (parsed and
@@ -1076,12 +1111,14 @@ the compiler's job at load time.
1076
1111
  - **Test/build artifacts must be gitignored.** This is **load-bearing**, not a
1077
1112
  style preference: every land decision detects "clean" via
1078
1113
  `git diff --name-status HEAD` (tracked changes) unioned with
1079
- `git ls-files --others --exclude-standard` (untracked files), which silently
1080
- omits anything matched by `.gitignore`. If a `script` state's command (or the
1081
- build it triggers) writes output — a `dist/`, a coverage report, a log file —
1082
- into the working tree, the tree never goes clean after a green run, and the
1083
- check's `"C"` pattern never fires. Gitignore every path your scripts write
1084
- before wiring gtd into a repo.
1114
+ `git ls-files --others --exclude-standard` (untracked files, classified by
1115
+ content against the base — one that is already there with the same bytes is
1116
+ not a change, and a file that exists on disk is never reported as a deletion,
1117
+ however the index sees it), which silently omits anything matched by
1118
+ `.gitignore`. If a `script` state's command (or the build it triggers) writes
1119
+ output — a `dist/`, a coverage report, a log file — into the working tree, the
1120
+ tree never goes clean after a green run, and the check's `"C"` pattern never
1121
+ fires. Gitignore every path your scripts write before wiring gtd into a repo.
1085
1122
  - **Repository root invocation.** Every state subcommand must run from the git
1086
1123
  repository root. `--help`/`--version` (and the `help`/`version` subcommands),
1087
1124
  `lsp`, `init`, `visualize`, `check`, and `install` skip this guard entirely
@@ -192745,6 +192745,87 @@ const parseNameStatus = (out) => {
192745
192745
  }
192746
192746
  return result;
192747
192747
  };
192748
+ /** Split a `-z` git output into its entries, dropping the trailing empty one. */
192749
+ const splitNul = (out) => out.split("\0").filter((s) => s.length > 0);
192750
+ /**
192751
+ * `git ls-tree` the given paths at `ref` → `path → blob object id`; a path
192752
+ * absent from the map does not exist at `ref`. Pathspec-limited, so the cost
192753
+ * tracks the candidate list rather than the size of the tree. An unresolvable
192754
+ * `ref` (an empty repo has no `HEAD`) yields an empty map — every candidate
192755
+ * then reads as "not at `ref`", exactly the pre-`base` behaviour.
192756
+ */
192757
+ const blobsAtRef = (exec, ref, paths) => exec("git", "ls-tree", "-r", "-z", ref, "--", ...paths).pipe(catchIf((e) => !isIndexLockError(e), () => succeed$3("")), map$4((out) => {
192758
+ const blobs = /* @__PURE__ */ new Map();
192759
+ for (const entry of splitNul(out)) {
192760
+ const tab = entry.indexOf(" ");
192761
+ if (tab === -1) continue;
192762
+ const [, type, oid] = entry.slice(0, tab).split(" ");
192763
+ if (type === "blob" && oid !== void 0) blobs.set(entry.slice(tab + 1), oid);
192764
+ }
192765
+ return blobs;
192766
+ }));
192767
+ /**
192768
+ * `git hash-object` every path's CURRENT bytes in ONE call → `path → object id`
192769
+ * (git prints one id per path, in argument order). An empty map when the call
192770
+ * fails at all (an unreadable file): every candidate then reads as "differs",
192771
+ * which is the safe direction — a file that EXISTS must never be reported
192772
+ * deleted.
192773
+ *
192774
+ * These ids are directly comparable with the ones `git ls-tree` reports because
192775
+ * hash-object applies the repo's own CLEAN FILTERS, looking each file's
192776
+ * attributes up from its own path — so in a `text=auto`/`core.autocrlf` repo an
192777
+ * untouched CRLF working-tree file hashes to the same LF-normalized blob git
192778
+ * stored, and is correctly read as unchanged. Never add `--no-filters` (nor
192779
+ * `--path=<other>`) here: that would report every such file `M`, i.e. a
192780
+ * spurious "the human edited something real" (`StepGuards`'s `hasCodeChange`),
192781
+ * which walks the review-signoff guard past its unticked-box check, plus a
192782
+ * spurious change for any `on` pattern to match. Both tiers of the
192783
+ * `changedPaths` contract pin this (`src/testing/GitTiers.ts`).
192784
+ *
192785
+ * A symlink is the one residual inexactness: hash-object reads through it, so it
192786
+ * hashes the target's content rather than the link text git stores, and reads as
192787
+ * `M`. Still never `D`.
192788
+ */
192789
+ const hashObjects = (exec, paths) => exec("git", "hash-object", "--", ...paths).pipe(map$4((out) => new Map(out.trim().split("\n").map((oid, i) => [paths[i] ?? "", oid.trim()]))), catchIf((e) => !isIndexLockError(e), () => succeed$3(/* @__PURE__ */ new Map())));
192790
+ /**
192791
+ * Classify every untracked path against `ref`'s tree, by CONTENT:
192792
+ *
192793
+ * - not at `ref` → `A`, an ordinary untracked addition
192794
+ * - at `ref` with different bytes → `M`
192795
+ * - at `ref` with identical bytes → no entry at all
192796
+ *
192797
+ * The last two exist because "untracked" does not mean "new": the review
192798
+ * checkout window's `git reset --mixed <base>` moves the index to the review
192799
+ * base, so every file the reviewed range ADDED is untracked while being
192800
+ * perfectly present (and usually unchanged) at the window's saved head. Read
192801
+ * from the worktree rather than the index, this is the same tree-vs-worktree
192802
+ * comparison the in-memory double has always done (`InMemRepo`'s
192803
+ * `changedPathsWorktree`) — production used to disagree with it here.
192804
+ *
192805
+ * "Different bytes" is decided the way GIT decides it — `hashObjects` hashes
192806
+ * through the repo's own clean filters, so a `text=auto` repo's untouched CRLF
192807
+ * file matches the LF blob it was stored as. Over-reporting `M` for an untouched
192808
+ * file would not be cosmetic: it is a spurious "the human edited something real"
192809
+ * (`StepGuards`'s `hasCodeChange`), which sends the review-signoff guard down
192810
+ * its it-is-a-comment branch and past the unticked-box check, plus a spurious
192811
+ * change for any `on` pattern to match.
192812
+ */
192813
+ const classifyUntracked = (exec, ref, untrackedPaths) => gen(function* () {
192814
+ const atRef = yield* blobsAtRef(exec, ref, untrackedPaths);
192815
+ const candidates = untrackedPaths.filter((path) => atRef.has(path));
192816
+ const onDisk = candidates.length === 0 ? /* @__PURE__ */ new Map() : yield* hashObjects(exec, candidates);
192817
+ return untrackedPaths.flatMap((path) => {
192818
+ const at = atRef.get(path);
192819
+ if (at === void 0) return [{
192820
+ path,
192821
+ status: "A"
192822
+ }];
192823
+ return onDisk.get(path) === at ? [] : [{
192824
+ path,
192825
+ status: "M"
192826
+ }];
192827
+ });
192828
+ });
192748
192829
  /**
192749
192830
  * True when `error` is git's `index.lock` contention failure — another process
192750
192831
  * held the index lock when git tried to take it. gtd shares one worktree index
@@ -192811,17 +192892,17 @@ const makeGitImpl = (executor, root) => {
192811
192892
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
192812
192893
  changedPathsSince: (ref) => exec("git", "diff", "--name-status", ref, "HEAD").pipe(map$4(parseNameStatus)),
192813
192894
  changedPaths: (base) => gen(function* () {
192814
- const nameStatusOut = yield* exec("git", "diff", "--name-status", base ?? "HEAD").pipe(catchIf((e) => !isIndexLockError(e), () => succeed$3("")));
192895
+ const ref = base ?? "HEAD";
192896
+ const nameStatusOut = yield* exec("git", "diff", "--name-status", ref).pipe(catchIf((e) => !isIndexLockError(e), () => succeed$3("")));
192815
192897
  const trackedPaths = parseNameStatus(nameStatusOut);
192816
- const untrackedAll = (yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter((s) => s.length > 0).map((path) => ({
192817
- path,
192818
- status: "A"
192819
- }));
192820
- const atBase = base === void 0 ? /* @__PURE__ */ new Set() : new Set((yield* exec("git", "ls-tree", "-r", "--name-only", "-z", base)).split("\0").filter((s) => s.length > 0));
192821
- const untracked = untrackedAll.filter((entry) => !atBase.has(entry.path));
192898
+ const untrackedRaw = yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z");
192899
+ const untrackedPaths = splitNul(untrackedRaw);
192900
+ if (untrackedPaths.length === 0) return trackedPaths;
192901
+ const untrackedChanges = yield* classifyUntracked(exec, ref, untrackedPaths);
192902
+ const untrackedSet = new Set(untrackedPaths);
192822
192903
  const seen = /* @__PURE__ */ new Set();
192823
192904
  const all = [];
192824
- for (const entry of [...trackedPaths, ...untracked]) if (!seen.has(entry.path)) {
192905
+ for (const entry of [...trackedPaths.filter((e) => !untrackedSet.has(e.path)), ...untrackedChanges]) if (!seen.has(entry.path)) {
192825
192906
  seen.add(entry.path);
192826
192907
  all.push(entry);
192827
192908
  }
@@ -194595,6 +194676,7 @@ const restAt = (ref) => gen(function* () {
194595
194676
  on,
194596
194677
  stepDef,
194597
194678
  changes,
194679
+ windowHead,
194598
194680
  memory,
194599
194681
  memoryResumed,
194600
194682
  hints,
@@ -195173,6 +195255,20 @@ const decideOpenWindow = (def, target, run, head) => {
195173
195255
  * Idempotent under re-entry: `updateRef`/`mixedResetTo` re-pointing at the
195174
195256
  * same target is a no-op, and `restoreStagedFrom` re-pinning an already-pinned
195175
195257
  * path is a no-op.
195258
+ *
195259
+ * `".gtd"` stays a LITERAL, deliberately, rather than being derived from the
195260
+ * workflow's own `stateDir`/`file:` declarations. The pin exists to keep gtd's
195261
+ * PLUMBING out of the reviewer's editor, and `.gtd/` is the conventional
195262
+ * directory it lives in — including the parts no state declares (a check
195263
+ * script's temp output), which a `file:`-derived list would miss. A steering
195264
+ * file a workflow repoints outside it (`vars: { reviewFile: REVIEW.md }`)
195265
+ * simply stays untracked in the window, which is harmless: `changedPaths`
195266
+ * classifies untracked paths by content (`src/Git.ts`), so an unedited one is
195267
+ * not a pending change and never a phantom deletion. Making the list dynamic
195268
+ * would also cost the in-memory tier its trust property — the recognizer
195269
+ * (`src/testing/EmittedScriptRecognizer.ts`'s `recognizeReviewWindowOpen`)
195270
+ * re-derives this exact string and compares, which only works while the paths
195271
+ * are fixed. Don't trade that for cosmetics.
195176
195272
  */
195177
195273
  const buildOpenWindowScript = (decision) => [
195178
195274
  updateRef(REVIEW_BASE_REF, decision.base),
@@ -206949,6 +207045,15 @@ const openInBrowser = (url) => {
206949
207045
  };
206950
207046
  //#endregion
206951
207047
  //#region src/StepGuards.ts
207048
+ /**
207049
+ * True when `changes` deletes `file` — the one question BOTH this registry's
207050
+ * `GuardContext.fileDeleted` and `program.ts`'s `steeringModeSteps` ask about a
207051
+ * state's `file:`, so they ask it in exactly one place. A deletion is a
207052
+ * legitimate step outcome at some states (a review sign-off's bare REVIEW.md
207053
+ * deletion) and a refusal at others (see `reviewSignoffGuard`), but either way
207054
+ * there is no file left to format or validate.
207055
+ */
207056
+ const deletesFile = (changes, file) => changes.some((c) => c.path === file && c.status === "D");
206952
207057
  /** Normalize every markdown checkbox to a single placeholder so a pure `[ ]`→`[x]` tick is invisible to a text comparison; any surviving difference is a human note. */
206953
207058
  const normalizeCheckboxes = (content) => content.replace(/\[[ xX]\]/g, "[_]");
206954
207059
  /** The `mode:` name of gtd's built-in REVIEW.md checkbox validator — the only mode the sign-off guard understands. */
@@ -207010,8 +207115,8 @@ const enforceStepGuards = (input) => gen(function* () {
207010
207115
  if (file === void 0) return;
207011
207116
  const applicable = stepGuards.filter((g) => g.appliesTo(input.rest));
207012
207117
  if (applicable.length === 0) return;
207013
- const fileDeleted = input.changes.some((c) => c.path === file && c.status === "D");
207014
- const hasCodeChange = input.changes.some((c) => !c.path.startsWith(".gtd/"));
207118
+ const fileDeleted = deletesFile(input.changes, file);
207119
+ const hasCodeChange = input.changes.some((c) => !c.path.startsWith(".gtd/") && c.path !== file);
207015
207120
  const base = {
207016
207121
  rest: input.rest,
207017
207122
  file,
@@ -207021,7 +207126,7 @@ const enforceStepGuards = (input) => gen(function* () {
207021
207126
  template: input.context
207022
207127
  };
207023
207128
  const files = yield* RepoFiles;
207024
- const head = yield* cached(files.committed(file));
207129
+ const head = yield* cached(files.committed(file, input.windowHead));
207025
207130
  const worktree = yield* cached(try_(() => files.working(file)));
207026
207131
  const ctx = {
207027
207132
  ...base,
@@ -207175,13 +207280,17 @@ gtd_land() {
207175
207280
  return 0
207176
207281
  }
207177
207282
 
207283
+ beat=1
207178
207284
  while :; do
207179
207285
  next="$(gtd next --json)" || exit 1
207180
207286
  kind="$(jq -r .kind <<<"$next")"
207181
207287
  log="$(jq -r .log <<<"$next")"
207182
207288
  case "$kind" in
207183
207289
  stalled) jq -r .content <<<"$next" >&2; exit 1 ;;
207184
- message) jq -r .content <<<"$next"; exit 0 ;;
207290
+ # you re-ran us resting here: you either edited something or accepted by
207291
+ # editing nothing, so land the opening beat either way. Later beats are
207292
+ # gates we just produced and you have not read yet — hand off.
207293
+ message) [ "$beat" = 1 ] || { jq -r .content <<<"$next"; exit 0; } ;;
207185
207294
  capture) ;; # the human already acted — just land it
207186
207295
  script) bash -c "$(jq -r .content <<<"$next")" >>"$log" 2>&1 || true ;;
207187
207296
  prompt)
@@ -207201,6 +207310,7 @@ while :; do
207201
207310
  done ;;
207202
207311
  esac
207203
207312
  gtd_land || exit 1
207313
+ beat=$((beat + 1))
207204
207314
  done`;
207205
207315
  const HEADER = () => `gtd ${GTD_VERSION$1} — driver protocol\n\ngtd decides and prints; the driver executes. gtd never runs git, never runs\nyour agent, and never runs a check script. The one subprocess gtd ever spawns\nitself is a steering mode's own format:/validate: command, during a\nland-capture guard.\n`;
207206
207316
  const BEAT_PROTOCOL = `
@@ -207222,7 +207332,12 @@ The \`kind\` field selects what to do:
207222
207332
 
207223
207333
  - \`stalled\` — print \`.content\` (a diagnosis) to stderr and exit non-zero.
207224
207334
  Terminal: another dispatch would just repeat the same fruitless turn.
207225
- - \`message\` — print \`.content\` and exit 0. This is a human gate.
207335
+ - \`message\` — print \`.content\` and exit 0. This is a human gate. EXCEPT on
207336
+ the run's opening beat: the human invoked you while resting there, so land
207337
+ it instead of printing. Some gates route a \`"C"\` (clean-tree) pattern
207338
+ onward, making "change nothing" a real decision that looks identical to a
207339
+ gate nobody has read yet; landing an opening beat at a gate without one is
207340
+ a benign no-op, and it prints on the next beat.
207226
207341
  - \`capture\` — a human gate the human already acted on (the tree is dirty).
207227
207342
  Land it immediately, no display needed.
207228
207343
  - \`script\` — run \`bash -c .content\`, appending its output to \`.log\`, and
@@ -207289,7 +207404,9 @@ const DRIVER_OBLIGATIONS = `
207289
207404
  no opening move: a human's pending edit arrives as a \`kind: "capture"\`
207290
207405
  beat, which you land immediately without executing anything.
207291
207406
  2. A \`kind: "stalled"\` beat halts the driver with a non-zero exit; a
207292
- \`kind: "message"\` beat prints \`content\` and exits 0.
207407
+ \`kind: "message"\` beat prints \`content\` and exits 0, unless it is the
207408
+ run's opening beat — land that one, since the human's re-invocation while
207409
+ resting there is itself their decision.
207293
207410
  3. Run scripts with their output appended to \`.log\` — gtd never creates or
207294
207411
  truncates that file itself; truncate it once per run. \`$GTD_LOOP_LOG\`
207295
207412
  overrides its path.
@@ -207444,11 +207561,22 @@ const headPreconditions = (currentCommit) => ({ expectedHead: currentCommit });
207444
207561
  * of gtd running it in process (see `src/StepGuards.ts`'s shrunk registry).
207445
207562
  * Empty for a state declaring no `file:`+`mode:` pair; an unknown mode name
207446
207563
  * is a refusal, exactly as it was when gtd ran the commands itself.
207564
+ *
207565
+ * Also empty when the step DELETES that file (`deletesFile`). A deletion is a
207566
+ * legitimate outcome at some states — `build.review.deciding`'s sign-off diff
207567
+ * is a bare REVIEW.md deletion — and there is nothing left to format or
207568
+ * validate either way. Emitting the mode's `format:` anyway made the step
207569
+ * UNLANDABLE for any formatter that treats a missing path as an error
207570
+ * (`prettier --write` exits non-zero with "No files matching the pattern were
207571
+ * found"): it is the first command in a `set -euo pipefail` script, so it
207572
+ * aborted the whole thing — window close, commit and all — before anything
207573
+ * could land, and a driver saw only a non-zero exit.
207447
207574
  */
207448
207575
  const steeringModeSteps = (rest) => gen(function* () {
207449
207576
  const file = rest.hints.file;
207450
207577
  const mode = rest.stateDef.mode;
207451
207578
  if (file === void 0 || mode === void 0) return [];
207579
+ if (deletesFile(rest.changes, file)) return [];
207452
207580
  const resolved = resolveSteeringMode(rest.def, mode);
207453
207581
  if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(rest.def, rest.state, mode)));
207454
207582
  return (yield* renderSteeringCommands(resolved, file, rest.context)).map((command) => ({
@@ -207496,8 +207624,10 @@ const previewSubject = (decision, rest) => decision.kind === "commit" ? succeed$
207496
207624
  * (decision 6): there is nothing to format/validate in an empty diff, and a
207497
207625
  * `format:` command running ahead of the commit could dirty the tree and
207498
207626
  * turn an "empty" attempt non-empty, breaking the derivation `stalledAt`
207499
- * relies on (`Edge.ts`). The review-window close still runs regardless —
207500
- * committing with a window open would land the attempt on the review base.
207627
+ * relies on (`Edge.ts`). It is skipped for a step that DELETES the state's
207628
+ * own `file:` too — see `steeringModeSteps`. The review-window close still
207629
+ * runs regardless — committing with a window open would land the attempt on
207630
+ * the review base.
207501
207631
  */
207502
207632
  const buildRequiredScript = (rest, decision, cost, model) => gen(function* () {
207503
207633
  const git = yield* GitService;
@@ -207559,6 +207689,7 @@ const planLanding = (opts = {}) => gen(function* () {
207559
207689
  context: rest.context,
207560
207690
  file: rest.hints.file,
207561
207691
  changes: rest.changes,
207692
+ windowHead: rest.windowHead,
207562
207693
  kind: decision.kind,
207563
207694
  attempt: isAttemptDecision(decision)
207564
207695
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "9.0.0",
3
+ "version": "9.1.1",
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": {