@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 +37 -22
- package/dist/gtd.bundle.mjs +132 -13
- package/package.json +1 -1
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
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
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
|
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,
|
|
@@ -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`).
|
|
207512
|
-
*
|
|
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
|
});
|