@pmelab/gtd 9.1.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
@@ -143,22 +143,28 @@ landing script opens a **review checkout window**: HEAD is rewound to the review
143
143
  base with the working tree untouched, so the whole reviewable change shows up as
144
144
  ordinary uncommitted changes in your editor's normal git integration (and files
145
145
  added during the process show up as ordinary untracked files, so discarding one
146
- deletes it). The next landing's own script closes the window before it commits.
147
- Tick a box as you review each hunk (ticking just records "I read this"), and
148
- leave a **comment** to request changes: a note on a line, an inline
149
- `// TODO`-style comment in the code, or a direct code edit. Any comment sends a
150
- build + re-review round — an agent first turns your comments into an explicit
151
- instruction list, then a build turn implements it (a re-review then covers only
152
- the follow-through, and a hand-edit is treated as your own fix the agent
153
- completes without reverting your lines; a comment can't be silently dropped — a
154
- build turn that addresses nothing is refused). For the simple flow, the build
155
- turn that follows through on feedback and the turn that drafts the final squash
156
- message both resume the same session that built the feature in the first place,
157
- since the review tail is nested inside that build identity rather than sitting
158
- beside it. Ticking every box with no comment is the sign-off, which collapses
159
- the whole process into one commit (a **squash finale** whose message an agent
160
- drafts). Landing with a box still unticked and no comment is refused (finish
161
- 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`.
162
168
 
163
169
  The same review tail also has a direct entry point —
164
170
  `gtd --entry review-gate.check --var reviewBase=<commitish>` starts a brand new
@@ -793,6 +799,13 @@ normalizes `[ ]`/`[x]` checkboxes before comparing). If you plug in your own
793
799
  — ticking a box, stripping a paragraph — makes the guard's decision and the
794
800
  file's actual content disagree, and gtd will not catch that for you.
795
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
+
796
809
  ### Built-in steering formats are ordinary modes
797
810
 
798
811
  `qa` and `review` are gtd's two built-in steering-file formats (parsed and
@@ -1098,12 +1111,14 @@ the compiler's job at load time.
1098
1111
  - **Test/build artifacts must be gitignored.** This is **load-bearing**, not a
1099
1112
  style preference: every land decision detects "clean" via
1100
1113
  `git diff --name-status HEAD` (tracked changes) unioned with
1101
- `git ls-files --others --exclude-standard` (untracked files), which silently
1102
- omits anything matched by `.gitignore`. If a `script` state's command (or the
1103
- build it triggers) writes output — a `dist/`, a coverage report, a log file —
1104
- into the working tree, the tree never goes clean after a green run, and the
1105
- check's `"C"` pattern never fires. Gitignore every path your scripts write
1106
- 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.
1107
1122
  - **Repository root invocation.** Every state subcommand must run from the git
1108
1123
  repository root. `--help`/`--version` (and the `help`/`version` subcommands),
1109
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,
@@ -207456,11 +207561,22 @@ const headPreconditions = (currentCommit) => ({ expectedHead: currentCommit });
207456
207561
  * of gtd running it in process (see `src/StepGuards.ts`'s shrunk registry).
207457
207562
  * Empty for a state declaring no `file:`+`mode:` pair; an unknown mode name
207458
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.
207459
207574
  */
207460
207575
  const steeringModeSteps = (rest) => gen(function* () {
207461
207576
  const file = rest.hints.file;
207462
207577
  const mode = rest.stateDef.mode;
207463
207578
  if (file === void 0 || mode === void 0) return [];
207579
+ if (deletesFile(rest.changes, file)) return [];
207464
207580
  const resolved = resolveSteeringMode(rest.def, mode);
207465
207581
  if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(rest.def, rest.state, mode)));
207466
207582
  return (yield* renderSteeringCommands(resolved, file, rest.context)).map((command) => ({
@@ -207508,8 +207624,10 @@ const previewSubject = (decision, rest) => decision.kind === "commit" ? succeed$
207508
207624
  * (decision 6): there is nothing to format/validate in an empty diff, and a
207509
207625
  * `format:` command running ahead of the commit could dirty the tree and
207510
207626
  * turn an "empty" attempt non-empty, breaking the derivation `stalledAt`
207511
- * relies on (`Edge.ts`). The review-window close still runs regardless —
207512
- * 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.
207513
207631
  */
207514
207632
  const buildRequiredScript = (rest, decision, cost, model) => gen(function* () {
207515
207633
  const git = yield* GitService;
@@ -207571,6 +207689,7 @@ const planLanding = (opts = {}) => gen(function* () {
207571
207689
  context: rest.context,
207572
207690
  file: rest.hints.file,
207573
207691
  changes: rest.changes,
207692
+ windowHead: rest.windowHead,
207574
207693
  kind: decision.kind,
207575
207694
  attempt: isAttemptDecision(decision)
207576
207695
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "9.1.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": {