@pmelab/gtd 14.0.1 → 14.1.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.
@@ -30948,15 +30948,25 @@ const scope = async (scope, fn) => {
30948
30948
  const read = (path) => ctx().read(path);
30949
30949
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
30950
30950
  const glob = (pattern) => ctx().glob(pattern);
30951
+ const toChanges = (list) => Object.freeze(Object.assign([...list], {
30952
+ paths: list.map((c) => c.path),
30953
+ get: (path) => list.find((c) => c.path === path)
30954
+ }));
30951
30955
  /** What the last step changed, optionally only the paths matching a glob. */
30952
30956
  const changes = (pattern) => {
30953
30957
  const context = ctx();
30954
30958
  const all = context.changes();
30955
- const list = pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern));
30956
- return Object.freeze(Object.assign([...list], {
30957
- paths: list.map((c) => c.path),
30958
- get: (path) => list.find((c) => c.path === path)
30959
- }));
30959
+ return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
30960
+ };
30961
+ /**
30962
+ * Every change from the tree at `hash` to the tree replay stands on — this
30963
+ * package's own range, when `hash` is captured at its first step. `hash` must
30964
+ * be the episode's base or one of its commits; anything else fails the step.
30965
+ */
30966
+ const changesSince = (hash, pattern) => {
30967
+ const context = ctx();
30968
+ const all = context.changesSince(hash);
30969
+ return toChanges(pattern === void 0 ? all : all.filter((c) => context.matches(c.path, pattern)));
30960
30970
  };
30961
30971
  /** The commit the process stands on at this point of the flow. */
30962
30972
  const head = () => ctx().head();
@@ -31128,6 +31138,7 @@ var flows_exports = /* @__PURE__ */ __exportAll({
31128
31138
  agent: () => agent,
31129
31139
  answered: () => answered,
31130
31140
  changes: () => changes,
31141
+ changesSince: () => changesSince,
31131
31142
  check: () => check,
31132
31143
  checkScript: () => checkScript,
31133
31144
  codeChanges: () => codeChanges,
@@ -45412,6 +45423,7 @@ const replay = async (input) => {
45412
45423
  ...c,
45413
45424
  parsed: parseCommitMessage(c.message)
45414
45425
  }));
45426
+ const treeAt = (hash) => hash === input.episode.base.hash ? input.episode.base.tree : commits.find((c) => c.hash === hash)?.tree;
45415
45427
  let cursor = 0;
45416
45428
  let pendingUsed = false;
45417
45429
  let position = input.episode.base;
@@ -45616,6 +45628,11 @@ const replay = async (input) => {
45616
45628
  read: (path) => position.tree.read(path),
45617
45629
  glob: (pattern) => position.tree.paths().filter((path) => globMatches(path, pattern)),
45618
45630
  changes: () => changesBetween(previousPosition.tree, position.tree),
45631
+ changesSince: (hash) => {
45632
+ const tree = treeAt(hash);
45633
+ if (tree === void 0) throw new Error(`gtd: changesSince(${hash}): ${hash} is not the episode base or one of its commits — pass a hash this run read from head() or start(), not one captured earlier, read from state, or from another branch`);
45634
+ return changesBetween(tree, position.tree);
45635
+ },
45619
45636
  matches: globMatches,
45620
45637
  sections: (text) => headingSections(text),
45621
45638
  sectionBodies: (text) => headingSectionBodies(text),
@@ -46638,29 +46655,286 @@ const gate = (name, message) => scope(name, async () => {
46638
46655
  });
46639
46656
  });
46640
46657
  //#endregion
