@pmelab/gtd 8.5.0 → 8.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/README.md +135 -5
  2. package/bin/gtd +183 -20
  3. package/dist/gtd.bundle.mjs +900 -308
  4. package/package.json +1 -1
@@ -191387,6 +191387,20 @@ const STEERING_FORMATS = /* @__PURE__ */ new Map([["qa", QA_FORMAT], ["review",
191387
191387
  const builtInModeNames = () => [...STEERING_FORMATS.keys()];
191388
191388
  /** The built-in `SteeringFormat` named `mode`, or `undefined` when `mode` names no built-in. */
191389
191389
  const steeringFormatFor = (mode) => STEERING_FORMATS.get(mode);
191390
+ /**
191391
+ * The literal (unrendered) Eta template a workflow compiler seeds as a
191392
+ * built-in mode's own `validate:` command — quoted so a file path containing
191393
+ * spaces survives `renderModeCommand`'s interpolation.
191394
+ */
191395
+ const seededValidateCommand = (mode) => `gtd check ${mode} '<%= it.file %>'`;
191396
+ /**
191397
+ * True when `command` is EXACTLY `mode`'s own seeded template string — a
191398
+ * literal comparison against the declared template, never a rendered
191399
+ * command, so recognition doesn't depend on which file the mode is applied
191400
+ * to. Lets a later mode resolver tell gtd's own seeding apart from a user's
191401
+ * hand-written override of the same mode.
191402
+ */
191403
+ const isSeededValidateCommand = (mode, command) => command === seededValidateCommand(mode);
191390
191404
  //#endregion
191391
191405
  //#region src/PatternConfig.ts
191392
191406
  /**
@@ -191460,11 +191474,13 @@ const steeringFormatFor = (mode) => STEERING_FORMATS.get(mode);
191460
191474
  * `ConfigService` compiles through this module's `compileModesMap` and hands
191461
191475
  * back in as `rcModes`, merged per half by `mergeModes`. Underneath BOTH layers
191462
191476
  * this compiler seeds `src/SteeringFormats.ts`'s built-in registry names (`qa`/
191463
- * `review`) as empty entries, so every compiled definition's `modes` map always
191464
- * carries them and a workflow never has to declare them just to use gtd's own
191465
- * validators — resolution of the merged map against the registry (whether a
191466
- * declared `validate:` displaces a format's own parser) is the edge's job
191467
- * (`src/SteeringMode.ts`), not this compiler's.
191477
+ * `review`) with that module's own `seededValidateCommand(name)` template as
191478
+ * their `validate:`, so every compiled definition's `modes` map always carries
191479
+ * a usable `validate:` command for them and a workflow never has to declare
191480
+ * them just to use gtd's own validators — a workflow (or `.gtdrc`) may still
191481
+ * override either half per name, and `src/SteeringFormats.ts`'s
191482
+ * `isSeededValidateCommand` lets the edge (`src/SteeringMode.ts`) tell gtd's
191483
+ * own seeding apart from a genuine user override.
191468
191484
  *
191469
191485
  * ## File references
191470
191486
  *
@@ -192105,7 +192121,7 @@ const compileWorkflowConfig = (raw, configDir, rcModes, inlineFileRefs = true, f
192105
192121
  const unknownTopKeys = Object.keys(raw).filter((k) => !KNOWN_TOP_KEYS.has(k));
192106
192122
  if (unknownTopKeys.length > 0) errors.push(`unknown top-level key(s) ${unknownTopKeys.join(", ")}`);
192107
192123
  const vars = compileVarsMap(raw.vars, errors);
192108
- const seeded = Object.fromEntries(builtInModeNames().map((name) => [name, {}]));
192124
+ const seeded = Object.fromEntries(builtInModeNames().map((name) => [name, { validate: seededValidateCommand(name) }]));
192109
192125
  const modes = mergeModes(mergeModes(seeded, compileModesMap(raw.modes, errors)), rcModes);
192110
192126
  validateMachinesShape(raw.machines, errors);
192111
192127
  const flattened = flattenMachines(raw, errors);
@@ -192761,13 +192777,15 @@ const makeGitImpl = (executor, root) => {
192761
192777
  hasCommits: () => exec("git", "rev-parse", "--verify", "HEAD").pipe(map$4(() => true), catchAll(() => succeed$3(false))),
192762
192778
  readFileAtRef: (ref, path) => exec("git", "show", `${ref}:${path}`),
192763
192779
  changedPathsSince: (ref) => exec("git", "diff", "--name-status", ref, "HEAD").pipe(map$4(parseNameStatus)),
192764
- changedPaths: () => gen(function* () {
192765
- const nameStatusOut = yield* exec("git", "diff", "--name-status", "HEAD").pipe(catchIf((e) => !isIndexLockError(e), () => succeed$3("")));
192780
+ changedPaths: (base) => gen(function* () {
192781
+ const nameStatusOut = yield* exec("git", "diff", "--name-status", base ?? "HEAD").pipe(catchIf((e) => !isIndexLockError(e), () => succeed$3("")));
192766
192782
  const trackedPaths = parseNameStatus(nameStatusOut);
192767
- const untracked = (yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter((s) => s.length > 0).map((path) => ({
192783
+ const untrackedAll = (yield* exec("git", "ls-files", "--others", "--exclude-standard", "-z")).split("\0").filter((s) => s.length > 0).map((path) => ({
192768
192784
  path,
192769
192785
  status: "A"
192770
192786
  }));
192787
+ 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));
192788
+ const untracked = untrackedAll.filter((entry) => !atBase.has(entry.path));
192771
192789
  const seen = /* @__PURE__ */ new Set();
192772
192790
  const all = [];
192773
192791
  for (const entry of [...trackedPaths, ...untracked]) if (!seen.has(entry.path)) {
@@ -192780,8 +192798,8 @@ const makeGitImpl = (executor, root) => {
192780
192798
  readRefOption: (ref) => exec("git", "rev-parse", "--verify", "--quiet", ref).pipe(map$4((s) => some(s.trim())), catchAll(() => succeed$3(none$4()))),
192781
192799
  isAncestor: (a, b) => run(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$2(CommandExecutor, executor)), map$4(() => true), catchAll(() => succeed$3(false))),
192782
192800
  topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
192783
- commitHistory: (base) => {
192784
- const range = base !== void 0 ? `${base}..HEAD` : void 0;
192801
+ commitHistory: (base, head = "HEAD") => {
192802
+ const range = base !== void 0 ? `${base}..${head}` : head !== "HEAD" ? head : void 0;
192785
192803
  const args = [
192786
192804
  "git",
192787
192805
  "log",
@@ -193491,8 +193509,225 @@ const restorability = (git, headHash, headMessage, tipHash) => gen(function* ()
193491
193509
  reason: "HEAD has advanced past the squash — restoring would discard commits built on top of it"
193492
193510
  };
193493
193511
  });
193494
- /** Idempotent: `deleteRef` already tolerates a missing ref. */
193495
- const clearRetainedHistory = (git) => git.deleteRef(HISTORY_REF);
193512
+ //#endregion
193513
+ //#region src/GitScript.ts
193514
+ /**
193515
+ * Bash-script builders mirroring `GitWriterOperations` (`src/Git.ts`) — one
193516
+ * function per writer method, each returning the equivalent bash as a
193517
+ * `string` instead of executing it. Scaffolding for a later change that will
193518
+ * emit these scripts rather than run git directly through the `Effect`
193519
+ * `CommandExecutor`; nothing calls this module yet.
193520
+ *
193521
+ * Pure, like `src/PatternMachine.ts`: no git, no filesystem, no `Effect`, no
193522
+ * IO of any kind. Every export is a plain, total function of its arguments.
193523
+ * `src/Git.ts`'s doc comments on `GitWriterOperations` are the source of
193524
+ * truth for each shape below; read them for WHY, not just WHAT.
193525
+ */
193526
+ /**
193527
+ * POSIX single-quote escaping: wrap `value` in `'...'`, replacing every
193528
+ * embedded `'` with `'\''` (close quote, escaped literal quote, reopen
193529
+ * quote). The only place a raw string may reach a shell command in this
193530
+ * module — every builder below routes every interpolated value through this.
193531
+ */
193532
+ const shellQuote = (value) => `'${value.replace(/'/g, "'\\''")}'`;
193533
+ /**
193534
+ * `--allow-empty` mirrors `commitAllWithPrefix`/`commitAsIs`'s load-bearing
193535
+ * use in `src/Git.ts`: gtd's workflow commits may be empty on purpose. The
193536
+ * retry-without-hooks behavior there keys off the specific "empty git commit"
193537
+ * hook-rejection message, re-failing on anything else (`src/Git.ts`'s `run`
193538
+ * doc comment records the regression a blind retry-on-any-failure would
193539
+ * reintroduce: reporting success on a genuinely rejected commit — a lint
193540
+ * error, a rejected commit-msg hook). Bash CAN inspect the message: capture
193541
+ * the first attempt's combined output and discriminate on it in a `case`,
193542
+ * exactly like `isIndexLockError`/`error.message.includes(...)` do in
193543
+ * `src/Git.ts`, instead of retrying on the exit code alone.
193544
+ */
193545
+ const commitAllowEmpty = (message) => {
193546
+ const m = shellQuote(message);
193547
+ return [
193548
+ `if ! out=$(git commit --allow-empty -m ${m} 2>&1); then`,
193549
+ ` case "$out" in`,
193550
+ ` *"empty git commit"*) git commit --allow-empty --no-verify -m ${m} ;;`,
193551
+ ` *) printf '%s\\n' "$out" >&2; exit 1 ;;`,
193552
+ ` esac`,
193553
+ `fi`
193554
+ ].join("\n");
193555
+ };
193556
+ /**
193557
+ * `git add -A` then `git commit --allow-empty -m <message>`, retried without
193558
+ * hooks on the same hook rejection `commitAllowEmpty` guards against. Joined
193559
+ * with `&&` so a failing `git add` (e.g. a quoted path breaking it) never
193560
+ * reaches the commit — the same "lost files whose quoted paths broke a
193561
+ * swallowed `git add`" failure mode `src/Git.ts`'s `run` doc comment records.
193562
+ */
193563
+ const commitAll = (message) => `git add -A &&\n${commitAllowEmpty(message)}`;
193564
+ /** `git commit --allow-empty -m <message>` — commits the index as-is, no implicit `git add`. */
193565
+ const commitAsIs = (message) => commitAllowEmpty(message);
193566
+ /** `git reset --soft <ref>` — HEAD moves; index and working tree untouched. */
193567
+ const softResetTo = (ref) => `git reset --soft ${shellQuote(ref)}`;
193568
+ /** `git reset --mixed <ref>` — HEAD and index move; working tree untouched. */
193569
+ const mixedResetTo = (ref) => `git reset --mixed ${shellQuote(ref)}`;
193570
+ /** `git reset --hard <ref>` — HEAD, index, AND working tree all move. */
193571
+ const hardResetTo = (ref) => `git reset --hard ${shellQuote(ref)}`;
193572
+ /**
193573
+ * `git add -A` then `git reset --hard HEAD` — discards every pending change,
193574
+ * tracked or untracked. Joined with `&&`: a failed stage must not reach the
193575
+ * hard reset, or untracked survivors remain — the exact outcome this builder
193576
+ * exists to avoid.
193577
+ */
193578
+ const discardPending = () => `git add -A && git reset --hard HEAD`;
193579
+ /** `git update-ref <ref> <hash>` — point a repo-local ref at a commit. */
193580
+ const updateRef = (ref, hash) => `git update-ref ${shellQuote(ref)} ${shellQuote(hash)}`;
193581
+ /** `git update-ref -d <ref>` — idempotent: deleting a missing ref is already a no-op in real git. */
193582
+ const deleteRef = (ref) => `git update-ref -d ${shellQuote(ref)}`;
193583
+ /**
193584
+ * `git restore --staged --source=<source> -- <paths…>` — tolerant of the
193585
+ * SAME two cases `src/Git.ts`'s `Effect.catchIf` tolerates (a missing ref, or
193586
+ * no path matching at `source`), and NOT tolerant of `index.lock` contention,
193587
+ * which must still fail the script rather than being swallowed here (see
193588
+ * `src/Git.ts`'s `restoreStagedFrom` doc comment and `isIndexLockError`) — a
193589
+ * lock failure silently swallowed here would mean the `.gtd/` index pin
193590
+ * silently doesn't happen, leaking `.gtd/` plumbing into the surfaced review
193591
+ * diff, the exact thing the pin exists to prevent. Captures the command's
193592
+ * output and discriminates on it, mirroring `isIndexLockError`'s two
193593
+ * substrings, exactly like `commitAllowEmpty` above. An empty `paths` emits
193594
+ * nothing at all (a harmless, syntactically valid empty script), mirroring
193595
+ * the Effect implementation's own `paths.length === 0` skip.
193596
+ */
193597
+ const restoreStagedFrom = (source, paths) => {
193598
+ if (paths.length === 0) return "";
193599
+ const pathArgs = paths.map(shellQuote).join(" ");
193600
+ return [
193601
+ `if ! out=$(git restore --staged --source=${shellQuote(source)} -- ${pathArgs} 2>&1); then`,
193602
+ ` case "$out" in`,
193603
+ ` *"index.lock"*|*"Another git process seems to be running"*) printf '%s\\n' "$out" >&2; exit 1 ;;`,
193604
+ ` *) : ;;`,
193605
+ ` esac`,
193606
+ `fi`
193607
+ ].join("\n");
193608
+ };
193609
+ //#endregion
193610
+ //#region src/Emit.ts
193611
+ /**
193612
+ * Assembles the two scripts a `gtd next`-time decision hands the external
193613
+ * driver: `required` (everything that decides what lands in git) and
193614
+ * `optional` (presentation only — e.g. opening the review checkout window so
193615
+ * an editor's diff view has something to show; a driver that skips it loses
193616
+ * nothing but that view). Pure, like `src/GitScript.ts`: no git, no
193617
+ * filesystem, no `Effect`. Every input string (a `GitScript.ts` builder's
193618
+ * output, a steering mode's already-rendered `format:`/`validate:` command)
193619
+ * arrives already rendered — this module only concatenates and wraps.
193620
+ *
193621
+ * Both halves are meant to stand alone: a driver may run either, both, or
193622
+ * only `required`, and a person could paste either into a terminal. That's
193623
+ * why each carries its OWN copy of the precondition asserts and the retry
193624
+ * helper, rather than the two sharing one preamble a caller has to splice
193625
+ * together correctly.
193626
+ */
193627
+ /**
193628
+ * The CLI resolves `expectedHead` (and a review window's `expectedHash`) at
193629
+ * DECIDE time; the script may RUN later, in a process that never shared
193630
+ * memory with the one that decided. This assertion closes that
193631
+ * time-of-check/time-of-use gap. A plain `[ ... ] || { ...; exit 1; }`
193632
+ * statement, not an `if`/`fi` block, so a line-oriented consumer can still
193633
+ * recognize it, and so it stays safe under `set -e`: in an OR list, `-e`
193634
+ * exempts every command except the LAST one, so the left-hand `[ ... ]`
193635
+ * failing here never trips `set -e` on its own — only the explicit `exit 1`
193636
+ * inside the right-hand group does. Exported (alongside `reviewWindowAssertion`)
193637
+ * solely so `src/testing/EmittedScriptRecognizer.ts` can re-derive and
193638
+ * string-compare this exact shape, the same "call the real builder, never
193639
+ * hand-copy its template" discipline it already applies to every
193640
+ * `GitScript.ts` builder.
193641
+ *
193642
+ * The probe is `git rev-parse --verify --quiet HEAD 2>/dev/null`, not the
193643
+ * bare `git rev-parse HEAD` a born repo's assertion could get away with —
193644
+ * load-bearing, not cosmetic: against an UNBORN HEAD (a repository with no
193645
+ * commits), the bare form prints the literal string `HEAD` to stdout (plus a
193646
+ * `fatal:` line to stderr) and exits 128, so `"$(git rev-parse HEAD)"` reads
193647
+ * as `"HEAD"`, never `""`, and an `expectedHead === ""` comparison could
193648
+ * never pass. `--verify --quiet` makes an unborn HEAD read back as an empty
193649
+ * string instead — the same idiom `reviewWindowAssertion` already uses for a
193650
+ * missing ref — so `[ "$(…)" = '' ]` is simply the unborn case of the same
193651
+ * comparison every born repo already runs; only the `printf` wording
193652
+ * branches on it, to avoid naming a hash that doesn't exist.
193653
+ */
193654
+ const headAssertion = (expectedHead) => {
193655
+ const q = shellQuote(expectedHead);
193656
+ const probe = `[ "$(git rev-parse --verify --quiet HEAD 2>/dev/null)" = ${q} ] || `;
193657
+ return expectedHead === "" ? probe + "{ printf 'gtd: repository changed since this script was generated (expected a repository with no commits yet) — re-run gtd\\n' >&2; exit 1; }" : probe + `{ printf 'gtd: repository changed since this script was generated (expected HEAD %s) — re-run gtd\\n' ${q} >&2; exit 1; }`;
193658
+ };
193659
+ /**
193660
+ * Same shape as `headAssertion`, for the saved-head ref a review window
193661
+ * pins. `--verify --quiet ... 2>/dev/null` makes a MISSING ref read as an
193662
+ * empty string rather than erroring the substitution itself — the assertion
193663
+ * only needs to compare, never to fail outright before the `[ ... ]` runs.
193664
+ */
193665
+ const reviewWindowAssertion = (ref, expectedHash) => {
193666
+ const refQ = shellQuote(ref);
193667
+ const hashQ = shellQuote(expectedHash);
193668
+ return `[ "$(git rev-parse --verify --quiet ${refQ} 2>/dev/null)" = ${hashQ} ] || { printf 'gtd: review window ref %s changed since this script was generated (expected %s) — re-run gtd\\n' ${refQ} ${hashQ} >&2; exit 1; }`;
193669
+ };
193670
+ /**
193671
+ * `gtd_retry` takes ONE already-`shellQuote`d command string and `eval`s it,
193672
+ * mirroring `src/Git.ts`'s `withIndexLockRetry`: retry ONLY on the same two
193673
+ * substrings `isIndexLockError` matches, with jittered exponential backoff
193674
+ * (~10ms doubling, capped at 6 TOTAL attempts — the initial try plus 5
193675
+ * retries), and propagate any other failure on the very first attempt. Output
193676
+ * is captured combined (`2>&1`) so the discrimination can inspect it exactly
193677
+ * like `commitAllowEmpty`/`restoreStagedFrom` in `src/GitScript.ts` do for
193678
+ * their own single-shot retries — this is the same technique, generalized
193679
+ * into a loop. `awk` (not bash, which has no fractional `sleep` builtin)
193680
+ * turns the millisecond backoff into the fractional-second argument `sleep`
193681
+ * needs; `-v` passes the values in rather than interpolating them into the
193682
+ * awk program text, so a variable's value can never be mistaken for awk
193683
+ * syntax.
193684
+ */
193685
+ const RETRY_HELPER = [
193686
+ `gtd_retry() {`,
193687
+ ` local cmd=$1 attempt=1 delay_ms=10 out total_ms jitter_ms`,
193688
+ ` while true; do`,
193689
+ ` if out=$(eval "$cmd" 2>&1); then`,
193690
+ ` [ -n "$out" ] && printf '%s\\n' "$out"`,
193691
+ ` return 0`,
193692
+ ` fi`,
193693
+ ` case "$out" in`,
193694
+ ` *"index.lock"*|*"Another git process seems to be running"*) ;;`,
193695
+ ` *) printf '%s\\n' "$out" >&2; return 1 ;;`,
193696
+ ` esac`,
193697
+ ` if [ "$attempt" -ge 6 ]; then`,
193698
+ ` printf '%s\\n' "$out" >&2`,
193699
+ ` return 1`,
193700
+ ` fi`,
193701
+ ` jitter_ms=$(( RANDOM % delay_ms + 1 ))`,
193702
+ ` total_ms=$(( delay_ms + jitter_ms ))`,
193703
+ ` sleep "$(awk -v ms="$total_ms" 'BEGIN { printf "%.3f", ms / 1000 }')"`,
193704
+ ` delay_ms=$(( delay_ms * 2 ))`,
193705
+ ` attempt=$(( attempt + 1 ))`,
193706
+ ` done`,
193707
+ `}`
193708
+ ].join("\n");
193709
+ const renderStep = (step) => step.kind === "gitWrite" ? `gtd_retry ${shellQuote(step.command)}` : step.command;
193710
+ const assembleScript = (preconditions, steps) => {
193711
+ if (steps.length === 0) return "";
193712
+ const sections = ["set -euo pipefail"];
193713
+ if (preconditions.expectedHead !== void 0) sections.push(headAssertion(preconditions.expectedHead));
193714
+ if (preconditions.reviewWindow) sections.push(reviewWindowAssertion(preconditions.reviewWindow.ref, preconditions.reviewWindow.expectedHash));
193715
+ if (steps.some((step) => step.kind === "gitWrite")) sections.push(RETRY_HELPER);
193716
+ sections.push(...steps.map(renderStep));
193717
+ return sections.join("\n\n");
193718
+ };
193719
+ /**
193720
+ * Build both halves from already-rendered command strings. `required`
193721
+ * defaults to `[]` (nothing to do), `optional` to `[]` (no review window to
193722
+ * open) — either omitted argument, or an explicit empty array, produces the
193723
+ * fixed empty-string result `EmittedScripts` documents: a driver checking
193724
+ * `if [ -n "$required" ]` (or the JS equivalent) never has to special-case a
193725
+ * bare preamble with no body.
193726
+ */
193727
+ const emitScripts = (preconditions, required = [], optional = []) => ({
193728
+ required: assembleScript(preconditions, required),
193729
+ optional: assembleScript(preconditions, optional)
193730
+ });
193496
193731
  //#endregion
193497
193732
  //#region src/Edge.ts
193498
193733
  /**
@@ -193537,6 +193772,17 @@ const clearRetainedHistory = (git) => git.deleteRef(HISTORY_REF);
193537
193772
  * what `PatternMachine.step` consumes, despite having a single reader.
193538
193773
  */
193539
193774
  const EMPTY_TREE$2 = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
193775
+ /**
193776
+ * The review checkout window's saved-head ref — duplicated from
193777
+ * `src/ReviewWindow.ts`'s own `REVIEW_HEAD_REF` rather than imported, because
193778
+ * `ReviewWindow.ts` already imports FROM this module (`currentRun`,
193779
+ * `reviewBaseFor`); importing it back here would be a real circular module
193780
+ * dependency. Mirrors `src/RetainedHistory.ts`'s own `HISTORY_REF` doc
193781
+ * comment on the same tradeoff. Read (never written) by `restAt`'s
193782
+ * window-aware branch and by the script preconditions below — this module
193783
+ * never opens or closes the window itself.
193784
+ */
193785
+ const REVIEW_HEAD_REF$1 = "refs/worktree/gtd/review-head";
193540
193786
  const subjectOf = (message) => (message.split("\n")[0] ?? "").trim();
193541
193787
  const COST_TRAILER_PREFIX = "Gtd-Cost: ";
193542
193788
  const COST_TRAILER_RE = /^Gtd-Cost:[ \t]*([0-9]+(?:\.[0-9]+)?)(?:[ \t]+(.+?))?[ \t]*$/gm;
@@ -193654,8 +193900,8 @@ const resolveRestFrom = (def, headSubject) => {
193654
193900
  }
193655
193901
  };
193656
193902
  };
193657
- /** The working tree's pending changes vs HEAD, as the pattern grammar's `{status, path}[]`. */
193658
- const pendingChanges = (git) => git.changedPaths().pipe(map$4((entries) => entries.map((e) => ({
193903
+ /** The working tree's pending changes vs `base` (default HEAD), as the pattern grammar's `{status, path}[]`. */
193904
+ const pendingChanges = (git, base) => git.changedPaths(base).pipe(map$4((entries) => entries.map((e) => ({
193659
193905
  status: normalizeStatus(e.status),
193660
193906
  path: e.path
193661
193907
  }))));
@@ -193696,9 +193942,16 @@ const parseEntryCommitOverrides = (processCommits) => {
193696
193942
  * same shape a fresh rest at a squashed boundary has always had.
193697
193943
  *
193698
193944
  * `hasCommits` is supplied by the caller (already read once — see the module
193699
- * doc comment's stage ordering) rather than re-read here.
193700
- */
193701
- const computeProcessRun = (git, def, hasCommits) => gen(function* () {
193945
+ * doc comment's stage ordering) rather than re-read here. `head` — a resolved
193946
+ * hash, never a symbolic ref — overrides the literal `HEAD` the walk would
193947
+ * otherwise end at: `restAt`'s window-aware branch passes the review
193948
+ * checkout window's saved-head hash here while the window is open, so the
193949
+ * trace still covers the commits a real `git log` would miss with HEAD
193950
+ * rewound to the review base. `undefined` (every other caller) is today's
193951
+ * exact behavior — an explicit trailing ref is passed to `git.commitHistory`
193952
+ * only when `head` differs from the literal `"HEAD"` it already defaults to.
193953
+ */
193954
+ const computeProcessRun = (git, def, hasCommits, head) => gen(function* () {
193702
193955
  if (!hasCommits) return {
193703
193956
  startHash: "",
193704
193957
  startParentHash: EMPTY_TREE$2,
@@ -193708,7 +193961,7 @@ const computeProcessRun = (git, def, hasCommits) => gen(function* () {
193708
193961
  entryVars: {}
193709
193962
  };
193710
193963
  const initialState = initialStateOf(def);
193711
- const history = yield* git.commitHistory();
193964
+ const history = yield* git.commitHistory(void 0, head);
193712
193965
  let i = history.length - 1;
193713
193966
  while (i >= 0) {
193714
193967
  const parsed = parseStateSubject(subjectOf(history[i].message));
@@ -193740,12 +193993,19 @@ const computeProcessRun = (git, def, hasCommits) => gen(function* () {
193740
193993
  * since abandon IS the recovery command for that case) and the review
193741
193994
  * window's re-arm (`openReviewWindow` stays graceful there too — see its own
193742
193995
  * doc comment).
193996
+ *
193997
+ * Window-aware on exactly the same terms as `restAt`: while a review checkout
193998
+ * window is open, real HEAD is rewound to the review base, so walking the
193999
+ * trace from literal HEAD would see a process that has not started. Both
194000
+ * callers need the REAL head — abandon rewinds the reviewed branch tip, the
194001
+ * re-arm re-derives the review base — so the saved head is the walk's origin.
193743
194002
  */
193744
194003
  const currentRun = gen(function* () {
193745
194004
  const git = yield* GitService;
193746
194005
  const config = yield* (yield* ConfigService).load;
193747
194006
  const hasCommits = yield* git.hasCommits();
193748
- return yield* computeProcessRun(git, config.workflow, hasCommits);
194007
+ const windowHead = getOrUndefined(yield* git.readRefOption(REVIEW_HEAD_REF$1));
194008
+ return yield* computeProcessRun(git, config.workflow, hasCommits, windowHead);
193749
194009
  });
193750
194010
  /**
193751
194011
  * PURE: the most-recent in-process turn commit that entered a `reviewBase`
@@ -193941,6 +194201,20 @@ const omitUndefined = (obj) => Object.fromEntries(Object.entries(obj).filter(([,
193941
194201
  * window's saved head via `REVIEW_HEAD_REF`, or `undefined` for HEAD itself —
193942
194202
  * see `currentRest`). Assembles every stage documented in the module doc
193943
194203
  * comment, threading one `hasCommits()` read through stages 3/5/11.
194204
+ *
194205
+ * Window-aware ONLY at `ref === undefined` (`currentRest`'s own case): when
194206
+ * the review checkout window's saved-head ref (`REVIEW_HEAD_REF`) resolves,
194207
+ * its hash is used as the effective head for `lastCommitSubject` AND
194208
+ * `computeProcessRun`'s trace walk — real git HEAD has been rewound to the
194209
+ * review base while the window is open, so reading literal `HEAD` there
194210
+ * would resolve state/trace/memory against the wrong commit. An explicit
194211
+ * `ref` (`gtd visualize`'s own call pattern) never triggers this branch —
194212
+ * that path resolves exactly as it always has. Safe to land now because
194213
+ * every existing command closes the window (`program.ts`'s
194214
+ * `runInReviewWindowBracket`) before `restAt`/`currentRest` ever runs, so the
194215
+ * saved-head ref is already gone by the time this branch could fire in
194216
+ * production — see the two direct tests in `Edge.test.ts` for the only
194217
+ * coverage today.
193944
194218
  */
193945
194219
  const restAt = (ref) => gen(function* () {
193946
194220
  const git = yield* GitService;
@@ -193949,16 +194223,19 @@ const restAt = (ref) => gen(function* () {
193949
194223
  const envVars = yield* EnvVars;
193950
194224
  const def = config.workflow;
193951
194225
  const hasCommits = yield* git.hasCommits();
193952
- const headSubject = hasCommits ? yield* git.lastCommitSubject(ref) : "";
194226
+ let windowHead;
194227
+ if (ref === void 0) windowHead = getOrUndefined(yield* git.readRefOption(REVIEW_HEAD_REF$1));
194228
+ const effectiveRef = windowHead ?? ref;
194229
+ const headSubject = hasCommits ? yield* git.lastCommitSubject(effectiveRef) : "";
193953
194230
  const resolution = resolveRestFrom(def, headSubject);
193954
194231
  if (!resolution.ok) return yield* fail$2(resolution.error);
193955
194232
  const resolved = resolution.rest;
193956
- const run = yield* computeProcessRun(git, def, hasCommits);
194233
+ const run = yield* computeProcessRun(git, def, hasCommits, windowHead);
193957
194234
  const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, envVars.all);
193958
194235
  const on = yield* renderOnEdgesOrFail(resolved.stateDef.on, vars);
193959
194236
  const stepDef = withRenderedOn(def, resolved.state, on);
193960
194237
  const reviewBase = reviewBaseFor(def, run);
193961
- const changes = yield* pendingChanges(git);
194238
+ const changes = yield* pendingChanges(git, windowHead);
193962
194239
  const context = yield* buildTemplateContext(git, templateRead(files), resolved.state, resolved.actor, run, vars, on, 0, void 0, reviewBase, hasCommits);
193963
194240
  const memory = memoryKeyFor(config.stateScopes, resolved, run);
193964
194241
  const hints = yield* renderHints(resolved.stateDef, context);
@@ -194035,10 +194312,13 @@ const renderRest = (rest) => gen(function* () {
194035
194312
  const formatStepRefusal = (invoker, refusal) => refusal.reason === "out-of-turn" ? `gtd step ${invoker}: out of turn — "${refusal.state}" awaits ${refusal.awaits}` : `gtd step ${invoker}: no declared pattern matches the pending changes at "${refusal.state}" — declared patterns: ${refusal.patterns.length > 0 ? refusal.patterns.join(", ") : "(none)"}`;
194036
194313
  /**
194037
194314
  * Build the `TemplateContext` for rendering a DIFFERENT state than `rest`'s
194038
- * own resting one, with a step's `cost`/`model` folded in — `planStep`'s
194039
- * squash branch is its only caller (the commit branch never reads a
194040
- * `TemplateContext` — see `executeDecision`), so this stays unexported per
194041
- * the module's two-caller rule.
194315
+ * own resting one, with a step's `cost`/`model` folded in. A squash's commit
194316
+ * template is the only thing that needs it (the commit branch never reads a
194317
+ * `TemplateContext` at all), and it is NOT interchangeable with `rest.context`:
194318
+ * that one is pinned to the resting state and carries `cost: 0`, so rendering
194319
+ * `it.processCost` against it silently omits the squashing step's own
194320
+ * `--cost`. Exported for `program.ts`'s `stepAsActor`, which assembles the
194321
+ * same script by hand and must pick the same context.
194042
194322
  */
194043
194323
  const contextAt = (rest, targetState, invoker, cost, model) => gen(function* () {
194044
194324
  const git = yield* GitService;
@@ -194086,6 +194366,119 @@ const executeDecision = (git, run, decision, context, cost, model) => gen(functi
194086
194366
  }
194087
194367
  });
194088
194368
  /**
194369
+ * Render a `"commit"`/`"squash"` decision as the `EmitStep`s the external
194370
+ * driver would run to produce the SAME effect `executeDecision` performs
194371
+ * directly — same switch, same subjects, same trailers, same ordering. Every
194372
+ * git call here is a READ (`git.resolveRef("HEAD")` for the squash branch's
194373
+ * pre-squash tip); nothing is written. `planStep`/`planEntry`'s script
194374
+ * assembly (`buildStepScripts`) and `Edge.test.ts`'s render/perform
194375
+ * equivalence tests are its two callers.
194376
+ *
194377
+ * The squash branch renders the commit-state template against the PENDING
194378
+ * tree — a render failure fails this Effect exactly like `executeDecision`'s
194379
+ * own (same message), touching no git. On success it emits, in order:
194380
+ * retain-history (`updateRef(HISTORY_REF, tip)`, OMITTED when `tip ===
194381
+ * run.startParentHash` — an empty process retains nothing, mirroring
194382
+ * `retainHistory`'s own no-op check, decided here since `tip` is already
194383
+ * known), soft-reset to the process start, commit-as-is with the rendered
194384
+ * message plus its `Gtd-History:` trailer, then discard-pending.
194385
+ *
194386
+ * The commit branch carries `executeDecision`'s one non-obvious case too: a
194387
+ * commit whose target is the workflow's INITIAL state, from a process that
194388
+ * retained nothing, is a no-op probe (`gtd --entry fix-precheck` against a
194389
+ * green suite is the shipped example) and must leave no trace at all. It
194390
+ * emits retain-history + a mixed reset to the process start instead of a
194391
+ * commit, so the entry commit and the probe collapse away together rather
194392
+ * than dirtying the log with a round trip to `idle`. Deciding it needs the
194393
+ * whole `rest` (its definition for the initial state, its pending changes for
194394
+ * the "retained nothing" test), which is why this takes a `Rest` rather than
194395
+ * the `ProcessRun` alone.
194396
+ */
194397
+ const renderDecision = (git, rest, decision, context, cost, model) => gen(function* () {
194398
+ const run = rest.run;
194399
+ switch (decision.kind) {
194400
+ case "commit":
194401
+ if (parseStateSubject(decision.subject)?.state === initialStateOf(rest.def) && run.startParentHash !== EMPTY_TREE$2 && (yield* retainsNothing(git, run, rest.changes))) {
194402
+ const tip = yield* git.resolveRef("HEAD");
194403
+ const steps = [];
194404
+ if (tip !== run.startParentHash) steps.push({
194405
+ kind: "gitWrite",
194406
+ command: updateRef(HISTORY_REF, tip)
194407
+ });
194408
+ steps.push({
194409
+ kind: "gitWrite",
194410
+ command: mixedResetTo(run.startParentHash)
194411
+ });
194412
+ return steps;
194413
+ }
194414
+ return [{
194415
+ kind: "gitWrite",
194416
+ command: commitAll(withCostTrailer(decision.subject, cost, model))
194417
+ }];
194418
+ case "squash": {
194419
+ const message = yield* try_({
194420
+ try: () => renderStateTemplate(decision.template, context),
194421
+ catch: (e) => /* @__PURE__ */ new Error(`gtd: rendering the "${decision.state}" commit template failed — nothing was committed: ${e instanceof Error ? e.message : String(e)}`)
194422
+ });
194423
+ const tip = yield* git.resolveRef("HEAD");
194424
+ const steps = [];
194425
+ if (tip !== run.startParentHash) steps.push({
194426
+ kind: "gitWrite",
194427
+ command: updateRef(HISTORY_REF, tip)
194428
+ });
194429
+ steps.push({
194430
+ kind: "gitWrite",
194431
+ command: softResetTo(run.startParentHash)
194432
+ });
194433
+ steps.push({
194434
+ kind: "gitWrite",
194435
+ command: commitAsIs(withHistoryTrailer(message, tip))
194436
+ });
194437
+ steps.push({
194438
+ kind: "gitWrite",
194439
+ command: discardPending()
194440
+ });
194441
+ return steps;
194442
+ }
194443
+ }
194444
+ });
194445
+ /**
194446
+ * The `EmitPreconditions` a step's/entry's assembled scripts assert against:
194447
+ * `expectedHead` is the resolved HEAD hash the `Rest` snapshot was already
194448
+ * taken at — `rest.context.currentCommit`, passed straight through, `""`
194449
+ * included (its own convention for "no commits yet", which `headAssertion`
194450
+ * now understands natively). When `targetState` declares `reviewWindow: true`
194451
+ * AND a window is currently open (`REVIEW_HEAD_REF` resolves), the script also
194452
+ * pins the window's saved-head hash — a later run whose window has since
194453
+ * closed or moved must re-decide rather than trust a stale script. No window,
194454
+ * or a non-review-window target, carries no `reviewWindow` precondition at
194455
+ * all.
194456
+ */
194457
+ const buildPreconditions = (git, rest, targetState) => gen(function* () {
194458
+ const expectedHead = rest.context.currentCommit;
194459
+ if (!isReviewWindowState(rest.def, targetState)) return { expectedHead };
194460
+ const windowHead = yield* git.readRefOption(REVIEW_HEAD_REF$1);
194461
+ if (isNone(windowHead)) return { expectedHead };
194462
+ return {
194463
+ expectedHead,
194464
+ reviewWindow: {
194465
+ ref: REVIEW_HEAD_REF$1,
194466
+ expectedHash: windowHead.value
194467
+ }
194468
+ };
194469
+ });
194470
+ /**
194471
+ * The `EmittedScripts` a `"commit"`/`"squash"` `StepPlan` carries alongside
194472
+ * `perform` — built from the SAME `renderDecision` output, but a render
194473
+ * failure collapses to an EMPTY script (`emitScripts` with no steps) instead
194474
+ * of failing this Effect: `perform` stays the one place a render failure is
194475
+ * ever reported to a caller, unchanged by this addition.
194476
+ */
194477
+ const buildStepScripts = (git, rest, decision, context, cost, model) => gen(function* () {
194478
+ const targetState = decision.kind === "commit" ? decision.to : decision.state;
194479
+ return emitScripts(yield* buildPreconditions(git, rest, targetState), yield* renderDecision(git, rest, decision, context, cost, model).pipe(catchAll(() => succeed$3([]))));
194480
+ });
194481
+ /**
194089
194482
  * Decide what invoking `invoker` at `rest` does (`PatternMachine.step` over
194090
194483
  * `rest.stepDef`/`rest.changes`/`rest.run.trace`), and — for a `"commit"`/
194091
194484
  * `"squash"` decision — build the `perform` Effect that actually writes it.
@@ -194099,19 +194492,19 @@ const executeDecision = (git, run, decision, context, cost, model) => gen(functi
194099
194492
  * reads it) or a freshly built `contextAt` for a squash (which renders a
194100
194493
  * DIFFERENT state's template with the step's cost folded in).
194101
194494
  */
194102
- const planStep = (rest, invoker, opts = {}) => {
194495
+ const planStep = (rest, invoker, opts = {}) => gen(function* () {
194103
194496
  const decision = step(rest.stepDef, rest.state, invoker, {
194104
194497
  changes: rest.changes,
194105
194498
  processTrace: rest.run.trace.map((entry) => entry.state)
194106
194499
  });
194107
- if (decision.kind === "refusal") return succeed$3({
194500
+ if (decision.kind === "refusal") return {
194108
194501
  kind: "refusal",
194109
194502
  message: formatStepRefusal(invoker, decision)
194110
- });
194111
- if (decision.kind === "noop") return succeed$3({
194503
+ };
194504
+ if (decision.kind === "noop") return {
194112
194505
  kind: "noop",
194113
194506
  state: decision.state
194114
- });
194507
+ };
194115
194508
  const { cost, model } = opts;
194116
194509
  const perform = gen(function* () {
194117
194510
  const git = yield* GitService;
@@ -194130,13 +194523,17 @@ const planStep = (rest, invoker, opts = {}) => {
194130
194523
  const context = yield* contextAt(rest, decision.state, invoker, cost, model);
194131
194524
  return yield* executeDecision(git, rest.run, decision, context, cost, model);
194132
194525
  });
194133
- return succeed$3({
194526
+ const git = yield* GitService;
194527
+ const scriptContext = decision.kind === "commit" ? rest.context : yield* contextAt(rest, decision.state, invoker, cost, model);
194528
+ const scripts = yield* buildStepScripts(git, rest, decision, scriptContext, cost, model);
194529
+ return {
194134
194530
  kind: decision.kind,
194135
194531
  state: rest.state,
194136
194532
  decision,
194137
- perform
194138
- });
194139
- };
194533
+ perform,
194534
+ scripts
194535
+ };
194536
+ });
194140
194537
  /**
194141
194538
  * `gtd step <actor> --entry <state>` (or its subcommand-less short form `gtd
194142
194539
  * --entry <state>`): start a brand NEW process at `entry.state` — any
@@ -194220,18 +194617,24 @@ const planEntry = (rest, actor, entry) => gen(function* () {
194220
194617
  ...base !== void 0 ? { base } : {},
194221
194618
  vars: varOverrides
194222
194619
  });
194620
+ const perform = gen(function* () {
194621
+ yield* (yield* GitService).commitAllWithPrefix(message);
194622
+ return {
194623
+ kind: "entry",
194624
+ state: entryState,
194625
+ subject
194626
+ };
194627
+ });
194628
+ const scriptsGit = yield* GitService;
194223
194629
  return {
194224
194630
  kind: "entry",
194225
194631
  state: entryState,
194226
194632
  subject,
194227
- perform: gen(function* () {
194228
- yield* (yield* GitService).commitAllWithPrefix(message);
194229
- return {
194230
- kind: "entry",
194231
- state: entryState,
194232
- subject
194233
- };
194234
- })
194633
+ perform,
194634
+ scripts: emitScripts(yield* buildPreconditions(scriptsGit, rest, entryState), [{
194635
+ kind: "gitWrite",
194636
+ command: commitAll(message)
194637
+ }])
194235
194638
  };
194236
194639
  });
194237
194640
  //#endregion
@@ -194249,24 +194652,37 @@ const planEntry = (rest, actor, entry) => gen(function* () {
194249
194652
  * `PatternMachine.StateDef`) — the pure engine never observes an open window,
194250
194653
  * exactly as before.
194251
194654
  *
194252
- * Lifecycle — driven from the program edge (`src/program.ts`), bracketing
194253
- * every state subcommand:
194254
- *
194255
- * - `closeReviewWindow` runs BEFORE anything reads or mutates state, keyed
194256
- * solely on `REVIEW_HEAD_REF` existing: `git reset --mixed <that ref>`
194257
- * restores HEAD/index exactly, leaving only the reviewer's own edits dirty
194258
- * (captured by the resting state's own `on` patterns like any other pending
194259
- * change). The pure machine therefore never sees the window.
194260
- * - `openReviewWindow` runs AFTER the subcommand finishes and self-guards on
194261
- * the resolved rest declaring `reviewWindow: true`: it saves HEAD to
194262
- * `REVIEW_HEAD_REF` (the base to `REVIEW_BASE_REF`), then `git reset --mixed
194263
- * <base>`. It re-arms after read-only commands (`gtd next` / `gtd status`)
194264
- * and refused invocations too, so the editor's diff view stays consistent no
194265
- * matter which command the loop last ran.
194266
- *
194267
- * Every open/close step is idempotent under re-entry, so a crash at any point
194268
- * is recovered by the next invocation's close (the saved ref also keeps the
194269
- * real head GC-reachable for the window's whole lifetime).
194655
+ * Lifecycle — driven from the program edge (`src/program.ts`), which DECIDES
194656
+ * with this module's pure/read-only halves and assembles the actual writes
194657
+ * into the two scripts a `gtd` command hands the external driver:
194658
+ *
194659
+ * - The CLOSE sequence (`decideCloseWindow` + `buildCloseWindowScript`) is
194660
+ * read-only in this module — it decides whether a window is open and safe
194661
+ * to close, keyed solely on `REVIEW_HEAD_REF` existing (the same
194662
+ * reviewed-branch/legacy-containment guards described below) — and
194663
+ * `program.ts`'s `buildRequiredScript` splices the resulting
194664
+ * `mixedResetTo`/`deleteRef`/`deleteRef` bash in as the LEAD steps of the
194665
+ * `required` script, ahead of the commit/squash steps themselves. Nothing
194666
+ * reads or mutates state until the driver actually runs that script; once
194667
+ * it does, HEAD/index are restored exactly, leaving only the reviewer's own
194668
+ * edits dirty (captured by the resting state's own `on` patterns like any
194669
+ * other pending change) — the pure machine never sees the window.
194670
+ * - The OPEN sequence (`decideOpenWindow` + `buildOpenWindowScript`) is
194671
+ * likewise read-only — it self-guards on the STEP'S OWN target state
194672
+ * declaring `reviewWindow: true` — and `program.ts`'s `openWindowScript`
194673
+ * turns that into the `optional` script's `updateRef`/`updateRef`/
194674
+ * `mixedResetTo`/`restoreStagedFrom` bash: saving HEAD to `REVIEW_HEAD_REF`
194675
+ * (the base to `REVIEW_BASE_REF`), then rewinding to the base. The driver
194676
+ * runs it AFTER the required script has already landed the new commit
194677
+ * (`buildOpenWindowScript`'s own doc comment explains why it pins the
194678
+ * literal string `"HEAD"`, never a resolved hash). It re-arms after
194679
+ * read-only commands (`gtd next` / `gtd status`) and refused invocations
194680
+ * too, so the editor's diff view stays consistent no matter which command
194681
+ * the loop last ran.
194682
+ *
194683
+ * Every close/open sequence is idempotent under re-entry, so a crash at any
194684
+ * point is recovered by the next invocation's close (the saved ref also keeps
194685
+ * the real head GC-reachable for the window's whole lifetime).
194270
194686
  */
194271
194687
  /**
194272
194688
  * The window's two state refs live in git's PER-WORKTREE `refs/worktree/*`
@@ -194284,7 +194700,7 @@ const planEntry = (rest, actor, entry) => gen(function* () {
194284
194700
  const REVIEW_HEAD_REF = "refs/worktree/gtd/review-head";
194285
194701
  const REVIEW_BASE_REF = "refs/worktree/gtd/review-base";
194286
194702
  /**
194287
- * The pre-7.2 SHARED refs. gtd never writes them any more; `closeReviewWindow`
194703
+ * The pre-7.2 SHARED refs. gtd never writes them any more; `decideCloseWindow`
194288
194704
  * only reads them to finish a window an older gtd left open across the upgrade
194289
194705
  * — and only when HEAD is still contained in the saved head, since a shared ref
194290
194706
  * may belong to a sibling worktree (see `openWindowRefs`).
@@ -194316,28 +194732,10 @@ const openWindowRefs = (git) => gen(function* () {
194316
194732
  legacy: true
194317
194733
  });
194318
194734
  });
194319
- /**
194320
- * Restore the real head if a review checkout window is open; no-op otherwise.
194321
- * Keyed on the saved-head ref's existence (see `openWindowRefs`), NOT on any
194322
- * config or machine state, so it always runs first and the machine never sees
194323
- * the window.
194324
- *
194325
- * Fails loudly — refs left in place — when HEAD is no longer on the reviewed
194326
- * branch (the base is not an ancestor of HEAD, e.g. after a branch switch): a
194327
- * mixed reset there would rewrite the wrong branch's tip. Manual commits on
194328
- * top of the base pass the guard: the reset keeps their content in the working
194329
- * tree, where the next turn's capture picks it up as review feedback.
194330
- *
194331
- * A window found under the LEGACY shared refs gets one extra guard: a shared
194332
- * ref may have been written by a sibling worktree, so the close only proceeds
194333
- * when HEAD is contained in the saved head — the shape a window this worktree
194334
- * opened itself has. Anything else refuses with the manual recovery rather
194335
- * than resetting this branch onto another worktree's work (issue #118).
194336
- */
194337
- const closeReviewWindow = gen(function* () {
194735
+ const decideCloseWindow = gen(function* () {
194338
194736
  const git = yield* GitService;
194339
194737
  const open = yield* openWindowRefs(git);
194340
- if (isNone(open)) return { closed: false };
194738
+ if (isNone(open)) return { shouldClose: false };
194341
194739
  const refs = open.value;
194342
194740
  const base = yield* git.readRefOption(refs.baseRef);
194343
194741
  if (isSome(base) && base.value !== EMPTY_TREE$1) {
@@ -194346,44 +194744,65 @@ const closeReviewWindow = gen(function* () {
194346
194744
  if (refs.legacy) {
194347
194745
  if (!(yield* git.isAncestor("HEAD", refs.headHash))) return yield* fail$2(/* @__PURE__ */ new Error(`a review checkout window is recorded under the shared ${LEGACY_REVIEW_HEAD_REF} (written by gtd 7.1 or older) but it does not belong to this worktree — HEAD is not contained in it. gtd now scopes the window per worktree (${REVIEW_HEAD_REF}); in the worktree that owns it, run ${manualRecovery(refs)}`));
194348
194746
  }
194349
- yield* git.mixedResetTo(refs.headRef);
194350
- yield* git.deleteRef(refs.headRef);
194351
- yield* git.deleteRef(refs.baseRef);
194352
- return { closed: true };
194747
+ return {
194748
+ shouldClose: true,
194749
+ refs
194750
+ };
194353
194751
  });
194354
194752
  /**
194355
- * Open (or re-arm) the review checkout window. Self-guarded: a no-op unless
194356
- * the resolved rest's state declares `reviewWindow: true` and a distinct base
194357
- * commit exists, so the caller invokes it unconditionally after every state
194358
- * subcommand.
194359
- *
194360
- * The base is the most-recent in-process `reviewBase` commit
194361
- * (`reviewBaseHash`) or, absent any such state, the process's diff base
194362
- * (`run.diffBase` — the process start, `startParentHash`, unless a
194363
- * `gtd review <commitish>` entry commit overrode it via a `Gtd-Review-Base:`
194364
- * trailer, see `computeProcessRun`). When that resolves to the empty tree (a
194365
- * process with no prior commit) or to HEAD itself (an empty process), there
194366
- * is nothing to surface and the window stays closed.
194367
- *
194368
- * Ordering is crash-safe: base ref → head ref → mixed reset → `.gtd/` index
194369
- * pin. A crash before the head-ref write leaves only a stale base ref
194370
- * (overwritten on the next open); a crash after it leaves HEAD ==
194371
- * review-head, which the next invocation's close restores as a no-op.
194372
- */
194373
- const openReviewWindow = gen(function* () {
194374
- const git = yield* GitService;
194375
- const def = (yield* (yield* ConfigService).load).workflow;
194376
- if (!(yield* git.hasCommits())) return { opened: false };
194377
- if (!isReviewWindowState(def, resolveState(def, yield* git.lastCommitSubject()))) return { opened: false };
194378
- const base = reviewBaseFor(def, yield* currentRun);
194379
- const headHash = yield* git.resolveRef("HEAD");
194380
- if (base === EMPTY_TREE$1 || base === headHash) return { opened: false };
194381
- yield* git.updateRef(REVIEW_BASE_REF, base);
194382
- yield* git.updateRef(REVIEW_HEAD_REF, headHash);
194383
- yield* git.mixedResetTo(base);
194384
- yield* git.restoreStagedFrom(REVIEW_HEAD_REF, [".gtd"]);
194385
- return { opened: true };
194386
- });
194753
+ * PURE: the close sequence's bash text — reset to the saved head, then delete
194754
+ * both refs — built from `src/GitScript.ts`'s `mixedResetTo`/`deleteRef`.
194755
+ * Assumes its caller's guards (the reviewed-branch check, the legacy
194756
+ * containment check — see `decideCloseWindow`) have already passed; this
194757
+ * builder performs none of them, only the write sequence for the case where
194758
+ * they did.
194759
+ *
194760
+ * The reset target is `refs.headHash` — the RESOLVED hash, not `refs.headRef`
194761
+ * by name — precisely so the sequence stays idempotent once the ref itself is
194762
+ * gone: the very next statement deletes `headRef`, so a script naming the ref
194763
+ * for the reset would fail on re-entry (the ref `git reset --mixed` would
194764
+ * need is the one the script just deleted). A hash stays resolvable
194765
+ * regardless, so a second run's reset is a true no-op (HEAD is already
194766
+ * there) and both deletes tolerate an already-missing ref. `legacy` on `refs`
194767
+ * goes unused here — the script only needs `headRef`/`baseRef`/`headHash` —
194768
+ * but the parameter carries the whole `WindowRefs` record so a caller can
194769
+ * pass `decideCloseWindow`'s resolved `refs` straight through.
194770
+ */
194771
+ const buildCloseWindowScript = (refs) => [
194772
+ mixedResetTo(refs.headHash),
194773
+ deleteRef(refs.headRef),
194774
+ deleteRef(refs.baseRef)
194775
+ ].join(" &&\n");
194776
+ const decideOpenWindow = (def, target, run, head) => {
194777
+ if (!isReviewWindowState(def, target)) return { shouldOpen: false };
194778
+ const base = reviewBaseFor(def, run);
194779
+ if (base === EMPTY_TREE$1 || base === head) return { shouldOpen: false };
194780
+ return {
194781
+ shouldOpen: true,
194782
+ base,
194783
+ head
194784
+ };
194785
+ };
194786
+ /**
194787
+ * PURE: the open sequence's bash text — base-ref write, head-ref write,
194788
+ * mixed-reset-to-base, then the `.gtd/` restore-staged-from pin, built from
194789
+ * `src/GitScript.ts`'s `updateRef`/`mixedResetTo`/`restoreStagedFrom`, in the
194790
+ * same crash-safe order `openReviewWindow` uses (base ref → head ref → mixed
194791
+ * reset → `.gtd/` pin). Deliberately no `git add --intent-to-add` anywhere —
194792
+ * new files stay untracked, for the exact two reasons `openReviewWindow`'s
194793
+ * own doc comment records — and no whole-tree index write: `restoreStagedFrom`
194794
+ * is scoped to `.gtd/` alone.
194795
+ *
194796
+ * Idempotent under re-entry: `updateRef`/`mixedResetTo` re-pointing at the
194797
+ * same target is a no-op, and `restoreStagedFrom` re-pinning an already-pinned
194798
+ * path is a no-op.
194799
+ */
194800
+ const buildOpenWindowScript = (decision) => [
194801
+ updateRef(REVIEW_BASE_REF, decision.base),
194802
+ updateRef(REVIEW_HEAD_REF, decision.head),
194803
+ mixedResetTo(decision.base),
194804
+ restoreStagedFrom(REVIEW_HEAD_REF, [".gtd"])
194805
+ ].join(" &&\n");
194387
194806
  //#endregion
194388
194807
  //#region node_modules/vscode-languageserver/lib/common/utils/is.js
194389
194808
  var require_is$2 = /* @__PURE__ */ __commonJSMin(((exports) => {
@@ -205438,17 +205857,17 @@ const resolveBuiltInMode = (mode) => {
205438
205857
  };
205439
205858
  const steeringCapabilities = (resolved) => {
205440
205859
  if (resolved === void 0) return {};
205860
+ const seeded = resolved.builtIn !== void 0 && resolved.validate?.kind === "command" && isSeededValidateCommand(resolved.mode, resolved.validate.command);
205861
+ const liveValidate = resolved.builtIn !== void 0 && (resolved.validate?.kind === "builtin" || seeded) ? resolved.builtIn.validate : void 0;
205441
205862
  return {
205442
205863
  ...resolved.builtIn !== void 0 ? { format: resolved.builtIn } : {},
205443
- ...resolved.validate?.kind === "builtin" ? { liveValidate: resolved.validate.format.validate } : {},
205444
- ...resolved.validate?.kind === "command" ? { externalValidate: true } : {}
205864
+ ...liveValidate !== void 0 ? { liveValidate } : {},
205865
+ ...resolved.validate?.kind === "command" && !seeded ? { externalValidate: true } : {}
205445
205866
  };
205446
205867
  };
205447
205868
  /** The error message for a `mode:` that resolves to nothing — lists what the active workflow does know. */
205448
205869
  const unknownModeMessage = (def, state, mode) => `state "${state}": mode "${mode}" is not defined by the active workflow (known modes: ${knownModes(def).join(", ")})`;
205449
205870
  const errorText$1 = (e) => e instanceof Error ? e.message : String(e);
205450
- /** How a non-zero exit reads in a message: a status number, or a signal death. */
205451
- const exitText = (status) => status === null ? "a signal" : `status ${status}`;
205452
205871
  /** Render one command template, turning an Eta failure into a named error rather than executing anything. */
205453
205872
  const renderCommand = (mode, key, command, file, context) => try_({
205454
205873
  try: () => renderModeCommand(command, {
@@ -205457,41 +205876,22 @@ const renderCommand = (mode, key, command, file, context) => try_({
205457
205876
  }),
205458
205877
  catch: (e) => /* @__PURE__ */ new Error(`mode "${mode}": "${key}" command failed to render — ${errorText$1(e)}`)
205459
205878
  });
205460
- /** The findings a non-zero `validate` exit reports: its output lines, or a synthesized line when it said nothing. */
205461
- const findingsFrom = (mode, outcome) => {
205462
- const lines = outcome.output.split("\n").map((line) => line.trimEnd()).filter((line) => line.length > 0);
205463
- if (lines.length > 0) return lines;
205464
- return [`mode "${mode}": validate command exited with ${exitText(outcome.status)} and no output`];
205465
- };
205466
- /**
205467
- * Format `file` in place by running the mode's `format:` command. A mode with
205468
- * no formatter (every mode, until a `modes:` layer declares one — gtd ships
205469
- * none) formats nothing. A failing command is a hard error: the file is left
205470
- * exactly as it was and the caller refuses, rather than validating a
205471
- * half-formatted file.
205472
- */
205473
- const formatSteeringFile = (resolved, file, context) => gen(function* () {
205474
- const command = resolved.formatCommand;
205475
- if (command === void 0) return;
205476
- const rendered = yield* renderCommand(resolved.mode, "format", command, file, context);
205477
- const outcome = yield* (yield* CommandRunner).bash(rendered);
205478
- if (outcome.status !== 0) return yield* fail$2(/* @__PURE__ */ new Error(`mode "${resolved.mode}": format command exited with ${exitText(outcome.status)}${outcome.output.trim().length > 0 ? `:\n${outcome.output.trimEnd()}` : ""}`));
205479
- });
205480
205879
  /**
205481
- * Validate `file` per its mode, returning the findings (empty = valid). A
205482
- * built-in validator runs its pure parser over `content` (the caller's already
205483
- * read this — see `src/StepGuards.ts`, which samples the file's bytes AFTER
205484
- * `formatSteeringFile` has run); a declared `validate:` command runs instead
205485
- * and reads nothing itself — the command owns how it inspects the file. A mode
205486
- * with neither reports no findings.
205487
- */
205488
- const validateSteeringFile = (resolved, file, content, context) => gen(function* () {
205489
- const validator = resolved.validate;
205490
- if (validator === void 0) return [];
205491
- if (validator.kind === "builtin") return validator.format.validate(content);
205492
- const rendered = yield* renderCommand(resolved.mode, "validate", validator.command, file, context);
205493
- const outcome = yield* (yield* CommandRunner).bash(rendered);
205494
- return outcome.status === 0 ? [] : findingsFrom(resolved.mode, outcome);
205880
+ * Render a mode's `format:`/`validate:` commands as plain strings, WITHOUT
205881
+ * running either — for a caller (an emitter that prints scripts rather than
205882
+ * executing them) that wants the commands themselves, not their outcome.
205883
+ * Unlike its siblings above, this needs no `CommandRunner`: it shares their
205884
+ * `renderCommand` helper for the rendering (and its errors) but never reaches
205885
+ * for a runner to execute the result. Order matches `formatAndValidateSteeringFile`
205886
+ * (format then validate); a half the mode doesn't declare — no `formatCommand`,
205887
+ * or a `validate` that is absent or `"builtin"` (an in-process parser has no
205888
+ * shell command to render) — is OMITTED from the result, not rendered as `""`.
205889
+ */
205890
+ const renderSteeringCommands = (resolved, file, context) => gen(function* () {
205891
+ const commands = [];
205892
+ if (resolved.formatCommand !== void 0) commands.push(yield* renderCommand(resolved.mode, "format", resolved.formatCommand, file, context));
205893
+ if (resolved.validate?.kind === "command") commands.push(yield* renderCommand(resolved.mode, "validate", resolved.validate.command, file, context));
205894
+ return commands;
205495
205895
  });
205496
205896
  //#endregion
205497
205897
  //#region src/Lsp.ts
@@ -206174,27 +206574,6 @@ const openInBrowser = (url) => {
206174
206574
  //#region src/StepGuards.ts
206175
206575
  /** 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. */
206176
206576
  const normalizeCheckboxes = (content) => content.replace(/\[[ xX]\]/g, "[_]");
206177
- const steeringFileGuard = {
206178
- name: "steering-file",
206179
- appliesTo: (rest) => rest.stateDef.mode !== void 0,
206180
- prepare: (ctx) => gen(function* () {
206181
- const mode = ctx.rest.stateDef.mode;
206182
- const resolved = resolveSteeringMode(ctx.rest.def, mode);
206183
- if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(ctx.rest.def, ctx.rest.state, mode)));
206184
- if ((yield* RepoFiles).working(ctx.file) === void 0) return;
206185
- yield* formatSteeringFile(resolved, ctx.file, ctx.template);
206186
- }),
206187
- check: (ctx) => gen(function* () {
206188
- const mode = ctx.rest.stateDef.mode;
206189
- const resolved = resolveSteeringMode(ctx.rest.def, mode);
206190
- if (resolved === void 0) return void 0;
206191
- const current = yield* ctx.worktree;
206192
- if (current === void 0) return void 0;
206193
- const errors = yield* validateSteeringFile(resolved, ctx.file, current, ctx.template);
206194
- if (errors.length === 0) return void 0;
206195
- return `${ctx.file} is not valid at "${ctx.rest.state}" — fix these before stepping:\n${errors.map((e) => ` - ${e}`).join("\n")}`;
206196
- })
206197
- };
206198
206577
  /** The `mode:` name of gtd's built-in REVIEW.md checkbox validator — the only mode the sign-off guard understands. */
206199
206578
  const REVIEW_MODE = "review";
206200
206579
  const reviewSignoffGuard = {
@@ -206224,9 +206603,8 @@ const feedbackProgressGuard = {
206224
206603
  };
206225
206604
  /** The `mode:` name of gtd's built-in open-questions checkbox format — the only mode the answer-completeness guard acts on. */
206226
206605
  const QA_MODE = "qa";
206227
- /** The four step-capture guards, in EVALUATION order — message precedence when two would fire is observable, so this order is the contract (matches the old hand-copied call order). */
206606
+ /** The three step-capture guards, in EVALUATION order — message precedence when two would fire is observable, so this order is the contract (matches the old hand-copied call order, minus the now-removed steering-file guard). */
206228
206607
  const stepGuards = [
206229
- steeringFileGuard,
206230
206608
  reviewSignoffGuard,
206231
206609
  feedbackProgressGuard,
206232
206610
  {
@@ -206242,7 +206620,7 @@ const stepGuards = [
206242
206620
  ];
206243
206621
  /**
206244
206622
  * The `gtd step` capture gate: run every guard the resolved rest's state
206245
- * applies to, formatting (write phase) before validating (read phase) — see
206623
+ * applies to against ONE sample of the current committed/working bytes — see
206246
206624
  * the module docstring — and fail with the first refusal's reason, prefixed
206247
206625
  * `gtd step <invoker>: ` (the one place that prefix, and the `cliErrorLine`
206248
206626
  * `/^gtd[: ]/` contract, is satisfied). A no-op for a squash/no-op decision, a
@@ -206264,7 +206642,6 @@ const enforceStepGuards = (input) => gen(function* () {
206264
206642
  hasCodeChange,
206265
206643
  template: input.context
206266
206644
  };
206267
- for (const g of applicable) if (g.prepare !== void 0) yield* g.prepare(base);
206268
206645
  const files = yield* RepoFiles;
206269
206646
  const head = yield* cached(files.committed(file));
206270
206647
  const worktree = yield* cached(try_(() => files.working(file)));
@@ -206278,37 +206655,6 @@ const enforceStepGuards = (input) => gen(function* () {
206278
206655
  if (refusal !== void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd step ${input.invoker}: ${refusal}`));
206279
206656
  }
206280
206657
  });
206281
- /**
206282
- * `gtd validate`'s shared evaluation: format the resolved rest's steering
206283
- * file in place, then validate its formatted contents — the SAME
206284
- * format-then-validate pair the `steering-file` guard's `prepare`/`check`
206285
- * split runs, so both format and check the same way (an agent's fresh draft
206286
- * and a human's edit are treated alike). `present` is false — and nothing is
206287
- * formatted or validated — when the state declares no steering file, or the
206288
- * file is absent (e.g. a human deleted `.gtd/REVIEW.md` to approve).
206289
- */
206290
- const checkSteeringFile = (rest, context, file) => gen(function* () {
206291
- const mode = rest.stateDef.mode;
206292
- if (file === void 0 || mode === void 0) return {
206293
- file,
206294
- present: false,
206295
- errors: []
206296
- };
206297
- const files = yield* RepoFiles;
206298
- if (files.working(file) === void 0) return {
206299
- file,
206300
- present: false,
206301
- errors: []
206302
- };
206303
- const resolved = resolveSteeringMode(rest.def, mode);
206304
- if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(rest.def, rest.state, mode)));
206305
- yield* formatSteeringFile(resolved, file, context);
206306
- return {
206307
- file,
206308
- present: true,
206309
- errors: yield* validateSteeringFile(resolved, file, files.working(file) ?? "", context)
206310
- };
206311
- });
206312
206658
  //#endregion
206313
206659
  //#region src/program.ts
206314
206660
  /**
@@ -206353,57 +206699,144 @@ const runInitCommand = (json, write) => gen(function* () {
206353
206699
  }) + "\n");
206354
206700
  else write("Wrote .gtdrc.json seeding the default variables (the test command) and a\nPrettier formatting suggestion. gtd runs its built-in workflow by default — add\na workflow: key only if you want to customize the machine itself.\n\n" + (inRepo ? "Review and commit it before starting: an uncommitted .gtdrc.json counts as a\npending change, so the initial state would capture it on the first step. Once\ncommitted, run `gtd step human` to begin.\n" : "This directory is not a git repository, so there is nothing to commit here. The\nconfig applies to any gtd repository nested below it — gtd discovers it by\nwalking up from the repository root. Run `gtd step human` from such a repo to\nbegin.\n"));
206355
206701
  });
206702
+ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
206703
+ /** The `EmitPreconditions` every script this file assembles asserts against: the resolved HEAD hash the snapshot driving it was taken at — `""` when the repo has no commits yet, `headAssertion`'s own convention for that case. */
206704
+ const headPreconditions = (currentCommit) => ({ expectedHead: currentCommit });
206705
+ /**
206706
+ * The resting state's own steering-mode commands, embedded ahead of the
206707
+ * commit — the step script now carries its own format/validate pair instead
206708
+ * of gtd running it in process (see `src/StepGuards.ts`'s shrunk registry).
206709
+ * Empty for a state declaring no `file:`+`mode:` pair; an unknown mode name
206710
+ * is a refusal, exactly as it was when gtd ran the commands itself.
206711
+ */
206712
+ const steeringModeSteps = (rest) => gen(function* () {
206713
+ const file = rest.hints.file;
206714
+ const mode = rest.stateDef.mode;
206715
+ if (file === void 0 || mode === void 0) return [];
206716
+ const resolved = resolveSteeringMode(rest.def, mode);
206717
+ if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(rest.def, rest.state, mode)));
206718
+ return (yield* renderSteeringCommands(resolved, file, rest.context)).map((command) => ({
206719
+ kind: "command",
206720
+ command
206721
+ }));
206722
+ });
206723
+ /**
206724
+ * The `optional` half: re-open the review checkout window when the step lands
206725
+ * at a `reviewWindow: true` state, `""` otherwise.
206726
+ *
206727
+ * `"HEAD"` is deliberately the literal string, not a resolved hash — git
206728
+ * resolves it at the moment this script runs, which is after the required
206729
+ * script has already landed the new commit (see `ReviewWindow.ts`'s
206730
+ * `decideOpenWindow` doc comment). For the same reason it carries no
206731
+ * `expectedHead` precondition: the HEAD it will see is a hash that does not
206732
+ * exist yet at decide time (see `EmitPreconditions`).
206733
+ */
206734
+ const openWindowScript = (rest, targetState) => {
206735
+ const decision = decideOpenWindow(rest.def, targetState, rest.run, "HEAD");
206736
+ if (!decision.shouldOpen) return "";
206737
+ return emitScripts({}, [], [{
206738
+ kind: "gitWrite",
206739
+ command: buildOpenWindowScript({
206740
+ base: decision.base,
206741
+ head: "HEAD"
206742
+ })
206743
+ }]).optional;
206744
+ };
206745
+ /**
206746
+ * The subject the required script WILL produce. A `"commit"` decision's is
206747
+ * fully deterministic already; a `"squash"` decision's is only known once its
206748
+ * template is rendered. Unreachable-on-failure: `renderDecision` already
206749
+ * rendered the same template and refused the command if it threw, so this
206750
+ * second render can never be the first to fail.
206751
+ */
206752
+ const previewSubject = (decision, rest) => decision.kind === "commit" ? succeed$3(decision.subject) : try_(() => renderStateTemplate(decision.template, rest.context).split("\n")[0].trim()).pipe(catchAll(() => succeed$3(null)));
206753
+ /**
206754
+ * The `required` half: everything that decides what lands in git, in order —
206755
+ * the review-window CLOSE (when one is open), the resting state's own
206756
+ * steering-mode format/validate commands, then the commit/squash steps
206757
+ * themselves.
206758
+ */
206759
+ const buildRequiredScript = (rest, decision, invoker, cost, model) => gen(function* () {
206760
+ const git = yield* GitService;
206761
+ const closeDecision = yield* decideCloseWindow;
206762
+ const commitContext = decision.kind === "commit" ? rest.context : yield* contextAt(rest, decision.state, invoker, cost, model);
206763
+ const steps = [
206764
+ ...closeDecision.shouldClose ? [{
206765
+ kind: "gitWrite",
206766
+ command: buildCloseWindowScript(closeDecision.refs)
206767
+ }] : [],
206768
+ ...yield* steeringModeSteps(rest),
206769
+ ...yield* renderDecision(git, rest, decision, commitContext, cost, model)
206770
+ ];
206771
+ return emitScripts(headPreconditions(rest.context.currentCommit), steps).required;
206772
+ });
206773
+ /** A step's `--cost`/`--model` as `planStep` options — each key OMITTED (never `undefined`-valued) when the flag was not given. */
206774
+ const stepOptions = (cost, model) => ({
206775
+ ...cost !== void 0 ? { cost } : {},
206776
+ ...model !== void 0 ? { model } : {}
206777
+ });
206356
206778
  /**
206357
- * Authenticate `invoker` against the currently resolved rest and perform the
206358
- * one resulting transition (commit or squash) for `gtd step`. `currentRest` →
206359
- * `planStep` decides; the four capture gates sit between the plan and
206360
- * `plan.perform` (nothing has written git yet), then `perform` runs. Refusals
206361
- * fail the Effect with a formatted message; a no-op/reset returns `subject:
206362
- * null` rather than failing (exit zero, per the plan's "clean no-op exits
206363
- * zero").
206779
+ * Authenticate `invoker` against the currently resolved rest and decide the
206780
+ * one resulting transition (commit or squash) for `gtd step` — WITHOUT
206781
+ * performing it. `currentRest` → `planStep` decides; the step-capture guards
206782
+ * (`src/StepGuards.ts`) then refuse before anything is emitted, exactly as
206783
+ * before. What used to be `plan.perform` is now assembled by hand into a
206784
+ * `required` script (the review-window CLOSE, when one is open; the resting
206785
+ * state's steering-mode format/validate commands, when it declares
206786
+ * `file:`+`mode:`; then the commit/squash steps themselves, via
206787
+ * `renderDecision`) and an `optional` one (the review-window OPEN, when the
206788
+ * target declares `reviewWindow: true`) — see the "Review-window management"
206789
+ * recipe this mirrors. Refusals fail the Effect with a formatted message; a
206790
+ * no-op returns `subject: null` and empty scripts (exit zero, nothing to
206791
+ * run).
206792
+ *
206793
+ * `plan.scripts` (built by `Edge.ts` itself) is NOT reused here: its baked-in
206794
+ * precondition uses `rest.context.currentCommit`, the LITERAL git HEAD at
206795
+ * decide time — which, while a review window is open, is the window's BASE,
206796
+ * not the real head — and it contains no window close/open steps at all. This
206797
+ * function builds its own required/optional pair to sidestep that trap.
206364
206798
  */
206365
206799
  const stepAsActor = (invoker, cost, model) => gen(function* () {
206366
206800
  const rest = yield* currentRest;
206367
- const plan = yield* planStep(rest, invoker, {
206368
- ...cost !== void 0 ? { cost } : {},
206369
- ...model !== void 0 ? { model } : {}
206370
- });
206801
+ const plan = yield* planStep(rest, invoker, stepOptions(cost, model));
206371
206802
  if (plan.kind === "refusal") return yield* fail$2(new Error(plan.message));
206372
206803
  if (plan.kind === "noop") return {
206373
206804
  state: plan.state,
206374
206805
  subject: null,
206375
206806
  cost: null,
206376
- model: null
206807
+ model: null,
206808
+ required: "",
206809
+ optional: ""
206377
206810
  };
206811
+ const decision = plan.decision;
206812
+ if (decision.kind !== "commit" && decision.kind !== "squash") return yield* fail$2(/* @__PURE__ */ new Error(`gtd: internal error — plan kind "${plan.kind}" but decision kind "${decision.kind}"`));
206378
206813
  yield* enforceStepGuards({
206379
206814
  rest,
206380
206815
  context: rest.context,
206381
206816
  file: rest.hints.file,
206382
206817
  changes: rest.changes,
206383
206818
  invoker,
206384
- kind: plan.kind
206819
+ kind: decision.kind
206385
206820
  });
206386
- const outcome = yield* plan.perform;
206387
- if (outcome.kind === "reset" || outcome.kind === "noop") return {
206388
- state: outcome.state,
206389
- subject: null,
206390
- cost: null,
206391
- model: null
206392
- };
206821
+ const targetState = decision.kind === "commit" ? decision.to : decision.state;
206393
206822
  return {
206394
206823
  state: rest.state,
206395
- subject: outcome.subject,
206824
+ subject: yield* previewSubject(decision, rest),
206396
206825
  cost: cost ?? null,
206397
- model: model ?? null
206826
+ model: model ?? null,
206827
+ required: yield* buildRequiredScript(rest, decision, invoker, cost, model),
206828
+ optional: openWindowScript(rest, targetState)
206398
206829
  };
206399
206830
  });
206400
- /** Renders `stepAsActor`'s result for `gtd step`. */
206831
+ /** Renders `stepAsActor`'s result for `gtd step`. Plain text names the (previewed) resulting subject exactly as before; `--json` additionally carries `required`/`optional` — the bash the external driver runs to actually land it. */
206401
206832
  const reportStepResult = (result, json, write) => {
206402
206833
  if (json) write(JSON.stringify({
206403
206834
  state: result.state,
206404
206835
  subject: result.subject,
206405
206836
  ...result.cost !== null ? { cost: result.cost } : {},
206406
- ...result.model !== null ? { model: result.model } : {}
206837
+ ...result.model !== null ? { model: result.model } : {},
206838
+ required: result.required,
206839
+ optional: result.optional
206407
206840
  }) + "\n");
206408
206841
  else write(result.subject !== null ? `committed: ${result.subject}\n` : `nothing to do at "${result.state}"\n`);
206409
206842
  };
@@ -206461,14 +206894,14 @@ const runEntryCommand = (actor, entryState, varOverrides, json, write, commandLa
206461
206894
  vars: varOverrides
206462
206895
  });
206463
206896
  if (plan.kind === "refusal") return yield* fail$2(new Error(plan.message));
206464
- yield* plan.perform;
206465
206897
  if (json) write(JSON.stringify({
206466
206898
  state: plan.state,
206467
- subject: plan.subject
206899
+ subject: plan.subject,
206900
+ required: plan.scripts.required,
206901
+ optional: plan.scripts.optional
206468
206902
  }) + "\n");
206469
206903
  else write(`committed: ${plan.subject}\n`);
206470
206904
  });
206471
- const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
206472
206905
  /**
206473
206906
  * `gtd abandon`: end the process currently underway WITHOUT completing it,
206474
206907
  * returning the machine to the workflow's initial state — the recovery path out
@@ -206500,6 +206933,13 @@ const EMPTY_TREE = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
206500
206933
  * that fails when there is nothing to recover is a worse tool. The one refusal
206501
206934
  * is a process whose first commit is the repository's own root commit: there is
206502
206935
  * no earlier commit to rewind to.
206936
+ *
206937
+ * The read-side refusal/no-op checks above stay direct `GitService` reads (the
206938
+ * documented exception, `AGENTS.md`) — abandon must still work when a `Rest`
206939
+ * would refuse. The actual mutation (retaining history, then the mixed reset)
206940
+ * is no longer PERFORMED here: it's emitted as a `required` bash script for
206941
+ * the external driver to run, built from `src/GitScript.ts`'s pure
206942
+ * `updateRef`/`mixedResetTo` — narrowing the exception further still.
206503
206943
  */
206504
206944
  const runAbandonCommand = (json, write) => gen(function* () {
206505
206945
  const git = yield* GitService;
@@ -206516,14 +206956,31 @@ const runAbandonCommand = (json, write) => gen(function* () {
206516
206956
  }
206517
206957
  const restState = run.trace[run.trace.length - 1].state;
206518
206958
  if (run.startParentHash === EMPTY_TREE) return yield* fail$2(/* @__PURE__ */ new Error(`gtd abandon: the process underway (resting at "${restState}") starts at the repository's first commit — there is no earlier commit to rewind to`));
206519
- yield* retainHistory(git, yield* git.resolveRef("HEAD"), run.startParentHash);
206520
- yield* git.mixedResetTo(run.startParentHash);
206521
- const subject = yield* git.lastCommitSubject();
206959
+ const closeDecision = yield* decideCloseWindow;
206960
+ const tip = closeDecision.shouldClose ? closeDecision.refs.headHash : yield* git.resolveRef("HEAD");
206961
+ const subject = yield* git.lastCommitSubject(run.startParentHash);
206962
+ const steps = [
206963
+ ...closeDecision.shouldClose ? [{
206964
+ kind: "gitWrite",
206965
+ command: buildCloseWindowScript(closeDecision.refs)
206966
+ }] : [],
206967
+ {
206968
+ kind: "gitWrite",
206969
+ command: updateRef(HISTORY_REF, tip)
206970
+ },
206971
+ {
206972
+ kind: "gitWrite",
206973
+ command: mixedResetTo(run.startParentHash)
206974
+ }
206975
+ ];
206976
+ const required = emitScripts(headPreconditions(yield* git.resolveRef("HEAD")), steps).required;
206522
206977
  if (json) write(JSON.stringify({
206523
206978
  state: initial,
206524
206979
  abandoned: true,
206525
206980
  from: restState,
206526
- head: run.startParentHash
206981
+ head: run.startParentHash,
206982
+ required,
206983
+ optional: ""
206527
206984
  }) + "\n");
206528
206985
  else write(`abandoned the process resting at "${restState}" — HEAD is back at ${run.startParentHash.slice(0, 7)} ("${subject}"), resting at "${initial}".\nEverything the process produced is kept as uncommitted changes (\`git status\`); discard them with \`git checkout -- . && git clean -fd .gtd\` for a clean tree.\n`);
206529
206986
  });
@@ -206537,7 +206994,10 @@ const runAbandonCommand = (json, write) => gen(function* () {
206537
206994
  * Guarded by `restorability` so it never discards work it didn't create:
206538
206995
  * refuses on a dirty working tree, when there is no retained history, and
206539
206996
  * when HEAD has advanced past the retained tip with commits that would be
206540
- * lost by resetting.
206997
+ * lost by resetting. Those checks stay direct `GitService` reads (same
206998
+ * documented exception as `gtd abandon`); the mutation itself (the hard
206999
+ * reset, then clearing the retained-history ref) is emitted as a `required`
207000
+ * script instead of performed here.
206541
207001
  */
206542
207002
  const runRestoreCommand = (json, write) => gen(function* () {
206543
207003
  const git = yield* GitService;
@@ -206549,31 +207009,69 @@ const runRestoreCommand = (json, write) => gen(function* () {
206549
207009
  const headHash = yield* git.resolveRef("HEAD");
206550
207010
  const check = yield* restorability(git, headHash, yield* git.lastCommitMessage(), tip);
206551
207011
  if (!check.ok) return yield* fail$2(/* @__PURE__ */ new Error(`gtd restore: ${check.reason} — HEAD ${headHash.slice(0, 7)} is ahead of the retained tip ${tip.slice(0, 7)}.`));
206552
- yield* git.hardResetTo(tip);
206553
- yield* clearRetainedHistory(git);
206554
- const after = yield* currentRest;
206555
- const subject = yield* git.lastCommitSubject();
207012
+ const after = yield* restAt(tip);
207013
+ const subject = yield* git.lastCommitSubject(tip);
207014
+ const steps = [{
207015
+ kind: "gitWrite",
207016
+ command: hardResetTo(tip)
207017
+ }, {
207018
+ kind: "gitWrite",
207019
+ command: deleteRef(HISTORY_REF)
207020
+ }];
207021
+ const required = emitScripts(headPreconditions(headHash), steps).required;
206556
207022
  if (json) write(JSON.stringify({
206557
207023
  state: after.state,
206558
207024
  restored: true,
206559
207025
  to: tip,
206560
- from: before.state
207026
+ from: before.state,
207027
+ required,
207028
+ optional: ""
206561
207029
  }) + "\n");
206562
207030
  else write(`restored the retained history — HEAD is back at ${tip.slice(0, 7)} ("${subject}"), resting at "${after.state}". Resume with the loop, or \`git reset\` to any earlier turn to restart from there.\n`);
206563
207031
  });
206564
207032
  /**
207033
+ * The mode's own resolved VALIDATE command, rendered against `file` — the
207034
+ * command `selfValidateInstruction` names. Resolution mirrors
207035
+ * `renderSteeringCommands`'s own layering (`src/SteeringMode.ts`) but picks
207036
+ * out the validate half alone: a declared `validate:` command (or, for
207037
+ * `qa`/`review`, `PatternConfig.ts`'s own seeded `gtd check <mode> '<file>'`
207038
+ * default — every compiled definition carries ONE of the two) renders as the
207039
+ * LAST command `renderSteeringCommands` would emit (format, if any, always
207040
+ * comes first); a mode resolving to a `"builtin"` validator (no shell command
207041
+ * at all — only reachable when `resolveSteeringMode` is asked about a mode
207042
+ * outside the active definition's own compiled `modes:`, since a real
207043
+ * definition always seeds one) or to no mode at all names the leaf
207044
+ * `gtd check <mode> '<file>'` invocation directly instead
207045
+ * (`seededValidateCommand`), so there is always something concrete to name.
207046
+ */
207047
+ const resolveSelfValidateCommand = (def, mode, file, context) => gen(function* () {
207048
+ const resolved = resolveSteeringMode(def, mode);
207049
+ if (resolved?.validate?.kind === "command") {
207050
+ const rendered = yield* renderSteeringCommands(resolved, file, context);
207051
+ const validateCommand = rendered[rendered.length - 1];
207052
+ if (validateCommand !== void 0) return validateCommand;
207053
+ }
207054
+ return yield* try_({
207055
+ try: () => renderModeCommand(seededValidateCommand(mode), {
207056
+ ...context,
207057
+ file
207058
+ }),
207059
+ catch: (e) => e instanceof Error ? e : new Error(String(e))
207060
+ });
207061
+ });
207062
+ /**
206565
207063
  * The self-validation instruction gtd APPENDS to a `prompt` rest that declares
206566
207064
  * both `file:` and `mode:` — i.e. a state whose actor hands over a steering
206567
- * file `gtd validate` formats and checks. Appended ONLY to plain `gtd next`
206568
- * output (for a human or a simple driver who reads the prompt and hands it to
206569
- * an agent, so the agent self-validates); withheld from `gtd next --json`,
206570
- * where the driving loop instead runs `gtd validate` after the turn and
206571
- * re-prompts on findings (see the `bin/gtd` loop driver). This is
206572
- * advisory: `gtd step` runs the same format-and-validate gate and REFUSES a
206573
- * turn whose steering file is invalid (see `stepAsActor`), so a malformed file
206574
- * is never captured whether or not this instruction was followed.
206575
- */
206576
- const selfValidateInstruction = (file) => `\nBefore finishing your turn, run \`gtd validate\` — it formats and checks ${file} — and fix every violation it reports until it exits cleanly. Do not finish while it still reports violations.\n`;
207065
+ * file some command validates. Appended ONLY to plain `gtd next` output (for a
207066
+ * human or a simple driver who reads the prompt and hands it to an agent, so
207067
+ * the agent self-validates); withheld from `gtd next --json`, where the
207068
+ * driving loop instead runs `gtd validate` after the turn and re-prompts on
207069
+ * findings (see the `bin/gtd` loop driver). This is advisory: `gtd step`
207070
+ * embeds that same command ahead of its own commit and REFUSES a turn whose
207071
+ * steering file is invalid (see `stepAsActor`), so a malformed file is never
207072
+ * captured whether or not this instruction was followed.
207073
+ */
207074
+ const selfValidateInstruction = (command, file) => `\nBefore finishing your turn, run \`${command}\` — it checks ${file} — and fix every violation it reports until it exits cleanly. Do not finish while it still reports violations.\n`;
206577
207075
  /** True when a rendered rest is a `prompt` turn that hands over a validatable steering file (`file:`+`mode:` both declared). */
206578
207076
  const emitsValidatablePrompt = (rendered) => rendered.kind === "prompt" && rendered.file !== void 0 && rendered.mode !== void 0;
206579
207077
  /** `gtd next [--json]`: pure emitter of the resolved rest's rendered content (no mutation). */
@@ -206590,51 +207088,116 @@ const nextJsonOutput = (rendered) => JSON.stringify({
206590
207088
  ...rendered.mode !== void 0 ? { mode: rendered.mode } : {},
206591
207089
  ...rendered.edges.length > 0 ? { edges: rendered.edges } : {}
206592
207090
  }) + "\n";
206593
- /** `gtd next`'s plain-text output: the rendered content (newline-terminated), plus the self-validation instruction when the rest is a validatable prompt (see `emitsValidatablePrompt`). */
206594
- const nextPlainOutput = (rendered) => {
207091
+ /** `gtd next`'s plain-text output: the rendered content (newline-terminated), plus the self-validation instruction (naming `selfValidateCommand`, when resolved) when the rest is a validatable prompt. */
207092
+ const nextPlainOutput = (rendered, selfValidateCommand) => {
206595
207093
  const base = rendered.content.endsWith("\n") ? rendered.content : rendered.content + "\n";
206596
- return emitsValidatablePrompt(rendered) ? base + selfValidateInstruction(rendered.file) : base;
207094
+ return emitsValidatablePrompt(rendered) && selfValidateCommand !== void 0 ? base + selfValidateInstruction(selfValidateCommand, rendered.file) : base;
206597
207095
  };
206598
207096
  const runNextCommand = (json, write) => gen(function* () {
206599
- const rendered = yield* renderRest(yield* currentRest);
206600
- write(json ? nextJsonOutput(rendered) : nextPlainOutput(rendered));
207097
+ const rest = yield* currentRest;
207098
+ const rendered = yield* renderRest(rest);
207099
+ const selfValidateCommand = !json && emitsValidatablePrompt(rendered) ? yield* resolveSelfValidateCommand(rest.def, rendered.mode, rendered.file, rest.context).pipe(catchAll(() => succeed$3(void 0))) : void 0;
207100
+ write(json ? nextJsonOutput(rendered) : nextPlainOutput(rendered, selfValidateCommand));
206601
207101
  });
206602
207102
  /**
206603
- * `gtd validate [--json]`: format (in place) then validate the steering file
206604
- * the resolved rest declares (`file:` rendered, `mode:` selecting how), over
206605
- * its WORKING-TREE contents. A state with no `file:`/`mode:`, or an absent
206606
- * file, has nothing to validate (exit 0). A clean verdict exits 0; violations
206607
- * FAIL the Effect with the findings (one per line), so the process exits
206608
- * non-zero — the signal a producing agent (or the driver) loops on until the
206609
- * file is valid. A built-in mode reuses the canonical `OpenQuestions`/
206610
- * `ReviewDoc` parsers (the same the LSP publishes), so there is one source of
206611
- * truth per format and no bash port; any `modes:`-declared command runs
206612
- * instead (or, for `format:`, in addition).
207103
+ * `gtd validate [--json]`: emit the script that would format (in place) then
207104
+ * validate the steering file the resolved rest declares (`file:` rendered,
207105
+ * `mode:` selecting how) — the SAME commands `gtd step`'s capture guard now
207106
+ * embeds ahead of its own commit (see `stepAsActor`), rendered here for a
207107
+ * human or agent to run directly. gtd itself reads no file and executes
207108
+ * nothing: a state with no `file:`/`mode:`, or a file absent from the working
207109
+ * tree, has nothing to validate (`script: ""`, exit 0 either way) — the
207110
+ * verdict now lives in the emitted script's own future exit code, not this
207111
+ * command's, so this never fails on a bad file. `RepoFiles`/`FileSystem` is
207112
+ * used only to check the file's PRESENCE, never to read or judge its content.
206613
207113
  */
206614
207114
  const runValidateCommand = (json, write) => gen(function* () {
206615
207115
  const rest = yield* currentRest;
206616
- const { file, present, errors } = yield* checkSteeringFile(rest, rest.context, rest.hints.file);
206617
- if (!present) {
207116
+ const file = rest.hints.file;
207117
+ const mode = rest.stateDef.mode;
207118
+ const emitNothingToValidate = () => {
206618
207119
  if (json) write(JSON.stringify({
206619
207120
  state: rest.state,
206620
- valid: true,
206621
- errors: []
207121
+ ...file !== void 0 ? { file } : {},
207122
+ ...mode !== void 0 ? { mode } : {},
207123
+ script: ""
206622
207124
  }) + "\n");
206623
207125
  else write(`nothing to validate at "${rest.state}"\n`);
207126
+ };
207127
+ if (file === void 0 || mode === void 0) {
207128
+ emitNothingToValidate();
207129
+ return;
207130
+ }
207131
+ const fs = yield* FileSystem;
207132
+ const toError = (e) => e instanceof Error ? e : new Error(String(e));
207133
+ if (!(yield* fs.exists(file).pipe(mapError$1(toError)))) {
207134
+ emitNothingToValidate();
206624
207135
  return;
206625
207136
  }
207137
+ const resolved = resolveSteeringMode(rest.def, mode);
207138
+ if (resolved === void 0) return yield* fail$2(new Error(unknownModeMessage(rest.def, rest.state, mode)));
207139
+ const commands = yield* renderSteeringCommands(resolved, file, rest.context);
207140
+ const script = emitScripts(headPreconditions(rest.context.currentCommit), commands.map((command) => ({
207141
+ kind: "command",
207142
+ command
207143
+ }))).required;
207144
+ if (json) write(JSON.stringify({
207145
+ state: rest.state,
207146
+ file,
207147
+ mode,
207148
+ script
207149
+ }) + "\n");
207150
+ else write(script.length > 0 ? `${script}\n` : `nothing to validate at "${rest.state}"\n`);
207151
+ });
207152
+ /**
207153
+ * `gtd check <mode> <file>`: read `<file>` and run the BUILT-IN steering
207154
+ * format named `<mode>`'s pure parser over its contents, printing each
207155
+ * finding one per line and exiting non-zero when there are any. Unlike `gtd
207156
+ * validate` (which resolves the currently resting state's own `file:`/`mode:`
207157
+ * and formats before validating), this resolves NO workflow state and reads
207158
+ * NO config — both `mode` and `file` are given explicitly, so it needs only
207159
+ * `FileSystem.FileSystem` and runs from any directory. This is what a
207160
+ * workflow's seeded `validate:` command (`SteeringFormats.ts`'s
207161
+ * `seededValidateCommand`) invokes as a leaf step; it does no formatting
207162
+ * in-place — an emitted script has no template context to format against.
207163
+ *
207164
+ * An absent file mirrors `gtd validate`'s own absent-file behavior: exit 0,
207165
+ * no output in plain mode, `{valid: true, errors: []}` under `--json`. A
207166
+ * clean parse is the same shape. A non-clean parse writes `--json`'s
207167
+ * `{valid: false, errors}` body (or each finding, one per line, in plain
207168
+ * mode) BEFORE failing the Effect. In PLAIN mode the failure then carries only
207169
+ * a summary message, landing on stderr alone (`cliErrorLine`'s line) — the
207170
+ * findings are already on stdout. Under `--json`, `Cli.ts`'s single envelope
207171
+ * writer (`report`) ALSO puts its own `{state:"error",prompt}` object on
207172
+ * stdout for any failing Effect, so a `--json` invocation with findings emits
207173
+ * stdout as two newline-delimited JSON documents — this handler's own
207174
+ * `{valid,errors}` body first, then the generic error envelope — rather than
207175
+ * one; a consumer must read line-delimited JSON, not `JSON.parse(stdout)`
207176
+ * whole. Kept as the pragmatic resolution of a real tension in this command's
207177
+ * two governing requirements ("exits non-zero" and "`--json` reports
207178
+ * `{valid,errors}`"): the alternative was piping a `--json` failure through a
207179
+ * NEW exit-code channel bypassing `Cli.ts`'s shared envelope entirely, which
207180
+ * every other command's `--json` failure goes through today.
207181
+ */
207182
+ const runCheckCommand = (mode, file, json, write) => gen(function* () {
207183
+ const format = steeringFormatFor(mode);
207184
+ if (format === void 0) return yield* fail$2(/* @__PURE__ */ new Error(`gtd check: unknown mode "${mode}" — known modes: ${builtInModeNames().join(", ")}`));
207185
+ const fs = yield* FileSystem;
207186
+ const toError = (e) => e instanceof Error ? e : new Error(String(e));
207187
+ const errors = (yield* fs.exists(file).pipe(mapError$1(toError))) ? format.validate(yield* fs.readFileString(file).pipe(mapError$1(toError))) : [];
206626
207188
  if (errors.length === 0) {
206627
207189
  if (json) write(JSON.stringify({
206628
- state: rest.state,
206629
- file,
206630
- mode: rest.stateDef.mode,
206631
207190
  valid: true,
206632
207191
  errors: []
206633
207192
  }) + "\n");
206634
- else write(`${file}: valid\n`);
206635
207193
  return;
206636
207194
  }
206637
- return yield* fail$2(/* @__PURE__ */ new Error(`${file} is not valid:\n${errors.map((e) => ` - ${e}`).join("\n")}`));
207195
+ if (json) write(JSON.stringify({
207196
+ valid: false,
207197
+ errors
207198
+ }) + "\n");
207199
+ else write(errors.join("\n") + "\n");
207200
+ return yield* fail$2(/* @__PURE__ */ new Error(`gtd check: ${file} is not valid under mode "${mode}" (${errors.length} finding(s))`));
206638
207201
  });
206639
207202
  /** Which declared `on` pattern (if any) each pending change matches — the pure computation `gtd status` reports (both plain and `--json`). `onEdges` is ALREADY RENDERED against `it.vars` (`renderOnEdges`) — the reported pattern is the one a real `gtd step` would match against. */
206640
207203
  const computeStatusChanges = (onEdges, changes) => changes.map((change) => {
@@ -206841,7 +207404,8 @@ const assertInitLocation = (git, fs) => gen(function* () {
206841
207404
  const needsOf = (kind) => {
206842
207405
  switch (kind) {
206843
207406
  case "lsp": return "none";
206844
- case "init": return "fs";
207407
+ case "init":
207408
+ case "check": return "fs";
206845
207409
  case "visualize": return "config";
206846
207410
  default: return "state";
206847
207411
  }
@@ -206859,29 +207423,22 @@ const dispatchCommand = (command, json, write) => {
206859
207423
  case "next": return runNextCommand(json, write);
206860
207424
  case "status": return runStatusCommand(json, write);
206861
207425
  case "validate": return runValidateCommand(json, write);
207426
+ case "check": return runCheckCommand(command.mode, command.file, json, write);
206862
207427
  }
206863
207428
  };
206864
207429
  /**
206865
- * Runs `command` inside the bracket every state-touching command shares: the
206866
- * repo-root guard, then close-any-open-review-window before the command sees
206867
- * HEAD, then re-arm it afterward whether `command` succeeded or refused (see
206868
- * `closeReviewWindow`/`openReviewWindow`).
206869
- */
206870
- const runInReviewWindowBracket = (git, fs, command) => gen(function* () {
206871
- yield* assertRunningFromRepoRoot(git, fs);
206872
- yield* closeReviewWindow;
206873
- const outcome = yield* either(command);
206874
- const rearm = yield* either(openReviewWindow);
206875
- if (isLeft(outcome)) return yield* fail$2(outcome.left);
206876
- if (isLeft(rearm)) return yield* fail$2(rearm.left);
206877
- });
206878
- /**
206879
207430
  * The one entry point `Cli.ts`'s `runCli` calls for a resolved `Command`:
206880
207431
  * dispatches to the matching `run*Command` handler (see `dispatchCommand`),
206881
- * wrapped in the repo-root guard + review-window bracket exactly when
206882
- * `needsOf(command.kind) === "state"` — `lsp`/`init`/`visualize` (the
206883
- * `standaloneKinds`) run bare. `Cli.ts` has already validated every field on
206884
- * `command` (arity, flag scope, decoding), so nothing here re-parses argv.
207432
+ * wrapped in the repo-root guard exactly when `needsOf(command.kind) ===
207433
+ * "state"` — `lsp`/`init`/`visualize`/`check` (the `standaloneKinds`) run
207434
+ * bare. `Cli.ts` has already validated every field on `command` (arity, flag
207435
+ * scope, decoding), so nothing here re-parses argv.
207436
+ *
207437
+ * No review-window close/open bracket runs here any more: every state-command
207438
+ * handler (see `stepAsActor`) now decides for itself, off `ReviewWindow.ts`'s
207439
+ * pure `decideCloseWindow`/`decideOpenWindow`, whether a close/open belongs in
207440
+ * the script it emits — there is no in-process HEAD to rewind ahead of time
207441
+ * (gtd itself no longer writes git for a state command at all).
206885
207442
  */
206886
207443
  const runCommand = (command, json, write) => {
206887
207444
  const dispatch = dispatchCommand(command, json, write);
@@ -206889,7 +207446,8 @@ const runCommand = (command, json, write) => {
206889
207446
  return gen(function* () {
206890
207447
  const git = yield* GitService;
206891
207448
  const fs = yield* FileSystem;
206892
- yield* runInReviewWindowBracket(git, fs, dispatch);
207449
+ yield* assertRunningFromRepoRoot(git, fs);
207450
+ yield* dispatch;
206893
207451
  });
206894
207452
  };
206895
207453
  //#endregion
@@ -207089,9 +207647,14 @@ const COMMAND_ROWS = [
207089
207647
  kind: "validate",
207090
207648
  arity: "none",
207091
207649
  details: [
207092
- "Format and validate the steering file the resolved rest",
207093
- "declares, with its mode's commands (its file:/mode:);",
207094
- "exits non-zero with the findings when it is invalid"
207650
+ "Print the script that formats (when declared) then",
207651
+ "validates the resolved rest's steering file, using its",
207652
+ "mode's commands (its file:/mode:), instead of running it —",
207653
+ "a driver runs the script and reads the findings from its",
207654
+ "own exit code/output. Always exits 0; --json emits",
207655
+ "{state, file?, mode?, script} (script is \"\" when there is",
207656
+ "nothing to validate; plain text prints \"nothing to",
207657
+ "validate\" in that case)"
207095
207658
  ]
207096
207659
  },
207097
207660
  {
@@ -207109,6 +207672,20 @@ const COMMAND_ROWS = [
207109
207672
  "local web server (--port <n>, --no-open; --json prints the",
207110
207673
  "model and exits)"
207111
207674
  ]
207675
+ },
207676
+ {
207677
+ token: "check",
207678
+ kind: "check",
207679
+ arity: { names: ["mode", "file"] },
207680
+ details: [
207681
+ "Read <file> and run the built-in steering format named",
207682
+ "<mode> (see `gtd validate`'s modes: qa, review) over its",
207683
+ "contents, printing each finding one per line and exiting",
207684
+ "non-zero when there are any. Resolves no workflow state and",
207685
+ "reads no config — standalone, runnable from any directory",
207686
+ "with <mode>/<file> given explicitly. This is what a",
207687
+ "workflow's emitted validation script invokes as a leaf step"
207688
+ ]
207112
207689
  }
207113
207690
  ];
207114
207691
  const commandByToken = (token) => COMMAND_ROWS.find((r) => r.token === token);
@@ -207147,7 +207724,7 @@ const flagHeader = (row) => {
207147
207724
  };
207148
207725
  const renderHelp = () => {
207149
207726
  const commandBlocks = COMMAND_ROWS.map((row) => {
207150
- const header = row.arity === "none" ? row.token : `${row.token} <${row.arity.name}>`;
207727
+ const header = row.arity === "none" ? row.token : "names" in row.arity ? `${row.token} <${row.arity.names[0]}> <${row.arity.names[1]}>` : `${row.token} <${row.arity.name}>`;
207151
207728
  return renderBlock(header, row.details);
207152
207729
  });
207153
207730
  return `Usage: gtd [command] [options]\n\nCommands:\n${PRELUDE_LOOP + commandBlocks[0] + commandBlocks[1] + ENTRY_SHORT_FORM + commandBlocks.slice(2).join("") + VERSION_BLOCK + HELP_BLOCK}\nOptions:\n${FLAGS.map((row) => renderBlock(flagHeader(row), row.help)).join("")}${renderBlock("--version, -v", ["Print version and exit"]) + renderBlock("--help, -h", ["Print this help and exit"])}`;
@@ -207233,6 +207810,12 @@ const scopeViolation = (kind, present) => {
207233
207810
  };
207234
207811
  const arityError = (cmd, rest, arity) => {
207235
207812
  if (arity === "none") return rest.length > 0 ? `gtd ${cmd}: too many arguments — expected none, got: ${rest.join(", ")}` : void 0;
207813
+ if ("names" in arity) {
207814
+ const [first, second] = arity.names;
207815
+ if (rest.length < 2) return rest.length === 0 ? `gtd ${cmd}: missing ${first} and ${second} arguments` : `gtd ${cmd}: missing ${second} argument`;
207816
+ if (rest.length > 2) return `gtd ${cmd}: too many arguments — expected ${first} and ${second}, got: ${rest.join(", ")}`;
207817
+ return;
207818
+ }
207236
207819
  if (rest.length === 0) return `gtd ${cmd}: missing ${arity.name} argument`;
207237
207820
  if (rest.length > 1) return `gtd ${cmd}: too many arguments — expected one ${arity.name}, got: ${rest.join(", ")}`;
207238
207821
  };
@@ -207339,6 +207922,15 @@ const parseArgv = (argv) => {
207339
207922
  },
207340
207923
  json
207341
207924
  };
207925
+ if (kind === "check") return {
207926
+ kind: "command",
207927
+ command: {
207928
+ kind: "check",
207929
+ mode: restPositionals[0],
207930
+ file: restPositionals[1]
207931
+ },
207932
+ json
207933
+ };
207342
207934
  return {
207343
207935
  kind: "command",
207344
207936
  command: { kind },