@pmelab/gtd 14.0.1 → 15.0.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "14.0.1",
3
+ "version": "15.0.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: () => [],
@@ -621,17 +621,19 @@ review the changes:
621
621
  - At least one \`## <Chunk Title>\` heading grouping hunks
622
622
  semantically (same feature/refactor/fix, across files), each
623
623
  with a short explanation of what changed and why, then one
624
- pointer per hunk (\`./\`-relative path, optional \`#line\`;
625
- checkboxes are for the human, not you). Put the note's
626
- opening line right on the pointer's line:
624
+ pointer per hunk (\`./\`-relative path, mandatory \`#start-end\`;
625
+ checkboxes are for the human, not you). A pointer into a NEW file
626
+ gets a range covering only the region the note is about — never
627
+ the whole file. Put the note's opening line right on the
628
+ pointer's line:
627
629
 
628
- - [ ] ./path/to/file.ts#42 — what this hunk does
630
+ - [ ] ./path/to/file.ts#42-70 — what this hunk does
629
631
 
630
632
  Continue a longer note below the pointer, indented exactly two spaces
631
633
  — never four or more, which reads as a code block and never
632
634
  reflows:
633
635
 
634
- - [ ] ./path/to/file.ts#42 — what this hunk does
636
+ - [ ] ./path/to/file.ts#42-70 — what this hunk does
635
637
  and here is more detail, continued below it
636
638
 
637
639
  A note sitting entirely on the line(s) beneath the pointer is