@pmelab/gtd 15.2.0 → 15.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/gtd.bundle.mjs +557 -86
  2. package/package.json +2 -1
@@ -8,7 +8,7 @@ import { isIP } from "node:net";
8
8
  import * as Crypto from "node:crypto";
9
9
  import { createHash, randomUUID } from "node:crypto";
10
10
  import * as NFS from "node:fs";
11
- import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
11
+ import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
12
12
  import * as OS from "node:os";
13
13
  import { homedir, tmpdir } from "node:os";
14
14
  import * as Path from "node:path";
@@ -47623,6 +47623,7 @@ const makeGitImpl = (executor, root) => {
47623
47623
  isAncestor: (a, b) => run$1(root, "git", "merge-base", "--is-ancestor", a, b).pipe(provide$2(succeed$2(CommandExecutor, executor)), map$4(() => true), catchAll(() => succeed$3(false))),
47624
47624
  topLevel: () => exec("git", "rev-parse", "--show-toplevel").pipe(map$4((s) => s.trim())),
47625
47625
  gitDir: () => exec("git", "rev-parse", "--absolute-git-dir").pipe(map$4((s) => s.trim())),
47626
+ gitCommonDir: () => exec("git", "rev-parse", "--path-format=absolute", "--git-common-dir").pipe(map$4((s) => s.trim())),
47626
47627
  commitHistory: (base, head = "HEAD") => {
47627
47628
  const range = base !== void 0 ? `${base}..${head}` : head !== "HEAD" ? head : void 0;
47628
47629
  const args = [
@@ -47647,6 +47648,27 @@ const makeGitImpl = (executor, root) => {
47647
47648
  };
47648
47649
  })), catchAll(() => succeed$3([])));
47649
47650
  },
