@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 +86 -49
- package/dist/gtd.bundle.mjs +147 -16
- package/package.json +1 -1
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
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
`
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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
|
|
371
|
-
|
|
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
|
-
|
|
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
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
package/dist/gtd.bundle.mjs
CHANGED
|
@@ -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
|
|
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
|
|
192817
|
-
|
|
192818
|
-
|
|
192819
|
-
|
|
192820
|
-
const
|
|
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, ...
|
|
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
|
|
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
|
-
|
|
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`).
|
|
207500
|
-
*
|
|
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
|
});
|