46658
+ //#region src/workflows/diff.ts
46659
+ const CONTEXT = 3;
46660
+ const TOO_LARGE_LINES = 1500;
46661
+ const LOCKFILES = /* @__PURE__ */ new Set([
46662
+ "package-lock.json",
46663
+ "npm-shrinkwrap.json",
46664
+ "yarn.lock",
46665
+ "pnpm-lock.yaml",
46666
+ "bun.lock",
46667
+ "bun.lockb",
46668
+ "Cargo.lock",
46669
+ "composer.lock",
46670
+ "Gemfile.lock",
46671
+ "poetry.lock",
46672
+ "uv.lock",
46673
+ "Pipfile.lock",
46674
+ "go.sum",
46675
+ "flake.lock",
46676
+ "mix.lock",
46677
+ "pubspec.lock",
46678
+ "Podfile.lock",
46679
+ "gradle.lockfile"
46680
+ ]);
46681
+ const GENERATED_GLOBS = [
46682
+ "dist/**",
46683
+ "build/**",
46684
+ "out/**",
46685
+ "coverage/**",
46686
+ "node_modules/**",
46687
+ "vendor/**",
46688
+ ".turbo/**",
46689
+ "**/__snapshots__/**",
46690
+ "**/*.snap",
46691
+ "**/*.min.js",
46692
+ "**/*.min.css",
46693
+ "**/*.map"
46694
+ ];
46695
+ const BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
46696
+ "png",
46697
+ "jpg",
46698
+ "jpeg",
46699
+ "gif",
46700
+ "webp",
46701
+ "ico",
46702
+ "pdf",
46703
+ "zip",
46704
+ "gz",
46705
+ "tar",
46706
+ "woff",
46707
+ "woff2",
46708
+ "ttf",
46709
+ "otf",
46710
+ "mp4",
46711
+ "wasm",
46712
+ "bin",
46713
+ "exe",
46714
+ "so",
46715
+ "dylib"
46716
+ ]);
46717
+ const globCache = /* @__PURE__ */ new Map();
46718
+ const compileGlob = (glob) => {
46719
+ let pattern = "^";
46720
+ let i = 0;
46721
+ while (i < glob.length) {
46722
+ const char = glob[i];
46723
+ if (char !== "*") {
46724
+ pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&");
46725
+ i += 1;
46726
+ } else if (glob[i + 1] !== "*") {
46727
+ pattern += "[^/]*";
46728
+ i += 1;
46729
+ } else if (glob[i + 2] === "/") {
46730
+ pattern += "(?:.*/)?";
46731
+ i += 3;
46732
+ } else {
46733
+ pattern += ".*";
46734
+ i += 2;
46735
+ }
46736
+ }
46737
+ return new RegExp(`${pattern}$`);
46738
+ };
46739
+ const matchesGlob = (path, glob) => {
46740
+ let regex = globCache.get(glob);
46741
+ if (regex === void 0) {
46742
+ regex = compileGlob(glob);
46743
+ globCache.set(glob, regex);
46744
+ }
46745
+ return regex.test(path);
46746
+ };
46747
+ const extensionOf = (path) => {
46748
+ const base = path.split("/").pop() ?? path;
46749
+ const dot = base.lastIndexOf(".");
46750
+ return dot === -1 ? "" : base.slice(dot + 1).toLowerCase();
46751
+ };
46752
+ const isBinary = (change) => BINARY_EXTENSIONS.has(extensionOf(change.path)) || (change.before?.includes("\0") ?? false) || (change.after?.includes("\0") ?? false);
46753
+ const isExcluded = (change) => change.path === ".gtd" || change.path.startsWith(".gtd/") || LOCKFILES.has(change.path.split("/").pop() ?? change.path) || GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) || isBinary(change);
46754
+ /** `changes` with lockfiles, generated trees and binaries dropped. */
46755
+ const filterChanges = (changes) => changes.filter((change) => !isExcluded(change));
46756
+ const linesOf = (content) => {
46757
+ if (content === void 0 || content === "") return [];
46758
+ const lines = content.split("\n");
46759
+ if (lines[lines.length - 1] === "") lines.pop();
46760
+ return lines;
46761
+ };
46762
+ /** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
46763
+ const lcsTable = (a, b) => {
46764
+ const n = a.length;
46765
+ const m = b.length;
46766
+ const dp = Array.from({ length: n + 1 }, () => Array.from({ length: m + 1 }).fill(0));
46767
+ for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]);
46768
+ return dp;
46769
+ };
46770
+ /** Walk an LCS table into an edit script, favoring a deletion on a tie. */
46771
+ const backtrack = (a, b, dp) => {
46772
+ const ops = [];
46773
+ let i = 0;
46774
+ let j = 0;
46775
+ while (i < a.length && j < b.length) if (a[i] === b[j]) {
46776
+ ops.push({
46777
+ type: "eq",
46778
+ line: a[i]
46779
+ });
46780
+ i++;
46781
+ j++;
46782
+ } else if (dp[i + 1][j] >= dp[i][j + 1]) {
46783
+ ops.push({
46784
+ type: "del",
46785
+ line: a[i]
46786
+ });
46787
+ i++;
46788
+ } else {
46789
+ ops.push({
46790
+ type: "add",
46791
+ line: b[j]
46792
+ });
46793
+ j++;
46794
+ }
46795
+ while (i < a.length) ops.push({
46796
+ type: "del",
46797
+ line: a[i++]
46798
+ });
46799
+ while (j < b.length) ops.push({
46800
+ type: "add",
46801
+ line: b[j++]
46802
+ });
46803
+ return ops;
46804
+ };
46805
+ /** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
46806
+ const lcsOps = (a, b) => backtrack(a, b, lcsTable(a, b));
46807
+ const commonAffixes = (before, after) => {
46808
+ const maxPrefix = Math.min(before.length, after.length);
46809
+ let prefix = 0;
46810
+ while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++;
46811
+ const maxSuffix = maxPrefix - prefix;
46812
+ let suffix = 0;
46813
+ while (suffix < maxSuffix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) suffix++;
46814
+ return {
46815
+ prefix,
46816
+ suffix
46817
+ };
46818
+ };
46819
+ const buildHunks = (ops) => {
46820
+ const beforeLineAt = [];
46821
+ const afterLineAt = [];
46822
+ let bl = 1;
46823
+ let al = 1;
46824
+ for (const op of ops) {
46825
+ beforeLineAt.push(bl);
46826
+ afterLineAt.push(al);
46827
+ if (op.type !== "add") bl++;
46828
+ if (op.type !== "del") al++;
46829
+ }
46830
+ const ranges = [];
46831
+ ops.forEach((op, idx) => {
46832
+ if (op.type === "eq") return;
46833
+ const start = Math.max(0, idx - CONTEXT);
46834
+ const end = Math.min(ops.length - 1, idx + CONTEXT);
46835
+ const last = ranges[ranges.length - 1];
46836
+ if (last !== void 0 && start <= last[1] + 1) last[1] = Math.max(last[1], end);
46837
+ else ranges.push([start, end]);
46838
+ });
46839
+ return ranges.map(([start, end]) => {
46840
+ const slice = ops.slice(start, end + 1);
46841
+ return {
46842
+ beforeStart: beforeLineAt[start],
46843
+ beforeCount: slice.filter((op) => op.type !== "add").length,
46844
+ afterStart: afterLineAt[start],
46845
+ afterCount: slice.filter((op) => op.type !== "del").length,
46846
+ lines: slice.map((op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`)
46847
+ };
46848
+ });
46849
+ };
46850
+ const renderHunk = (hunk) => [`@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`, ...hunk.lines];
46851
+ /** One file's unified diff, or a summary line when its changed region is too large to inline. */
46852
+ const renderFileDiff = (change) => {
46853
+ const before = linesOf(change.before);
46854
+ const after = linesOf(change.after);
46855
+ const { prefix, suffix } = commonAffixes(before, after);
46856
+ const beforeMiddle = before.slice(prefix, before.length - suffix);
46857
+ const afterMiddle = after.slice(prefix, after.length - suffix);
46858
+ if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`;
46859
+ const prefixOps = before.slice(0, prefix).map((line) => ({
46860
+ type: "eq",
46861
+ line
46862
+ }));
46863
+ const suffixOps = before.slice(before.length - suffix).map((line) => ({
46864
+ type: "eq",
46865
+ line
46866
+ }));
46867
+ const ops = [
46868
+ ...prefixOps,
46869
+ ...lcsOps(beforeMiddle, afterMiddle),
46870
+ ...suffixOps
46871
+ ];
46872
+ const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`;
46873
+ const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`;
46874
+ return [
46875
+ `--- ${beforePath}`,
46876
+ `+++ ${afterPath}`,
46877
+ ...buildHunks(ops).flatMap(renderHunk)
46878
+ ].join("\n");
46879
+ };
46880
+ const omissionTrailer = (paths) => `${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`;
46881
+ /** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
46882
+ const sizeWithTrailer = (files, keepCount) => {
46883
+ let used = 0;
46884
+ for (let i = 0; i < keepCount; i++) used += Buffer.byteLength(files[i].text, "utf8") + (i > 0 ? 1 : 0);
46885
+ if (files.length - keepCount === 0) return used;
46886
+ const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path));
46887
+ return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8");
46888
+ };
46889
+ /**
46890
+ * The filtered `changes`, rendered as one unified diff (files in path order)
46891
+ * and bounded to `capBytes`: over the cap, whole files are dropped from the
46892
+ * end — never bytes off a hunk — and named in a trailer line, so the
46893
+ * judge is told what it did not see rather than handed a silently short diff.
46894
+ * The trailer itself is sized against the cap along with the files it keeps,
46895
+ * so a package with many touched files can never push the total past it.
46896
+ */
46897
+ const packageDiff = (changes, capBytes) => {
46898
+ const files = filterChanges(changes).slice().sort((a, b) => a.path.localeCompare(b.path)).map((change) => ({
46899
+ path: change.path,
46900
+ text: renderFileDiff(change)
46901
+ }));
46902
+ let keepCount = files.length;
46903
+ while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--;
46904
+ const dropped = files.slice(keepCount).map((f) => f.path);
46905
+ return [...files.slice(0, keepCount).map((f) => f.text), ...dropped.length > 0 ? [omissionTrailer(dropped)] : []].join("\n");
46906
+ };
46907
+ //#endregion
46641
46908
  //#region src/workflows/packages.ts
46642
46909
  const MAX_SECTIONS = 8;
46910
+ const DIFF_KEY = "diff";
46643
46911
  const sectionQuestion = (id, title) => ({
46644
46912
  id,
46645
46913
  primitive: "noul",
46646
- instructions: `Is the requirement "${title}" already fully satisfied by the code on the range from ${start()} to the working tree?`,
46647
- criteria: "Judge from the package markdown plus that range, read yourself. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing."
46914
+ instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
46915
+ criteria: "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing."
46648
46916
  });
46649
46917
  /**
46650
46918
  * Review one freshly built package against its spec: a pre-judge per
46651
46919
  * section, then an agent review of the sections it did not confidently
46652
- * clear. Resolves `true` when approved.
46920
+ * clear. `since` is the commit the package's own build started from — the
46921
+ * pre-judge's evidence is a diff over exactly that range, never an earlier
46922
+ * package's commits. Resolves `true` when approved.
46653
46923
  */
46654
- const specReview = async (pkg) => {
46924
+ const specReview = async (pkg, since) => {
46655
46925
  const found = sectionBodies(read(pkg) ?? "");
46656
46926
  const titles = found.map((section) => section.title);
46657
46927
  const judged = titles.length > 0 && titles.length <= MAX_SECTIONS;
46658
46928
  const evidence = {};
46659
46929
  const questions = [];
46660
- if (judged) found.forEach(({ title, body }, i) => {
46661
- evidence[`section-${i + 1}`] = body;
46662
- questions.push(sectionQuestion(`section-${i + 1}`, title));
46663
- });
46930
+ if (judged) {
46931
+ found.forEach(({ title, body }, i) => {
46932
+ evidence[`section-${i + 1}`] = body;
46933
+ questions.push(sectionQuestion(`section-${i + 1}`, title));
46934
+ });
46935
+ const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1));
46936
+ evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes);
46937
+ }
46664
46938
  const { answers, truncated } = await judge("spec.pre", {
46665
46939
  questions,
46666
46940
  evidence,
@@ -46670,7 +46944,7 @@ const specReview = async (pkg) => {
46670
46944
  const clearMinP = numeric(vars.specPreJudge, Infinity);
46671
46945
  const failing = titles.filter((_, i) => {
46672
46946
  const id = `section-${i + 1}`;
46673
- return !judged || truncated.includes(id) || !answered(answers[id], "yes", clearMinP);
46947
+ return !judged || truncated.includes(id) || truncated.includes(DIFF_KEY) || !answered(answers[id], "yes", clearMinP);
46674
46948
  });
46675
46949
  if (titles.length > 0 && failing.length === 0) return true;
46676
46950
  await reviewPackage(pkg, failing);
@@ -46680,10 +46954,11 @@ const specReview = async (pkg) => {
46680
46954
  };
46681
46955
  /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
46682
46956
  const packageItem = async (pkg) => {
46957
+ const since = head();
46683
46958
  await build(pkg);
46684
46959
  for (;;) {
46685
46960
  await healthy(fixSuite);
46686
- if (await specReview(pkg)) break;
46961
+ if (await specReview(pkg, since)) break;
46687
46962
  await fixSpec(pkg);
46688
46963
  }
46689
46964
  await run$2("closing", removeScript([
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "14.0.1",
3
+ "version": "14.1.0",
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": {
@@ -125,6 +125,8 @@ export interface FlowContext {
125
125
  readonly glob: (pattern: string) => readonly string[]
126
126
  /** The last step's changes, content read on demand. */
127
127
  readonly changes: () => readonly Change[]
128
+ /** Every change from the tree at `hash` to the tree replay stands on. Throws when `hash` resolves to neither. */
129
+ readonly changesSince: (hash: string) => readonly Change[]
128
130
  readonly matches: (path: string, pattern: string) => boolean
129
131
  readonly sections: (text: string) => readonly string[]
130
132
  readonly sectionBodies: (text: string) => readonly Section[]
@@ -201,17 +203,34 @@ export const read = (path: string): string | undefined => ctx().read(path)
201
203
  /** Every path in the tree matching `pattern` (`*` stays within a segment, `**` crosses them). */
202
204
  export const glob = (pattern: string): readonly string[] => ctx().glob(pattern)
203
205
 
204
- /** What the last step changed, optionally only the paths matching a glob. */
205
- export const changes = (pattern?: string): Changes => {
206
- const context = ctx()
207
- const all = context.changes()
208
- const list = pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern))
209
- return Object.freeze(
206
+ const toChanges = (list: readonly Change[]): Changes =>
207
+ Object.freeze(
210
208
  Object.assign([...list], {
211
209
  paths: list.map((c) => c.path),
212
210
  get: (path: string) => list.find((c) => c.path === path),
213
211
  }),
214
212
  )
213
+
214
+ /** What the last step changed, optionally only the paths matching a glob. */
215
+ export const changes = (pattern?: string): Changes => {
216
+ const context = ctx()
217
+ const all = context.changes()
218
+ return toChanges(
219
+ pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern)),
220
+ )
221
+ }
222
+
223
+ /**
224
+ * Every change from the tree at `hash` to the tree replay stands on — this
225
+ * package's own range, when `hash` is captured at its first step. `hash` must
226
+ * be the episode's base or one of its commits; anything else fails the step.
227
+ */
228
+ export const changesSince = (hash: string, pattern?: string): Changes => {
229
+ const context = ctx()
230
+ const all = context.changesSince(hash)
231
+ return toChanges(
232
+ pattern === undefined ? all : all.filter((c) => context.matches(c.path, pattern)),
233
+ )
215
234
  }
216
235
 
217
236
  /** The commit the process stands on at this point of the flow. */
@@ -0,0 +1,115 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { Change } from "../flows/index.js"
3
+ import { filterChanges, packageDiff } from "./diff.js"
4
+
5
+ const change = (over: Partial<Change> & Pick<Change, "path" | "status">): Change => ({
6
+ before: undefined,
7
+ after: undefined,
8
+ ...over,
9
+ })
10
+
11
+ // A cap high enough that nothing is ever dropped, for cases exercising the
12
+ // rendering pipeline rather than the byte-budget behavior itself.
13
+ const NO_CAP = 10_000
14
+
15
+ describe("packageDiff / rendering", () => {
16
+ it("renders a normal edit as hunks with three lines of context", () => {
17
+ const before = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n")
18
+ const after = before.replace("line 5", "line five")
19
+ const text = packageDiff([change({ path: "a.ts", status: "modified", before, after })], NO_CAP)
20
+ expect(text).toContain("--- a/a.ts")
21
+ expect(text).toContain("+++ b/a.ts")
22
+ expect(text).toContain("-line 5")
23
+ expect(text).toContain("+line five")
24
+ // three lines of context on either side of the one changed line
25
+ expect(text).toContain(" line 2")
26
+ expect(text).toContain(" line 8")
27
+ expect(text).not.toContain(" line 1\n")
28
+ })
29
+
30
+ it("degrades a 1500+ line change to its summary line", () => {
31
+ const before = Array.from({ length: 2000 }, (_, i) => `line ${i}`).join("\n")
32
+ const after = Array.from({ length: 2000 }, (_, i) => `changed ${i}`).join("\n")
33
+ const text = packageDiff(
34
+ [change({ path: "big.ts", status: "modified", before, after })],
35
+ NO_CAP,
36
+ )
37
+ expect(text).toBe("big.ts: +2000/-2000 lines, too large to inline")
38
+ })
39
+
40
+ it("renders files in path order for byte-identical output across replays", () => {
41
+ const a = change({ path: "z.ts", status: "added", after: "z" })
42
+ const b = change({ path: "a.ts", status: "added", after: "a" })
43
+ expect(packageDiff([a, b], NO_CAP)).toBe(packageDiff([b, a], NO_CAP))
44
+ expect(packageDiff([a, b], NO_CAP).indexOf("a.ts")).toBeLessThan(
45
+ packageDiff([a, b], NO_CAP).indexOf("z.ts"),
46
+ )
47
+ })
48
+ })
49
+
50
+ describe("filterChanges / exclusion list", () => {
51
+ it("excludes a lockfile change entirely", () => {
52
+ const c = change({ path: "package-lock.json", status: "modified", before: "a", after: "b" })
53
+ expect(filterChanges([c])).toEqual([])
54
+ expect(packageDiff([c], NO_CAP)).toBe("")
55
+ })
56
+
57
+ it("excludes .gtd/** paths", () => {
58
+ const c = change({ path: ".gtd/PLAN.md", status: "added", after: "x" })
59
+ expect(filterChanges([c])).toEqual([])
60
+ })
61
+
62
+ it("excludes a binary file by extension", () => {
63
+ const c = change({ path: "logo.png", status: "added", after: "binary-ish" })
64
+ expect(filterChanges([c])).toEqual([])
65
+ })
66
+
67
+ it("excludes a binary file by content, separately from extension", () => {
68
+ const c = change({ path: "weird.txt", status: "added", after: "abc\0def" })
69
+ expect(filterChanges([c])).toEqual([])
70
+ })
71
+
72
+ it("keeps an ordinary source file", () => {
73
+ const c = change({ path: "src/a.ts", status: "added", after: "export const a = 1\n" })
74
+ expect(filterChanges([c])).toEqual([c])
75
+ })
76
+ })
77
+
78
+ describe("packageDiff", () => {
79
+ it("drops whole files from the end and names them in a trailer", () => {
80
+ const a = change({ path: "a.ts", status: "added", after: "export const a = 1\n" })
81
+ const b = change({
82
+ path: "b.ts",
83
+ status: "added",
84
+ after: Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n"),
85
+ })
86
+ const text = packageDiff([a, b], 120)
87
+ expect(text).toContain("--- /dev/null")
88
+ expect(text).toContain("+++ b/a.ts")
89
+ expect(text).not.toContain("b.ts\n")
90
+ expect(text).toContain("1 file(s) omitted for the judge's byte budget: b.ts")
91
+ })
92
+
93
+ it("counts the omission trailer itself against the cap, so many dropped paths never push the total past it", () => {
94
+ const kept = change({ path: "a.ts", status: "added", after: "export const a = 1\n" })
95
+ const rest = Array.from({ length: 6 }, (_, i) =>
96
+ change({ path: `f${i}.ts`, status: "added", after: "y" }),
97
+ )
98
+ // A naive cap check (size the kept text alone, append the trailer
99
+ // afterwards) would keep a.ts plus two of the small files here — 150
100
+ // bytes of text — then tack on an unbudgeted trailer for the other four,
101
+ // landing well past capBytes. Budgeting the trailer itself must instead
102
+ // drop enough files that the total, trailer included, stays at or under it.
103
+ const capBytes = 150
104
+ const text = packageDiff([kept, ...rest], capBytes)
105
+ expect(Buffer.byteLength(text, "utf8")).toBeLessThanOrEqual(capBytes)
106
+ expect(text).toContain("omitted")
107
+ })
108
+
109
+ it("keeps every file when the cap is not exceeded", () => {
110
+ const a = change({ path: "a.ts", status: "added", after: "x" })
111
+ const text = packageDiff([a], 10_000)
112
+ expect(text).not.toContain("omitted")
113
+ expect(text).toContain("a.ts")
114
+ })
115
+ })
@@ -0,0 +1,306 @@
1
+ import type { Change } from "../flows/index.js"
2
+
3
+ // Pure evidence rendering for `packages.item.spec.pre`: no step, no tree
4
+ // access. Everything here works from the `Change` list a flow already holds.
5
+
6
+ const CONTEXT = 3
7
+ const TOO_LARGE_LINES = 1500
8
+
9
+ // ── The fixed exclusion list — no configuration key ─────────────────────────
10
+
11
+ const LOCKFILES = new Set([
12
+ "package-lock.json",
13
+ "npm-shrinkwrap.json",
14
+ "yarn.lock",
15
+ "pnpm-lock.yaml",
16
+ "bun.lock",
17
+ "bun.lockb",
18
+ "Cargo.lock",
19
+ "composer.lock",
20
+ "Gemfile.lock",
21
+ "poetry.lock",
22
+ "uv.lock",
23
+ "Pipfile.lock",
24
+ "go.sum",
25
+ "flake.lock",
26
+ "mix.lock",
27
+ "pubspec.lock",
28
+ "Podfile.lock",
29
+ "gradle.lockfile",
30
+ ])
31
+
32
+ const GENERATED_GLOBS = [
33
+ "dist/**",
34
+ "build/**",
35
+ "out/**",
36
+ "coverage/**",
37
+ "node_modules/**",
38
+ "vendor/**",
39
+ ".turbo/**",
40
+ "**/__snapshots__/**",
41
+ "**/*.snap",
42
+ "**/*.min.js",
43
+ "**/*.min.css",
44
+ "**/*.map",
45
+ ]
46
+
47
+ const BINARY_EXTENSIONS = new Set([
48
+ "png",
49
+ "jpg",
50
+ "jpeg",
51
+ "gif",
52
+ "webp",
53
+ "ico",
54
+ "pdf",
55
+ "zip",
56
+ "gz",
57
+ "tar",
58
+ "woff",
59
+ "woff2",
60
+ "ttf",
61
+ "otf",
62
+ "mp4",
63
+ "wasm",
64
+ "bin",
65
+ "exe",
66
+ "so",
67
+ "dylib",
68
+ ])
69
+
70
+ // A small, self-contained glob match — `*` stays within a segment, `**`
71
+ // crosses them — kept local rather than reached through another boundary for
72
+ // eighteen fixed patterns.
73
+ const globCache = new Map<string, RegExp>()
74
+ const compileGlob = (glob: string): RegExp => {
75
+ let pattern = "^"
76
+ let i = 0
77
+ while (i < glob.length) {
78
+ const char = glob[i]!
79
+ if (char !== "*") {
80
+ pattern += char.replace(/[.+^${}()|[\]\\?]/g, "\\$&")
81
+ i += 1
82
+ } else if (glob[i + 1] !== "*") {
83
+ pattern += "[^/]*"
84
+ i += 1
85
+ } else if (glob[i + 2] === "/") {
86
+ pattern += "(?:.*/)?"
87
+ i += 3
88
+ } else {
89
+ pattern += ".*"
90
+ i += 2
91
+ }
92
+ }
93
+ return new RegExp(`${pattern}$`)
94
+ }
95
+ const matchesGlob = (path: string, glob: string): boolean => {
96
+ let regex = globCache.get(glob)
97
+ if (regex === undefined) {
98
+ regex = compileGlob(glob)
99
+ globCache.set(glob, regex)
100
+ }
101
+ return regex.test(path)
102
+ }
103
+
104
+ const extensionOf = (path: string): string => {
105
+ const base = path.split("/").pop() ?? path
106
+ const dot = base.lastIndexOf(".")
107
+ return dot === -1 ? "" : base.slice(dot + 1).toLowerCase()
108
+ }
109
+
110
+ const isBinary = (change: Change): boolean =>
111
+ BINARY_EXTENSIONS.has(extensionOf(change.path)) ||
112
+ (change.before?.includes("\0") ?? false) ||
113
+ (change.after?.includes("\0") ?? false)
114
+
115
+ const isExcluded = (change: Change): boolean =>
116
+ change.path === ".gtd" ||
117
+ change.path.startsWith(".gtd/") ||
118
+ LOCKFILES.has(change.path.split("/").pop() ?? change.path) ||
119
+ GENERATED_GLOBS.some((glob) => matchesGlob(change.path, glob)) ||
120
+ isBinary(change)
121
+
122
+ /** `changes` with lockfiles, generated trees and binaries dropped. */
123
+ export const filterChanges = (changes: readonly Change[]): readonly Change[] =>
124
+ changes.filter((change) => !isExcluded(change))
125
+
126
+ // ── Line diff ────────────────────────────────────────────────────────────
127
+
128
+ interface Op {
129
+ readonly type: "eq" | "add" | "del"
130
+ readonly line: string
131
+ }
132
+
133
+ const linesOf = (content: string | undefined): readonly string[] => {
134
+ if (content === undefined || content === "") return []
135
+ const lines = content.split("\n")
136
+ if (lines[lines.length - 1] === "") lines.pop()
137
+ return lines
138
+ }
139
+
140
+ /** `dp[i][j]` = the LCS length of `a[i:]` and `b[j:]`. */
141
+ const lcsTable = (a: readonly string[], b: readonly string[]): number[][] => {
142
+ const n = a.length
143
+ const m = b.length
144
+ const dp: number[][] = Array.from({ length: n + 1 }, () =>
145
+ Array.from<number>({ length: m + 1 }).fill(0),
146
+ )
147
+ for (let i = n - 1; i >= 0; i--) {
148
+ for (let j = m - 1; j >= 0; j--) {
149
+ dp[i]![j] = a[i] === b[j] ? dp[i + 1]![j + 1]! + 1 : Math.max(dp[i + 1]![j]!, dp[i]![j + 1]!)
150
+ }
151
+ }
152
+ return dp
153
+ }
154
+
155
+ /** Walk an LCS table into an edit script, favoring a deletion on a tie. */
156
+ const backtrack = (a: readonly string[], b: readonly string[], dp: number[][]): Op[] => {
157
+ const ops: Op[] = []
158
+ let i = 0
159
+ let j = 0
160
+ while (i < a.length && j < b.length) {
161
+ if (a[i] === b[j]) {
162
+ ops.push({ type: "eq", line: a[i]! })
163
+ i++
164
+ j++
165
+ } else if (dp[i + 1]![j]! >= dp[i]![j + 1]!) {
166
+ ops.push({ type: "del", line: a[i]! })
167
+ i++
168
+ } else {
169
+ ops.push({ type: "add", line: b[j]! })
170
+ j++
171
+ }
172
+ }
173
+ while (i < a.length) ops.push({ type: "del", line: a[i++]! })
174
+ while (j < b.length) ops.push({ type: "add", line: b[j++]! })
175
+ return ops
176
+ }
177
+
178
+ /** Longest-common-subsequence edit script between two (already trimmed) line arrays. */
179
+ const lcsOps = (a: readonly string[], b: readonly string[]): Op[] => backtrack(a, b, lcsTable(a, b))
180
+
181
+ const commonAffixes = (
182
+ before: readonly string[],
183
+ after: readonly string[],
184
+ ): { readonly prefix: number; readonly suffix: number } => {
185
+ const maxPrefix = Math.min(before.length, after.length)
186
+ let prefix = 0
187
+ while (prefix < maxPrefix && before[prefix] === after[prefix]) prefix++
188
+ const maxSuffix = maxPrefix - prefix
189
+ let suffix = 0
190
+ while (
191
+ suffix < maxSuffix &&
192
+ before[before.length - 1 - suffix] === after[after.length - 1 - suffix]
193
+ )
194
+ suffix++
195
+ return { prefix, suffix }
196
+ }
197
+
198
+ interface Hunk {
199
+ readonly beforeStart: number
200
+ readonly beforeCount: number
201
+ readonly afterStart: number
202
+ readonly afterCount: number
203
+ readonly lines: readonly string[]
204
+ }
205
+
206
+ const buildHunks = (ops: readonly Op[]): readonly Hunk[] => {
207
+ const beforeLineAt: number[] = []
208
+ const afterLineAt: number[] = []
209
+ let bl = 1
210
+ let al = 1
211
+ for (const op of ops) {
212
+ beforeLineAt.push(bl)
213
+ afterLineAt.push(al)
214
+ if (op.type !== "add") bl++
215
+ if (op.type !== "del") al++
216
+ }
217
+ const ranges: [number, number][] = []
218
+ ops.forEach((op, idx) => {
219
+ if (op.type === "eq") return
220
+ const start = Math.max(0, idx - CONTEXT)
221
+ const end = Math.min(ops.length - 1, idx + CONTEXT)
222
+ const last = ranges[ranges.length - 1]
223
+ if (last !== undefined && start <= last[1] + 1) {
224
+ last[1] = Math.max(last[1], end)
225
+ } else {
226
+ ranges.push([start, end])
227
+ }
228
+ })
229
+ return ranges.map(([start, end]) => {
230
+ const slice = ops.slice(start, end + 1)
231
+ return {
232
+ beforeStart: beforeLineAt[start]!,
233
+ beforeCount: slice.filter((op) => op.type !== "add").length,
234
+ afterStart: afterLineAt[start]!,
235
+ afterCount: slice.filter((op) => op.type !== "del").length,
236
+ lines: slice.map(
237
+ (op) => `${op.type === "add" ? "+" : op.type === "del" ? "-" : " "}${op.line}`,
238
+ ),
239
+ }
240
+ })
241
+ }
242
+
243
+ const renderHunk = (hunk: Hunk): readonly string[] => [
244
+ `@@ -${hunk.beforeStart},${hunk.beforeCount} +${hunk.afterStart},${hunk.afterCount} @@`,
245
+ ...hunk.lines,
246
+ ]
247
+
248
+ /** One file's unified diff, or a summary line when its changed region is too large to inline. */
249
+ const renderFileDiff = (change: Change): string => {
250
+ const before = linesOf(change.before)
251
+ const after = linesOf(change.after)
252
+ const { prefix, suffix } = commonAffixes(before, after)
253
+ const beforeMiddle = before.slice(prefix, before.length - suffix)
254
+ const afterMiddle = after.slice(prefix, after.length - suffix)
255
+ if (beforeMiddle.length > TOO_LARGE_LINES || afterMiddle.length > TOO_LARGE_LINES) {
256
+ return `${change.path}: +${afterMiddle.length}/-${beforeMiddle.length} lines, too large to inline`
257
+ }
258
+ const prefixOps: Op[] = before.slice(0, prefix).map((line) => ({ type: "eq", line }))
259
+ const suffixOps: Op[] = before.slice(before.length - suffix).map((line) => ({ type: "eq", line }))
260
+ const ops = [...prefixOps, ...lcsOps(beforeMiddle, afterMiddle), ...suffixOps]
261
+ const beforePath = change.status === "added" ? "/dev/null" : `a/${change.path}`
262
+ const afterPath = change.status === "deleted" ? "/dev/null" : `b/${change.path}`
263
+ return [`--- ${beforePath}`, `+++ ${afterPath}`, ...buildHunks(ops).flatMap(renderHunk)].join(
264
+ "\n",
265
+ )
266
+ }
267
+
268
+ const omissionTrailer = (paths: readonly string[]): string =>
269
+ `${paths.length} file(s) omitted for the judge's byte budget: ${paths.join(", ")}`
270
+
271
+ interface RenderedFile {
272
+ readonly path: string
273
+ readonly text: string
274
+ }
275
+
276
+ /** The byte size of keeping `files`' first `keepCount` entries plus the trailer the rest would need — the trailer counts against the cap too, not just the kept text. */
277
+ const sizeWithTrailer = (files: readonly RenderedFile[], keepCount: number): number => {
278
+ let used = 0
279
+ for (let i = 0; i < keepCount; i++) {
280
+ used += Buffer.byteLength(files[i]!.text, "utf8") + (i > 0 ? 1 : 0)
281
+ }
282
+ const droppedCount = files.length - keepCount
283
+ if (droppedCount === 0) return used
284
+ const trailer = omissionTrailer(files.slice(keepCount).map((f) => f.path))
285
+ return used + (keepCount > 0 ? 1 : 0) + Buffer.byteLength(trailer, "utf8")
286
+ }
287
+
288
+ /**
289
+ * The filtered `changes`, rendered as one unified diff (files in path order)
290
+ * and bounded to `capBytes`: over the cap, whole files are dropped from the
291
+ * end — never bytes off a hunk — and named in a trailer line, so the
292
+ * judge is told what it did not see rather than handed a silently short diff.
293
+ * The trailer itself is sized against the cap along with the files it keeps,
294
+ * so a package with many touched files can never push the total past it.
295
+ */
296
+ export const packageDiff = (changes: readonly Change[], capBytes: number): string => {
297
+ const files = filterChanges(changes)
298
+ .slice()
299
+ .sort((a, b) => a.path.localeCompare(b.path))
300
+ .map((change) => ({ path: change.path, text: renderFileDiff(change) }))
301
+ let keepCount = files.length
302
+ while (keepCount > 0 && sizeWithTrailer(files, keepCount) > capBytes) keepCount--
303
+ const dropped = files.slice(keepCount).map((f) => f.path)
304
+ const kept = files.slice(0, keepCount).map((f) => f.text)
305
+ return [...kept, ...(dropped.length > 0 ? [omissionTrailer(dropped)] : [])].join("\n")
306
+ }
@@ -1,7 +1,9 @@
1
1
  import {
2
2
  answered,
3
3
  changes,
4
+ changesSince,
4
5
  glob,
6
+ head,
5
7
  judge,
6
8
  numeric,
7
9
  read,
@@ -10,31 +12,34 @@ import {
10
12
  run,
11
13
  scope,
12
14
  sectionBodies,
13
- start,
14
15
  vars,
15
16
  wrote,
16
17
  type JudgeQuestion,
17
18
  } from "../flows/index.js"
19
+ import { packageDiff } from "./diff.js"
18
20
  import { healthy } from "./health.js"
19
21
  import { ARCHITECTURE, build, fixSpec, fixSuite, reviewPackage, SPEC_FEEDBACK } from "./steps.js"
20
22
  import * as t from "./text.js"
21
23
 
22
24
  const MAX_SECTIONS = 8
25
+ const DIFF_KEY = "diff"
23
26
 
24
27
  const sectionQuestion = (id: string, title: string): JudgeQuestion => ({
25
28
  id,
26
29
  primitive: "noul",
27
- instructions: `Is the requirement "${title}" already fully satisfied by the code on the range from ${start()} to the working tree?`,
30
+ instructions: `Is the requirement "${title}" — the "${id}" evidence — already fully satisfied by the code in the "${DIFF_KEY}" evidence?`,
28
31
  criteria:
29
- "Judge from the package markdown plus that range, read yourself. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
32
+ "Judge from the requirement text and the diff evidence alone. Only answer yes at a probability clearing the threshold below if genuinely confident nothing in this section is missing.",
30
33
  })
31
34
 
32
35
  /**
33
36
  * Review one freshly built package against its spec: a pre-judge per
34
37
  * section, then an agent review of the sections it did not confidently
35
- * clear. Resolves `true` when approved.
38
+ * clear. `since` is the commit the package's own build started from — the
39
+ * pre-judge's evidence is a diff over exactly that range, never an earlier
40
+ * package's commits. Resolves `true` when approved.
36
41
  */
37
- export const specReview = async (pkg: string): Promise<boolean> => {
42
+ export const specReview = async (pkg: string, since: string): Promise<boolean> => {
38
43
  const found = sectionBodies(read(pkg) ?? "")
39
44
  const titles = found.map((section) => section.title)
40
45
  const judged = titles.length > 0 && titles.length <= MAX_SECTIONS
@@ -45,6 +50,10 @@ export const specReview = async (pkg: string): Promise<boolean> => {
45
50
  evidence[`section-${i + 1}`] = body
46
51
  questions.push(sectionQuestion(`section-${i + 1}`, title))
47
52
  })
53
+ // The diff's own even share of the budget: one key per section plus
54
+ // `diff` itself, so this cap equals what `budgeted` gives it below.
55
+ const capBytes = Math.floor(numeric(vars.judgeBudgetBytes, 32768) / (found.length + 1))
56
+ evidence[DIFF_KEY] = packageDiff(changesSince(since), capBytes)
48
57
  }
49
58
  const { answers, truncated } = await judge("spec.pre", {
50
59
  questions,
@@ -52,11 +61,17 @@ export const specReview = async (pkg: string): Promise<boolean> => {
52
61
  message: t.packagesItemSpecPreMessage(),
53
62
  label: "Judging spec coverage",
54
63
  })
55
- // A section is cleared only by a confident yes on evidence that was not cut.
64
+ // A section is cleared only by a confident yes on evidence that was not
65
+ // cut — its own body, or the diff every question is judged against.
56
66
  const clearMinP = numeric(vars.specPreJudge, Infinity)
57
67
  const failing = titles.filter((_, i) => {
58
68
  const id = `section-${i + 1}`
59
- return !judged || truncated.includes(id) || !answered(answers[id], "yes", clearMinP)
69
+ return (
70
+ !judged ||
71
+ truncated.includes(id) ||
72
+ truncated.includes(DIFF_KEY) ||
73
+ !answered(answers[id], "yes", clearMinP)
74
+ )
60
75
  })
61
76
  if (titles.length > 0 && failing.length === 0) return true
62
77
  await reviewPackage(pkg, failing)
@@ -69,10 +84,11 @@ export const specReview = async (pkg: string): Promise<boolean> => {
69
84
 
70
85
  /** Build `pkg`, keep the suite green, review it against its spec, and close it out, which removes it. */
71
86
  export const packageItem = async (pkg: string): Promise<void> => {
87
+ const since = head()
72
88
  await build(pkg)
73
89
  for (;;) {
74
90
  await healthy(fixSuite)
75
- if (await specReview(pkg)) break
91
+ if (await specReview(pkg, since)) break
76
92
  await fixSpec(pkg)
77
93
  }
78
94
  // The spent technical plan goes with the first package built from it.
@@ -22,6 +22,7 @@ export const renderText = <T>(text: () => T, context: TextContext = {}): T => {
22
22
  read: context.read ?? (() => undefined),
23
23
  glob: () => [],
24
24
  changes: () => [],
25
+ changesSince: unavailable,
25
26
  matches: () => false,
26
27
  sections: () => [],
27
28
  sectionBodies: () => [],