47651
+ subjectHistory: (pageSize, skip, head = "HEAD") => {
47652
+ const ref = head !== "HEAD" ? head : void 0;
47653
+ const args = [
47654
+ "git",
47655
+ "log",
47656
+ "--first-parent",
47657
+ "--reverse",
47658
+ "-z",
47659
+ "--format=%H%x02%s",
47660
+ `--skip=${skip}`,
47661
+ ...Number.isFinite(pageSize) ? [`--max-count=${pageSize}`] : [],
47662
+ ...ref !== void 0 ? [ref] : []
47663
+ ];
47664
+ return exec(...args).pipe(map$4((out) => splitNul(out).map((chunk) => {
47665
+ const sep = chunk.indexOf("");
47666
+ return {
47667
+ hash: (sep === -1 ? chunk : chunk.slice(0, sep)).trim(),
47668
+ subject: (sep === -1 ? "" : chunk.slice(sep + 1)).trim()
47669
+ };
47670
+ })), catchAll(() => succeed$3([])));
47671
+ },
47650
47672
  commitAllWithPrefix: (prefix) => gen(function* () {
47651
47673
  yield* exec("git", "add", "-A");
47652
47674
  yield* exec("git", "commit", "--allow-empty", "-m", prefix).pipe(catchAll((error) => error.message.includes("empty git commit") ? exec("git", "commit", "--allow-empty", "--no-verify", "-m", prefix) : fail$2(error)));
@@ -47676,6 +47698,158 @@ var GitService = class GitService extends Tag("GitService")() {
47676
47698
  const toError = (e) => e instanceof Error ? e : new Error(String(e));
47677
47699
  const MAX_BUFFER = 256 * 1024 * 1024;
47678
47700
  const HASH_CHUNK = 500;
47701
+ /** A full sha1 or sha256 object id — the only refs an immutable, disk-cached read is safe to key on. */
47702
+ const isObjectHash = (ref) => /^[0-9a-f]{40}$|^[0-9a-f]{64}$/.test(ref);
47703
+ const GTD_CACHE_SUBDIR = "gtd-cache";
47704
+ const treeCachePath = (commonDir, hash) => join(commonDir, GTD_CACHE_SUBDIR, "trees", hash);
47705
+ const blobCachePath = (commonDir, id) => join(commonDir, GTD_CACHE_SUBDIR, "blobs", id);
47706
+ const readTextCache = (path) => {
47707
+ try {
47708
+ return readFileSync(path, "utf8");
47709
+ } catch {
47710
+ return;
47711
+ }
47712
+ };
47713
+ /**
47714
+ * Atomic write: a temp name in the SAME directory, `rename`d into place —
47715
+ * rename is atomic on POSIX, so a concurrent reader sees either the old file
47716
+ * or the whole new one, never a half-written one. Any failure (full disk, a
47717
+ * read-only git directory, a raced directory removal) is swallowed: the
47718
+ * cache is a best-effort speedup, never a correctness dependency, and the
47719
+ * alternative — failing a gtd command over a cache write — is worse.
47720
+ */
47721
+ const writeTextCacheAtomic = (path, content) => {
47722
+ try {
47723
+ mkdirSync(dirname(path), { recursive: true });
47724
+ const tmp = `${path}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`;
47725
+ writeFileSync(tmp, content, "utf8");
47726
+ renameSync(tmp, path);
47727
+ } catch {}
47728
+ };
47729
+ /** A commit's `path → blob id` map, serialized as JSON. `undefined` on ANY read failure — missing, truncated, unparseable — so the caller falls back to asking git. */
47730
+ const readTreeCache = (commonDir, hash) => {
47731
+ const raw = readTextCache(treeCachePath(commonDir, hash));
47732
+ if (raw === void 0) return void 0;
47733
+ try {
47734
+ const parsed = JSON.parse(raw);
47735
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return void 0;
47736
+ const map = /* @__PURE__ */ new Map();
47737
+ for (const [path, id] of Object.entries(parsed)) {
47738
+ if (typeof id !== "string") return void 0;
47739
+ map.set(path, id);
47740
+ }
47741
+ return map;
47742
+ } catch {
47743
+ return;
47744
+ }
47745
+ };
47746
+ const writeTreeCache = (commonDir, hash, tree) => writeTextCacheAtomic(treeCachePath(commonDir, hash), JSON.stringify(Object.fromEntries(tree)));
47747
+ /** Parses a `:`-prefixed raw-diff header token into its status letter and new blob id. */
47748
+ const parseRawDiffHeader = (header) => {
47749
+ const fields = header.slice(1).split(" ").filter((f) => f.length > 0);
47750
+ const status = fields[4] ?? "";
47751
+ const newId = fields[3] ?? "";
47752
+ return {
47753
+ letter: status[0] ?? "",
47754
+ newId
47755
+ };
47756
+ };
47757
+ /**
47758
+ * Consumes the 1 (or, for rename/copy, 2) NUL-separated path tokens that
47759
+ * follow a raw-diff header, starting at `tokens[i]`. Returns the resulting
47760
+ * entry (`undefined` if a path token is missing) and the next index.
47761
+ */
47762
+ const consumeRawDiffPaths = (tokens, i, letter, newId) => {
47763
+ if (letter === "R" || letter === "C") {
47764
+ const oldPath = tokens[i];
47765
+ const path = tokens[i + 1];
47766
+ return {
47767
+ entry: oldPath !== void 0 && path !== void 0 ? {
47768
+ status: letter,
47769
+ oldPath,
47770
+ path,
47771
+ newId
47772
+ } : void 0,
47773
+ next: i + 2
47774
+ };
47775
+ }
47776
+ const path = tokens[i];
47777
+ return {
47778
+ entry: path !== void 0 ? {
47779
+ status: letter,
47780
+ path,
47781
+ newId
47782
+ } : void 0,
47783
+ next: i + 1
47784
+ };
47785
+ };
47786
+ /** Parses the NUL-separated body of ONE commit's `--raw -z` diff (the format string's own separator already stripped). */
47787
+ const parseRawDiff = (body) => {
47788
+ const tokens = body.split("\0").filter((t) => t.length > 0);
47789
+ const entries = [];
47790
+ let i = 0;
47791
+ while (i < tokens.length) {
47792
+ const header = tokens[i];
47793
+ i += 1;
47794
+ if (!header.startsWith(":")) continue;
47795
+ const { letter, newId } = parseRawDiffHeader(header);
47796
+ const { entry, next } = consumeRawDiffPaths(tokens, i, letter, newId);
47797
+ i = next;
47798
+ if (entry !== void 0) entries.push(entry);
47799
+ }
47800
+ return entries;
47801
+ };
47802
+ /** Applies one commit's raw-diff entries to a (mutable, working-copy) tree map. */
47803
+ const applyRawDiff = (tree, entries) => {
47804
+ for (const entry of entries) if (entry.status === "D") tree.delete(entry.path);
47805
+ else {
47806
+ if (entry.status === "R") tree.delete(entry.oldPath);
47807
+ tree.set(entry.path, entry.newId);
47808
+ }
47809
+ };
47810
+ /** `git ls-tree -r -z --full-tree <ref>`'s output, parsed into `path → blob id`. */
47811
+ const parseLsTree = (out) => {
47812
+ const entries = /* @__PURE__ */ new Map();
47813
+ for (const record of out.split("\0")) {
47814
+ const tab = record.indexOf(" ");
47815
+ if (tab === -1) continue;
47816
+ const id = record.slice(0, tab).split(" ")[2];
47817
+ if (id !== void 0) entries.set(record.slice(tab + 1), id);
47818
+ }
47819
+ return entries;
47820
+ };
47821
+ /**
47822
+ * Runs `git ls-tree` for `ref` and reports whether it actually succeeded —
47823
+ * `ok: false` on ANY subprocess failure (a transient `EAGAIN`/`ENOMEM` on
47824
+ * fork, a `git gc` race, a signal), never folded into an empty tree here.
47825
+ * The disk/memo cache must only ever hold a git-CONFIRMED tree: an immutable
47826
+ * key can serve a wrong answer forever if what's stored under it came from a
47827
+ * failed read rather than from the key itself, so callers that cache must
47828
+ * check `ok` before doing so.
47829
+ */
47830
+ const fetchTree = (root, ref) => {
47831
+ try {
47832
+ return {
47833
+ ok: true,
47834
+ entries: parseLsTree(execFileSync("git", [
47835
+ "ls-tree",
47836
+ "-r",
47837
+ "-z",
47838
+ "--full-tree",
47839
+ ref
47840
+ ], {
47841
+ cwd: root,
47842
+ encoding: "utf8",
47843
+ maxBuffer: MAX_BUFFER
47844
+ }))
47845
+ };
47846
+ } catch {
47847
+ return {
47848
+ ok: false,
47849
+ entries: /* @__PURE__ */ new Map()
47850
+ };
47851
+ }
47852
+ };
47679
47853
  const assertRepoRelative = (path) => {
47680
47854
  if (isAbsolute(path)) throw new Error(`Workspace: "${path}" is an absolute path — this member takes repo-relative paths only (use "atPath"/"writeAtPath" to read/write outside the repo)`);
47681
47855
  return path;
@@ -47696,13 +47870,96 @@ const lstatOrAbsent = (path) => {
47696
47870
  throw e;
47697
47871
  }
47698
47872
  };
47699
- const makeWorkspaceOps = (root, git) => {
47873
+ /**
47874
+ * Runs `git log --raw` for `base..head` (or all of `head`'s first-parent
47875
+ * history when `base` is `undefined`) and splits the output into one
47876
+ * `RawLogEntry` per commit, validating the result against `commits` — same
47877
+ * count, same hashes, in order — since the two disagreeing means the episode
47878
+ * bounds no longer match git's own history.
47879
+ */
47880
+ const runRawLogReplay = (root, base, head, commits) => {
47881
+ const chunks = execFileSync("git", [
47882
+ "log",
47883
+ "--first-parent",
47884
+ "--reverse",
47885
+ "-z",
47886
+ "--raw",
47887
+ "-r",
47888
+ "--no-abbrev",
47889
+ "--format=%x01%H",
47890
+ base !== void 0 ? `${base}..${head}` : head
47891
+ ], {
47892
+ cwd: root,
47893
+ encoding: "utf8",
47894
+ maxBuffer: MAX_BUFFER
47895
+ }).split("").filter((c) => c.length > 0);
47896
+ if (chunks.length !== commits.length) throw new Error(`gtd: episode reconstruction expected ${commits.length} commit(s) from git log, got ${chunks.length} — the episode bounds disagree with git's own history`);
47897
+ return chunks.map((chunk, i) => {
47898
+ const nul = chunk.indexOf("\0");
47899
+ const hash = nul === -1 ? chunk : chunk.slice(0, nul);
47900
+ if (hash !== commits[i]) throw new Error(`gtd: episode reconstruction hash mismatch at position ${i}: expected ${commits[i]}, got ${hash}`);
47901
+ const tail = nul === -1 ? "" : chunk.slice(nul + 1);
47902
+ return {
47903
+ hash,
47904
+ tail: tail.startsWith("\n") ? tail.slice(1) : tail
47905
+ };
47906
+ });
47907
+ };
47908
+ /** Applies each `RawLogEntry`'s diff in order onto `baseTree`, caching (and returning) every resulting tree. */
47909
+ const foldRawDiffs = (baseTree, replay, rememberTree) => {
47910
+ const result = /* @__PURE__ */ new Map();
47911
+ let current = new Map(baseTree);
47912
+ for (const { hash, tail } of replay) {
47913
+ const next = new Map(current);
47914
+ applyRawDiff(next, parseRawDiff(tail));
47915
+ current = next;
47916
+ result.set(hash, current);
47917
+ rememberTree(hash, current);
47918
+ }
47919
+ return result;
47920
+ };
47921
+ const makeWorkspaceOps = (root, git, commonDir) => {
47700
47922
  const resolveAny = (path) => isAbsolute(path) ? path : join(root, path);
47923
+ const treeMemo = /* @__PURE__ */ new Map();
47924
+ const blobMemo = /* @__PURE__ */ new Map();
47925
+ /** A commit's tree, from the in-process memo or the on-disk cache — no subprocess. `undefined` on a cold miss. */
47926
+ const peekTree = (hash) => {
47927
+ const memoHit = treeMemo.get(hash);
47928
+ if (memoHit !== void 0) return memoHit;
47929
+ if (commonDir === void 0) return void 0;
47930
+ const cached = readTreeCache(commonDir, hash);
47931
+ if (cached !== void 0) treeMemo.set(hash, cached);
47932
+ return cached;
47933
+ };
47934
+ const rememberTree = (hash, tree) => {
47935
+ treeMemo.set(hash, tree);
47936
+ if (commonDir !== void 0) writeTreeCache(commonDir, hash, tree);
47937
+ };
47701
47938
  const readSync = (path) => readFileOrAbsent(join(root, assertRepoRelative(path)));
47702
- const readCommittedSync = (path, ref = "HEAD") => {
47703
- const relPath = assertRepoRelative(path);
47939
+ /**
47940
+ * A blob's content, keyed on its OWN id rather than a (path, ref) pair —
47941
+ * the same content read from two different commits costs one read, not
47942
+ * two. `git cat-file -p` reads any object by id directly, no pathspec
47943
+ * needed. Any failure folds to `undefined`, matching `readCommittedSync`'s
47944
+ * own catch-all-as-absence.
47945
+ */
47946
+ const readBlobSync = (id) => {
47947
+ const memoHit = blobMemo.get(id);
47948
+ if (memoHit !== void 0) return memoHit;
47949
+ if (commonDir !== void 0) {
47950
+ const cached = readTextCache(blobCachePath(commonDir, id));
47951
+ if (cached !== void 0) {
47952
+ blobMemo.set(id, cached);
47953
+ return cached;
47954
+ }
47955
+ }
47956
+ let content;
47704
47957
  try {
47705
- return execFileSync("git", ["show", `${ref}:${relPath}`], {
47958
+ content = execFileSync("git", [
47959
+ "cat-file",
47960
+ "-p",
47961
+ id
47962
+ ], {
47706
47963
  cwd: root,
47707
47964
  encoding: "utf8",
47708
47965
  maxBuffer: MAX_BUFFER
@@ -47710,33 +47967,72 @@ const makeWorkspaceOps = (root, git) => {
47710
47967
  } catch {
47711
47968
  return;
47712
47969
  }
47970
+ blobMemo.set(id, content);
47971
+ if (commonDir !== void 0) writeTextCacheAtomic(blobCachePath(commonDir, id), content);
47972
+ return content;
47713
47973
  };
47714
- const treeSync = (ref) => {
47715
- const entries = /* @__PURE__ */ new Map();
47716
- let out;
47974
+ const readCommittedSync = (path, ref = "HEAD") => {
47975
+ const relPath = assertRepoRelative(path);
47976
+ if (isObjectHash(ref)) {
47977
+ const id = treeSync(ref).get(relPath);
47978
+ return id === void 0 ? void 0 : readBlobSync(id);
47979
+ }
47717
47980
  try {
47718
- out = execFileSync("git", [
47719
- "ls-tree",
47720
- "-r",
47721
- "-z",
47722
- "--full-tree",
47723
- ref
47724
- ], {
47981
+ return execFileSync("git", ["show", `${ref}:${relPath}`], {
47725
47982
  cwd: root,
47726
47983
  encoding: "utf8",
47727
47984
  maxBuffer: MAX_BUFFER
47728
47985
  });
47729
47986
  } catch {
47730
- return entries;
47987
+ return;
47731
47988
  }
47732
- for (const record of out.split("\0")) {
47733
- const tab = record.indexOf(" ");
47734
- if (tab === -1) continue;
47735
- const id = record.slice(0, tab).split(" ")[2];
47736
- if (id !== void 0) entries.set(record.slice(tab + 1), id);
47989
+ };
47990
+ const readTree = (ref, onFailure) => {
47991
+ if (isObjectHash(ref)) {
47992
+ const cached = peekTree(ref);
47993
+ if (cached !== void 0) return cached;
47994
+ }
47995
+ const { ok, entries } = fetchTree(root, ref);
47996
+ if (!ok) {
47997
+ if (onFailure === "throw") throw new Error(`gtd: failed to read the tree at "${ref}" — git ls-tree did not succeed`);
47998
+ return entries;
47737
47999
  }
48000
+ if (isObjectHash(ref)) rememberTree(ref, entries);
47738
48001
  return entries;
47739
48002
  };
48003
+ const treeSync = (ref) => readTree(ref, "empty");
48004
+ /**
48005
+ * `treeSync`'s starting point when a failed read must not silently
48006
+ * proceed: a cache hit or a git-confirmed `ls-tree` succeeds; any
48007
+ * subprocess failure THROWS rather than folding to an empty tree, because
48008
+ * `episodeTrees` folds every subsequent commit's diff on top of this one —
48009
+ * a wrong empty base would poison every commit's cached tree in the
48010
+ * episode, not just this one's.
48011
+ */
48012
+ const confirmedTree = (ref) => readTree(ref, "throw");
48013
+ /**
48014
+ * The delta-stream reconstruction (package 4's Requirement A): one
48015
+ * `ls-tree` for `base`, one `--raw` `git log` for every commit, applied in
48016
+ * order. Skipped entirely when every requested hash is already known
48017
+ * (memo or on-disk cache) — then this issues NO subprocess at all.
48018
+ */
48019
+ const episodeTrees = (base, commits) => try_({
48020
+ try: () => {
48021
+ const result = /* @__PURE__ */ new Map();
48022
+ const baseTree = base !== void 0 ? confirmedTree(base) : /* @__PURE__ */ new Map();
48023
+ if (base !== void 0) result.set(base, baseTree);
48024
+ if (commits.length === 0) return result;
48025
+ if (commits.every((hash) => peekTree(hash) !== void 0)) {
48026
+ for (const hash of commits) result.set(hash, treeSync(hash));
48027
+ return result;
48028
+ }
48029
+ const head = commits[commits.length - 1];
48030
+ const replay = runRawLogReplay(root, base, head, commits);
48031
+ for (const [hash, tree] of foldRawDiffs(baseTree, replay, rememberTree)) result.set(hash, tree);
48032
+ return result;
48033
+ },
48034
+ catch: toError
48035
+ });
47740
48036
  const gitSync = (args, input) => execFileSync("git", args, {
47741
48037
  cwd: root,
47742
48038
  encoding: "utf8",
@@ -47795,6 +48091,7 @@ const makeWorkspaceOps = (root, git) => {
47795
48091
  return {
47796
48092
  treeSync,
47797
48093
  worktreeSync,
48094
+ episodeTrees,
47798
48095
  readSync,
47799
48096
  read: (path) => try_({
47800
48097
  try: () => readSync(path),
@@ -47820,7 +48117,8 @@ var Workspace = class Workspace extends Tag("Workspace")() {
47820
48117
  static Live = effect$1(Workspace, gen(function* () {
47821
48118
  const { root } = yield* Host;
47822
48119
  const git = yield* GitService;
47823
- return makeWorkspaceOps(root, git);
48120
+ const commonDir = yield* git.gitCommonDir().pipe(catchAll(() => succeed$3(void 0)));
48121
+ return makeWorkspaceOps(root, git, commonDir);
47824
48122
  }));
47825
48123
  };
47826
48124
  //#endregion
@@ -200431,7 +200729,9 @@ const load = gen(function* () {
200431
200729
  workflowVars: { ...loaded.defaults },
200432
200730
  rcVars: compiled.rcVars,
200433
200731
  ...compiled.ui !== void 0 ? { ui: compiled.ui } : {},
200434
- warnings: diagnostics.filter((d) => d.severity === "warning")
200732
+ warnings: diagnostics.filter((d) => d.severity === "warning"),
200733
+ configFiles: levels.map((level) => level.filepath),
200734
+ workflowFiles: loaded.dependencyFiles
200435
200735
  };
200436
200736
  });
200437
200737
  const TRANSFORM_MEMO_SIZE = 512;
@@ -200480,7 +200780,7 @@ const optionalFunction = (exports, name) => {
200480
200780
  * Read a workflow module: the default export is the flow; `defaults`,
200481
200781
  * `summary`, `base` and `steering` are optional; any other export is ignored.
200482
200782
  */
200483
- const fromModule = (exported, origin) => {
200783
+ const fromModule = (exported, origin, dependencyFiles) => {
200484
200784
  const exports = typeof exported === "object" && exported !== null ? exported : {};
200485
200785
  const flow = exports.default;
200486
200786
  if (typeof flow !== "function") throw new Error("the default export is not a flow — export default an async function");
@@ -200490,11 +200790,28 @@ const fromModule = (exported, origin) => {
200490
200790
  steering: stringRecord(exports, "steering"),
200491
200791
  summary: optionalFunction(exports, "summary"),
200492
200792
  base: optionalFunction(exports, "base"),
200493
- origin
200793
+ origin,
200794
+ dependencyFiles
200494
200795
  };
200495
200796
  };
200496
- /** Evaluate `gtd.config.ts`, or take the bundled default. */
200497
- const loadWorkflow = (module) => module === void 0 ? fromModule(unified_exports, BUILT_IN_ORIGIN) : fromModule(jiti().evalModule(module.source, { filename: module.filepath }), module.filepath);
200797
+ /**
200798
+ * Evaluate `gtd.config.ts`, or take the bundled default. `cache` is jiti's
200799
+ * OWN per-call module cache (unrelated to the instance's `moduleCache:
200800
+ * false` — that governs reuse ACROSS calls, this one is populated fresh
200801
+ * DURING this one) — passing an empty object in has the side effect of
200802
+ * collecting every real file jiti resolved evaluating this module, module
200803
+ * AND every relative import it pulled in, transitively. Virtual modules
200804
+ * (`@pmelab/gtd/flows`) never touch it — no real file backs them.
200805
+ */
200806
+ const loadWorkflow = (module) => {
200807
+ if (module === void 0) return fromModule(unified_exports, BUILT_IN_ORIGIN, []);
200808
+ const cache = {};
200809
+ const exported = jiti().evalModule(module.source, {
200810
+ filename: module.filepath,
200811
+ cache
200812
+ });
200813
+ return fromModule(exported, module.filepath, Object.keys(cache));
200814
+ };
200498
200815
  const headTree = (workspace) => {
200499
200816
  let entries;
200500
200817
  const list = () => entries ??= workspace.treeSync("HEAD");
@@ -206061,8 +206378,86 @@ const runOf = (history, location, closingHash = void 0) => {
206061
206378
  }
206062
206379
  };
206063
206380
  };
206064
- const historyUpTo = (git, head) => git.commitHistory(void 0, head);
206065
- const computeProcessRun = (git, def, head) => map$4(historyUpTo(git, head), (history) => runOf(history, locateEpisode(def, history)));
206381
+ const PAGE_TIERS = [
206382
+ 32,
206383
+ 128,
206384
+ 512,
206385
+ Infinity
206386
+ ];
206387
+ /**
206388
+ * `locateEpisode`'s own boundary test, subject-only: a commit that isn't a
206389
+ * gtd step commit, or one landing the flow's initial state, bounds an
206390
+ * episode from below — EXCEPT a bare `gtd(human): <x>` subject (no `→`),
206391
+ * which is NEVER a boundary here, whatever `<x>` is. That bare shape is
206392
+ * `locateEpisode`'s own `opening` branch (an entered episode's first commit),
206393
+ * which takes priority there over the initial-state check no matter what the
206394
+ * entry is named — including an entry named after `def.initial` itself,
206395
+ * where the two branches would otherwise disagree. A bare human commit can
206396
+ * also be a self-loop step landing rather than a real opening (this
206397
+ * subject-only pass can't read the `Gtd-Step` trailer that would tell them
206398
+ * apart), so ruling it out here can only make `findBoundaryBase` walk a FEW
206399
+ * commits further back than strictly needed — never stop short of the
206400
+ * boundary `locateEpisode` would find once it has full commit bodies in
206401
+ * hand. That asymmetry is the only direction this approximation may err in;
206402
+ * it must never call a real boundary "not yet found".
206403
+ */
206404
+ const subjectIsBoundary = (def, subject) => {
206405
+ const parsed = parseCommitMessage(subject).parsed;
206406
+ if (parsed === void 0 || !ACTORS.has(parsed.actor)) return true;
206407
+ if (parsed.actor === "human" && parsed.from === void 0) return false;
206408
+ return parsed.to === def.initial;
206409
+ };
206410
+ /** The last page index (walking newest→oldest, same direction as `locateEpisode`) whose subject is a boundary, or `undefined` if none in `page`. */
206411
+ const boundaryIndexIn = (def, page) => {
206412
+ for (let i = page.length - 1; i >= 0; i--) if (subjectIsBoundary(def, page[i].subject)) return i;
206413
+ };
206414
+ /**
206415
+ * `findBoundaryBase`'s result: `base` is the hash to pass `commitHistory` so
206416
+ * a `git.commitHistory(base, head)` call reads exactly the episode ending at
206417
+ * `head`, plus whatever a subject-only read couldn't rule out — never the
206418
+ * whole repository. `base` is deliberately the boundary commit's PARENT, not
206419
+ * the boundary commit itself: `commitHistory`'s range excludes `base`, and
206420
+ * `runOf` needs the boundary commit present in the read history
206421
+ * (`episode.base`, `startParentHash`). `base: undefined` means "read from the
206422
+ * repository root" — an empty repository, an unresolvable `head`, or an
206423
+ * episode that reaches the root commit, none of which cost anything extra
206424
+ * since that IS the whole history in those cases.
206425
+ *
206426
+ * `head` is the CONCRETE hash the literal `head` argument resolved to on this
206427
+ * call's first read — `undefined` only when it never resolved (an empty
206428
+ * repository, or an unresolvable ref). The caller must hand this hash, not
206429
+ * the original `head` argument, to its own `commitHistory` call: resolving a
206430
+ * symbolic ref (literal `HEAD`, most callers' default) a second time risks
206431
+ * reading a DIFFERENT commit than this function paged through, if something
206432
+ * moves HEAD in between — pinning both reads to one resolved hash is what
206433
+ * keeps the two-read split from disagreeing with itself.
206434
+ */
206435
+ const findBoundaryBase = (git, def, head) => gen(function* () {
206436
+ let pinnedHead = head;
206437
+ for (const pageSize of PAGE_TIERS) {
206438
+ const page = yield* git.subjectHistory(pageSize, 0, pinnedHead);
206439
+ if (pinnedHead === void 0) pinnedHead = page[page.length - 1]?.hash;
206440
+ const wholeHistory = !Number.isFinite(pageSize) || page.length < pageSize;
206441
+ const boundaryIdx = boundaryIndexIn(def, page);
206442
+ if (boundaryIdx === void 0 || boundaryIdx === 0) {
206443
+ if (wholeHistory) return {
206444
+ base: void 0,
206445
+ head: pinnedHead
206446
+ };
206447
+ continue;
206448
+ }
206449
+ return {
206450
+ base: page[boundaryIdx - 1].hash,
206451
+ head: pinnedHead
206452
+ };
206453
+ }
206454
+ return {
206455
+ base: void 0,
206456
+ head: pinnedHead
206457
+ };
206458
+ });
206459
+ const historyUpTo = (git, def, head) => flatMap$4(findBoundaryBase(git, def, head), ({ base, head: resolvedHead }) => git.commitHistory(base, resolvedHead));
206460
+ const computeProcessRun = (git, def, head) => map$4(historyUpTo(git, def, head), (history) => runOf(history, locateEpisode(def, history)));
206066
206461
  /** The run alone, never replaying — `gtd abandon` must work even when replay would refuse. */
206067
206462
  const currentRun = gen(function* () {
206068
206463
  const git = yield* GitService;
@@ -206073,19 +206468,29 @@ const currentRun = gen(function* () {
206073
206468
  const summaryRun = gen(function* () {
206074
206469
  const git = yield* GitService;
206075
206470
  const def = (yield* (yield* ConfigService).load).workflow;
206076
- const history = yield* historyUpTo(git, void 0);
206077
- const head = history[history.length - 1];
206078
- if (!(head !== void 0 && parseCommitMessage(head.message).parsed?.to === def.initial)) return runOf(history, locateEpisode(def, history));
206079
- const before = history.slice(0, -1);
206471
+ const headEntry = (yield* git.subjectHistory(1, 0))[0];
206472
+ if (headEntry === void 0) {
206473
+ const history = yield* historyUpTo(git, def, void 0);
206474
+ return runOf(history, locateEpisode(def, history));
206475
+ }
206476
+ if (!(parseCommitMessage(headEntry.subject).parsed?.to === def.initial)) {
206477
+ const history = yield* historyUpTo(git, def, headEntry.hash);
206478
+ return runOf(history, locateEpisode(def, history));
206479
+ }
206480
+ const parentRef = `${headEntry.hash}~1`;
206481
+ const before = yield* historyUpTo(git, def, parentRef);
206080
206482
  const location = locateEpisode(def, before);
206081
- return runOf(history, { ...location }, head.hash);
206483
+ let closing = yield* git.commitHistory(parentRef, headEntry.hash);
206484
+ if (closing.length === 0) closing = yield* git.commitHistory(void 0, headEntry.hash);
206485
+ return runOf([...before, ...closing], { ...location }, headEntry.hash);
206082
206486
  });
206083
206487
  const commitTree = (workspace, hash) => {
206084
206488
  let entries;
206085
206489
  const list = () => entries ??= workspace.treeSync(hash);
206490
+ let sortedPaths;
206086
206491
  const contents = /* @__PURE__ */ new Map();
206087
206492
  return {
206088
- paths: () => [...list().keys()].sort(),
206493
+ paths: () => sortedPaths ??= [...list().keys()].sort(),
206089
206494
  read: (path) => {
206090
206495
  if (!list().has(path)) return void 0;
206091
206496
  if (!contents.has(path)) contents.set(path, workspace.readCommittedSync(path, hash));
@@ -206094,9 +206499,13 @@ const commitTree = (workspace, hash) => {
206094
206499
  id: (path) => list().get(path)
206095
206500
  };
206096
206501
  };
206097
- /** The tree a landing would commit: the working tree, with any rewrite the landing script applies first. */
206098
- const pendingTree = (workspace, rewrite) => {
206099
- const entries = workspace.worktreeSync();
206502
+ /**
206503
+ * The tree a landing would commit: the working tree, with any rewrite the
206504
+ * landing script applies first. `entries` is `setup.pendingWorktree()` —
206505
+ * already memoized — never a fresh `workspace.worktreeSync()` call; see that
206506
+ * field's own comment for why the memo lives there and not on `Workspace`.
206507
+ */
206508
+ const pendingTree = (workspace, entries, rewrite) => {
206100
206509
  const paths = [...entries.keys()];
206101
206510
  const readWorktree = (path) => {
206102
206511
  try {
@@ -206128,32 +206537,18 @@ const judgeBudgetBytes = (vars) => {
206128
206537
  if (raw.trim() === "" || !Number.isInteger(n) || n <= 0) throw new Error(`"judgeBudgetBytes" must be a positive integer — got ${JSON.stringify(raw)} (blanking this var disables the payload bound rather than the mechanism it guards, so it is refused rather than defaulted)`);
206129
206538
  return n;
206130
206539
  };
206131
- const episodeCommits = (setup) => setup.run.episode.commits.map((c) => ({
206132
- hash: c.hash,
206133
- message: c.message,
206134
- tree: commitTree(setup.workspace, c.hash)
206135
- }));
206136
- const replayFor = (setup, pending) => {
206137
- const base = setup.run.episode.base;
206138
- return replay({
206139
- flow: setup.def.flow,
206140
- episode: {
206141
- entry: setup.run.entry,
206142
- base: base === void 0 ? {
206143
- hash: EMPTY_TREE$1,
206144
- tree: treeFromRecord({})
206145
- } : {
206146
- hash: base,
206147
- tree: commitTree(setup.workspace, base)
206148
- },
206149
- commits: episodeCommits(setup)
206150
- },
206151
- vars: setup.vars,
206152
- start: setup.run.diffBase,
206153
- budgetBytes: setup.budget,
206154
- ...pending !== void 0 ? { pending } : {}
206155
- });
206156
- };
206540
+ const replayFor = (setup, pending) => replay({
206541
+ flow: setup.def.flow,
206542
+ episode: {
206543
+ entry: setup.run.entry,
206544
+ base: setup.base,
206545
+ commits: setup.episode
206546
+ },
206547
+ vars: setup.vars,
206548
+ start: setup.run.diffBase,
206549
+ budgetBytes: setup.budget,
206550
+ ...pending !== void 0 ? { pending } : {}
206551
+ });
206157
206552
  const replayError = (outcome) => {
206158
206553
  if (outcome.kind === "divergence" || outcome.kind === "failed") return new Error(outcome.message);
206159
206554
  if (outcome.kind === "refused") return /* @__PURE__ */ new Error(`gtd: ${outcome.message}`);
@@ -206288,15 +206683,33 @@ const restAt = (ref) => gen(function* () {
206288
206683
  const def = config.workflow;
206289
206684
  const run = yield* computeProcessRun(git, def, ref);
206290
206685
  const vars = resolveVars(config.workflowVars, config.rcVars, run.entryVars, host.env);
206686
+ const budget = yield* try_({
206687
+ try: () => judgeBudgetBytes(vars),
206688
+ catch: (e) => e instanceof Error ? e : new Error(String(e))
206689
+ });
206690
+ yield* workspace.episodeTrees(run.episode.base, run.episode.commits.map((c) => c.hash));
206691
+ const base = run.episode.base === void 0 ? {
206692
+ hash: EMPTY_TREE$1,
206693
+ tree: treeFromRecord({})
206694
+ } : {
206695
+ hash: run.episode.base,
206696
+ tree: commitTree(workspace, run.episode.base)
206697
+ };
206698
+ const episode = run.episode.commits.map((c) => ({
206699
+ hash: c.hash,
206700
+ message: c.message,
206701
+ tree: commitTree(workspace, c.hash)
206702
+ }));
206703
+ let pendingMemo;
206291
206704
  const setup = {
206292
206705
  def,
206293
206706
  run,
206294
206707
  vars,
206295
- budget: yield* try_({
206296
- try: () => judgeBudgetBytes(vars),
206297
- catch: (e) => e instanceof Error ? e : new Error(String(e))
206298
- }),
206299
- workspace
206708
+ budget,
206709
+ workspace,
206710
+ base,
206711
+ episode,
206712
+ pendingWorktree: () => pendingMemo ??= workspace.worktreeSync()
206300
206713
  };
206301
206714
  const outcome = yield* promise(() => replayFor(setup));
206302
206715
  const error = replayError(outcome);
@@ -206347,7 +206760,7 @@ const entryRefusal = (rest, name, entryVars) => gen(function* () {
206347
206760
  entry: name,
206348
206761
  base: {
206349
206762
  hash: "",
206350
- tree: pendingTree(workspace, void 0)
206763
+ tree: pendingTree(workspace, rest.setup.pendingWorktree(), void 0)
206351
206764
  },
206352
206765
  commits: []
206353
206766
  },
@@ -206411,7 +206824,7 @@ const decideLanding = (rest, verdicts) => gen(function* () {
206411
206824
  const early = cleanLanding(rest);
206412
206825
  if (early !== void 0) return early;
206413
206826
  const outcome = yield* promise(() => replayFor(rest.setup, {
206414
- tree: pendingTree(rest.setup.workspace, reviewGateRewrite(rest.stepDef)),
206827
+ tree: pendingTree(rest.setup.workspace, rest.setup.pendingWorktree(), reviewGateRewrite(rest.stepDef)),
206415
206828
  ...verdicts !== void 0 ? { verdicts } : {}
206416
206829
  }));
206417
206830
  if (outcome.kind === "refused") return {
@@ -218205,9 +218618,13 @@ const workspaceLayerForRoot = (root) => Workspace.Live.pipe(provide$1(merge(host
218205
218618
  const layersForRoot = (root) => mergeAll$1(gitLayerForRoot(root), ConfigService.Live, ConfigDiscovery.Live, workspaceLayerForRoot(root), hostLayerForRoot(root), Narrator.layer(() => {}, false));
218206
218619
  /**
218207
218620
  * One `ManagedRuntime` per root, memoised — caches the runtime (the
218208
- * constructed service layer), not the config: each `LspEnv` method still
218209
- * calls `ConfigService.load` itself, so an edited `.gtdrc` takes effect on
218210
- * the next request; only the expensive layer construction is avoided.
218621
+ * constructed service layer), not the config: `currentSteeringFile` and a
218622
+ * `steeringMapFor` cache miss still call `ConfigService.load` fresh through
218623
+ * it. A `steeringMapFor` hit skips that load entirely, but its own memo (see
218624
+ * `makeNodeLspEnv`) is keyed on the `(path, size, mtimeMs)` of every file the
218625
+ * LAST such load reported touching, so an edit to `.gtdrc`, `gtd.config.ts`,
218626
+ * or a module `gtd.config.ts` imports still forces the next request to
218627
+ * reload — only an unrelated repeat request skips it.
218211
218628
  */
218212
218629
  const runtimeCache = /* @__PURE__ */ new Map();
218213
218630
  const runtimeFor = (root) => {
@@ -218237,21 +218654,75 @@ const reachedSteeringSteps = gen(function* () {
218237
218654
  }));
218238
218655
  return {
218239
218656
  def: config.workflow,
218240
- steps: [...reached, ...declared]
218657
+ steps: [...reached, ...declared],
218658
+ dependencyFiles: [...config.configFiles, ...config.workflowFiles]
218241
218659
  };
218242
218660
  });
218661
+ /** A cheap `(size, mtimeMs)` fingerprint of `path` — a stat, never a read. `"missing"` when it doesn't exist, so a file's creation OR deletion still changes the identity. */
218662
+ const fingerprintOf = (path) => {
218663
+ try {
218664
+ const stat = statSync(path);
218665
+ return `${stat.size}:${stat.mtimeMs}`;
218666
+ } catch {
218667
+ return "missing";
218668
+ }
218669
+ };
218670
+ /** `paths`' identity, sorted so the same set always reads the same regardless of build order. `known`'s fingerprint wins over a fresh `fingerprintOf` stat where it has one — see `steeringMapFor`'s miss path for why that matters. */
218671
+ const identityOf = (paths, known) => paths.map((path) => `${path}:${known?.get(path) ?? fingerprintOf(path)}`).sort().join("|");
218672
+ /**
218673
+ * Every path a `.gtdrc`/`gtd.config.ts` COULD live at for `root` — every
218674
+ * `SEARCH_PLACES` name plus `gtd.config.ts` itself, in every directory
218675
+ * `walkUp(root, home)` visits — computed from path strings alone, no
218676
+ * filesystem access. Unlike `ConfigOperations.configFiles`/`workflowFiles`
218677
+ * (which name only what a load actually FOUND), this list is complete
218678
+ * whether or not any of it exists yet, so `identityOf` on it catches a
218679
+ * brand-new `.gtdrc` appearing where there was none, not just an edit to one
218680
+ * that already existed.
218681
+ */
218682
+ const candidateConfigPaths = (root) => walkUp(root, liveHost.home).flatMap((dir) => [...SEARCH_PLACES.map((name) => resolve(dir, name)), resolve(dir, WORKFLOW_MODULE)]);
218243
218683
  /** The Node adapter: the only place `LspEnv`'s Effects/layers get built and run. `startLspServer` is its production caller; most `Lsp.test.ts` coverage exercises a fake `LspEnv` instead, but this is exported so the real wiring (real git/config/repo-files layers) gets exercised against a real temp repo too. */
218244
- const makeNodeLspEnv = (warn) => ({
218245
- cwd: liveHost.root,
218246
- steeringMapFor: async (root) => {
218247
- const { def, steps } = await runtimeFor(root).runPromise(reachedSteeringSteps);
218248
- const { map, warnings } = buildSteeringMap(def, steps, root);
218249
- for (const warning of warnings) warn(warning);
218250
- return map;
218251
- },
218252
- gitTopLevel: (dir) => runtimeFor(dir).runPromise(flatMap$4(GitService, (git) => git.topLevel())),
218253
- currentSteeringFile: (root) => runtimeFor(root).runPromise(resolveSteeringFile)
218254
- });
218684
+ const makeNodeLspEnv = (warn) => {
218685
+ const stepsMemo = /* @__PURE__ */ new Map();
218686
+ const headHashFor = (root) => runtimeFor(root).runPromise(flatMap$4(GitService, (git) => git.resolveRef("HEAD"))).catch(() => "no-head");
218687
+ /** `result.dependencyFiles` beyond `candidateConfigPaths(root)` — a genuine import elsewhere (e.g. a split workflow's sibling module), never `gtd.config.ts`/`.gtdrc` themselves, which the static set already names. */
218688
+ const dynamicOnlyPaths = (root, result) => {
218689
+ const staticSet = new Set(candidateConfigPaths(root));
218690
+ return result.dependencyFiles.filter((path) => !staticSet.has(path));
218691
+ };
218692
+ const queues = /* @__PURE__ */ new Map();
218693
+ const serialized = (root, task) => {
218694
+ const settled = (queues.get(root) ?? Promise.resolve()).then(task, task);
218695
+ queues.set(root, settled.then(() => void 0, () => void 0));
218696
+ return settled;
218697
+ };
218698
+ return {
218699
+ cwd: liveHost.root,
218700
+ steeringMapFor: (root) => serialized(root, async () => {
218701
+ const headHash = await headHashFor(root);
218702
+ const cached = stepsMemo.get(root);
218703
+ if (cached !== void 0 && cached.headHash === headHash && identityOf([...candidateConfigPaths(root), ...dynamicOnlyPaths(root, cached.result)]) === cached.configIdentity) {
218704
+ const { map, warnings } = buildSteeringMap(cached.result.def, cached.result.steps, root);
218705
+ for (const warning of warnings) warn(warning);
218706
+ return map;
218707
+ }
218708
+ const staticPaths = candidateConfigPaths(root);
218709
+ const knownDynamic = cached !== void 0 ? dynamicOnlyPaths(root, cached.result) : [];
218710
+ const before = new Map([...staticPaths, ...knownDynamic].map((path) => [path, fingerprintOf(path)]));
218711
+ const result = await runtimeFor(root).runPromise(reachedSteeringSteps);
218712
+ const configIdentity = identityOf([...staticPaths, ...dynamicOnlyPaths(root, result)], before);
218713
+ stepsMemo.set(root, {
218714
+ headHash,
218715
+ configIdentity,
218716
+ result
218717
+ });
218718
+ const { map, warnings } = buildSteeringMap(result.def, result.steps, root);
218719
+ for (const warning of warnings) warn(warning);
218720
+ return map;
218721
+ }),
218722
+ gitTopLevel: (dir) => runtimeFor(dir).runPromise(flatMap$4(GitService, (git) => git.topLevel())),
218723
+ currentSteeringFile: (root) => runtimeFor(root).runPromise(resolveSteeringFile)
218724
+ };
218725
+ };
218255
218726
  /**
218256
218727
  * Starts the `gtd lsp` server over stdio. The returned Effect resolves when
218257
218728
  * the client disconnects (`exit` notification), so the process exits cleanly
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "15.2.0",
3
+ "version": "15.2.1",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -38,6 +38,7 @@
38
38
  "build": "tsdown --filter web && npx @tailwindcss/cli -i src/web/styles.css -o dist/web/main.css --minify && node scripts/inline-web-client.mjs && tsdown --filter gtd",
39
39
  "postbuild": "jiti scripts/generate-schema.ts && node scripts/assert-no-test-doubles.mjs",
40
40
  "watch": "tsdown --watch",
41
+ "bench:replay": "node scripts/bench-replay.mjs",
41
42
  "test": "turbo run format:check typecheck lint lint:sh lint:boundaries test:unit test:e2e:inmem test:e2e:live test:web analyze",
42
43
  "test:unit": "vitest run --project unit",
43
44
  "test:watch": "vitest",