@pmelab/gtd 32.1.0 → 33.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.
@@ -1,14 +1,20 @@
1
+ import { posix } from "node:path"
1
2
  import {
2
3
  changes,
4
+ check,
5
+ env,
3
6
  head,
4
7
  human,
5
8
  judgeDiffShape,
6
9
  kept,
10
+ quote,
11
+ read,
7
12
  removeScript,
8
13
  run,
9
14
  scope,
10
15
  slice,
11
16
  vars,
17
+ type Change,
12
18
  type DiffShapeResult,
13
19
  type KeptTip,
14
20
  } from "../../flows/index.js"
@@ -16,12 +22,19 @@ import { healthy } from "../shared/health.js"
16
22
  import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
17
23
  import * as t from "../shared/text.js"
18
24
  import { defaults } from "../shared/vars.js"
19
- import { requireCriteria, requireCriteriaFrozen } from "./criteria.js"
20
- import { refactorStrays, reviewedRefactor } from "./refactor.js"
25
+ import { criteriaOf, requireCriteria, requireCriteriaFrozen } from "./criteria.js"
26
+ import {
27
+ COMMENT,
28
+ holdsCallSites,
29
+ isTestFile,
30
+ refactorStrays,
31
+ reviewedRefactor,
32
+ } from "./refactor.js"
33
+ import { isCode } from "./review.js"
21
34
  import { flagModeOn } from "../shared/options.js"
22
35
  import type { RiskTier } from "./tier.js"
23
36
 
24
- /** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
37
+ /** Naive-pass retries after a `reject` or a red suite; the next one rests at `seam.steer`. */
25
38
  const NAIVE_RETRIES = 2
26
39
 
27
40
  /** A change to an existing file outside the allowlist is a stray even when the task needs it: the judge decides. */
@@ -35,14 +48,121 @@ export const judgeSeam = (tip: KeptTip): Promise<DiffShapeResult> =>
35
48
  exempt: tip.changes.filter((c) => c.status === "added").map((c) => c.path),
36
49
  })
37
50
 
38
- export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
39
- t.agentWithSkills("naive", t.naivePassPrompt(rejection), {
40
- label: rejection === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
51
+ export const naivePass = (retry?: t.NaiveRetry): Promise<void> =>
52
+ t.agentWithSkills("naive", t.naivePassPrompt(retry), {
53
+ label: retry === undefined ? "Writing the naive pass" : "Rewriting the naive pass",
41
54
  keep: true,
42
55
  model: coder(),
43
56
  system: t.builderSystem(),
44
57
  })
45
58
 
59
+ // Only a string an import, `require` or `mock` call takes: a URL route or path-like data is not one.
60
+ const IMPORTED = /(?:\bfrom|\bimport|\brequire\s*\(|\bmock\s*\()\s*\(?\s*(["'`])([^"'`\n]+)\1/g
61
+ const EXTENSION = /\.[\w-]+$/
62
+ const ALIAS = /^(?:[@~]\/|#)/
63
+
64
+ /** The specifiers an added `path` answers to: itself, without its extension, and its directory for an `index`. */
65
+ const namesOf = (path: string): string[] => {
66
+ const bare = path.replace(EXTENSION, "")
67
+ return posix.basename(bare) === "index" ? [path, bare, posix.dirname(bare)] : [path, bare]
68
+ }
69
+
70
+ /**
71
+ * The repository paths `test` imports, each with and without an extension: a
72
+ * relative specifier resolved from its directory, an aliased one (`@/`, `~/`,
73
+ * `#`) as a path tail, anything else as written. Comments are skipped; a
74
+ * computed import goes unseen.
75
+ */
76
+ const importsOf = (test: Change): { readonly path: string; readonly tail: boolean }[] =>
77
+ [...(test.after ?? "").replace(COMMENT, "").matchAll(IMPORTED)].flatMap(([, , spec]) => {
78
+ const relative = /^\.\.?(\/|$)/.test(spec!)
79
+ const tail = ALIAS.test(spec!)
80
+ const path = relative ? posix.join(posix.dirname(test.path), spec!) : spec!.replace(ALIAS, "")
81
+ return [path, path.replace(EXTENSION, "")].map((p) => ({ path: p, tail }))
82
+ })
83
+
84
+ const reaches = (imports: ReturnType<typeof importsOf>, added: string): boolean =>
85
+ namesOf(added).some((name) =>
86
+ imports.some(({ path, tail }) => name === path || (tail && name.endsWith(`/${path}`))),
87
+ )
88
+
89
+ const named = (id: string, text: string): boolean =>
90
+ new RegExp(`(?<![\\w-])${id}(?!\\d)`).test(text)
91
+
92
+ /** The lines `change` added: an ID an earlier change left in the file does not count. */
93
+ const addedLines = (change: Change): string => {
94
+ const before = new Set((change.before ?? "").split("\n"))
95
+ return change
96
+ .after!.split("\n")
97
+ .filter((line) => !before.has(line))
98
+ .join("\n")
99
+ }
100
+
101
+ /**
102
+ * Why `tip`'s tests fall short, or `undefined`: a criterion ID of `file` no
103
+ * test line of the pass names, or a test importing a file the pass added —
104
+ * naive structure a reset and refactor replaces, which the tests must survive.
105
+ */
106
+ export const acceptanceFault = (file: string, tip: KeptTip): string | undefined => {
107
+ const ids = (criteriaOf(read(file) ?? "") ?? []).map((c) => c.id).filter((id) => id !== "")
108
+ const tests = tip.changes.filter(
109
+ (c) => c.after !== undefined && isCode(c.path) && isTestFile(c.path),
110
+ )
111
+ const lines = tests.map(addedLines)
112
+ const untested = ids.filter((id) => !lines.some((text) => named(id, text)))
113
+ if (untested.length > 0)
114
+ return `acceptance-tests: no test of the naive pass names ${untested.join(", ")} — write a behaviour-level test for each criterion with its ID in the test or scenario name, e.g. "[${untested[0]}] …"`
115
+ const added = tip.changes
116
+ .filter((c) => c.status === "added" && isCode(c.path) && !isTestFile(c.path))
117
+ .map((c) => c.path)
118
+ const reaching = tests
119
+ .filter((test) => holdsCallSites(test.path))
120
+ .flatMap((test) => {
121
+ const imports = importsOf(test)
122
+ return added
123
+ .filter((path) => reaches(imports, path))
124
+ .map((path) => ` - ${test.path} → ${path}`)
125
+ })
126
+ if (reaching.length > 0)
127
+ return `acceptance-internals: these tests import files the naive pass added — test through code that existed before it (a public entry point, an existing module's exports):\n${reaching.join("\n")}`
128
+ return undefined
129
+ }
130
+
131
+ const restore = (path: string): string =>
132
+ `if git cat-file -e ${quote(`HEAD:${path}`)} 2>/dev/null; then git checkout -q HEAD -- ${quote(path)}; else rm -f -- ${quote(path)}; fi`
133
+
134
+ /**
135
+ * Run the suite with `tip`'s diff applied, then take it out again; resolves
136
+ * `true` when green. The pass's paths go back to HEAD before (an interrupted
137
+ * run left them applied) and after; afterwards so does anything else the run
138
+ * changed, judged against what was already dirty, which is left alone.
139
+ */
140
+ const naiveGreen = (tip: KeptTip): Promise<boolean> => {
141
+ const paths = tip.changes.map((c) => restore(c.path))
142
+ return check(
143
+ "check",
144
+ [
145
+ ...paths,
146
+ "dirty=$(git -c core.quotePath=false ls-files -m -d -o --exclude-standard)",
147
+ `if ! git diff --binary ${quote(`${tip.hash}^`)} ${quote(tip.hash)} -- | git apply; then`,
148
+ " echo 'gtd: the kept naive pass no longer applies to HEAD; no test ran'",
149
+ " exit 1",
150
+ "fi",
151
+ "(",
152
+ env.testCommand ?? "",
153
+ ")",
154
+ "code=$?",
155
+ ...paths,
156
+ "git -c core.quotePath=false ls-files -m -d -o --exclude-standard | while IFS= read -r path; do",
157
+ ' printf \'%s\\n\' "$dirty" | grep -qxF -- "$path" && continue',
158
+ ' if git cat-file -e "HEAD:$path" 2>/dev/null; then git checkout -q HEAD -- "$path"; else rm -f -- "$path"; fi',
159
+ "done",
160
+ 'exit "$code"',
161
+ ].join("\n"),
162
+ { report: FEEDBACK, label: "Running the naive pass's tests" },
163
+ )
164
+ }
165
+
46
166
  export interface ImplementOptions {
47
167
  readonly reference?: string | undefined
48
168
  readonly tier?: RiskTier | undefined
@@ -103,34 +223,79 @@ interface Decision {
103
223
  readonly shape?: DiffShapeResult
104
224
  }
105
225
 
226
+ /** Resolves `true` when the human steered (edited), `false` on a clean overrule. */
227
+ const steer = async (retry: t.NaiveRetry): Promise<boolean> => {
228
+ await human("steer", {
229
+ message: t.seamSteerMessage(retry),
230
+ label: "Steer or overrule the naive pass",
231
+ file: REQUIREMENTS,
232
+ acceptClean: true,
233
+ })
234
+ if (changes().length === 0) return false
235
+ requireCriteria(REQUIREMENTS)
236
+ return true
237
+ }
238
+
239
+ type Verdict =
240
+ | { readonly kind: "retry"; readonly retry: t.NaiveRetry }
241
+ | { readonly kind: "steered" }
242
+ | { readonly kind: "overruled" }
243
+
244
+ /** Rejects, test faults and red checks share one budget; a steer or an overrule refills it. */
245
+ const budget = (): ((next: t.NaiveRetry) => Promise<Verdict>) => {
246
+ let retries = 0
247
+ return async (next) => {
248
+ if (retries < NAIVE_RETRIES) {
249
+ retries++
250
+ return { kind: "retry", retry: next }
251
+ }
252
+ const steered = await steer(next)
253
+ retries = 0
254
+ // A steered or overruled red pass leaves its report behind: nothing reads it any more.
255
+ if (read(FEEDBACK) !== undefined)
256
+ await run("clear", removeScript([FEEDBACK]), { label: "Clearing the red check" })
257
+ return { kind: steered ? "steered" : "overruled" }
258
+ }
259
+ }
260
+
261
+ interface Vetting {
262
+ readonly again: ReturnType<typeof budget>
263
+ /** Set once a human overrules a test fault: the rule does not fit this change, so later passes skip it. */
264
+ waived: boolean
265
+ }
266
+
267
+ /** Sends a pass whose tests fall short or run red back (`undefined`: it goes on, an overruled one as it is). */
268
+ const vetted = async (tip: KeptTip, vetting: Vetting): Promise<Verdict | undefined> => {
269
+ const fault = vetting.waived ? undefined : acceptanceFault(REQUIREMENTS, tip)
270
+ if (fault !== undefined) {
271
+ const verdict = await vetting.again({ fault, hash: tip.hash })
272
+ if (verdict.kind !== "overruled") return verdict
273
+ vetting.waived = true
274
+ }
275
+ if (await naiveGreen(tip)) return undefined
276
+ const verdict = await vetting.again({ red: tip.hash })
277
+ return verdict.kind === "overruled" ? undefined : verdict
278
+ }
279
+
280
+ /** The judge's ruling on a vetted pass: the decision, or the next attempt's verdict. */
281
+ const ruled = async (tip: KeptTip, again: Vetting["again"]): Promise<Decision | Verdict> => {
282
+ const shape = await judgeSeam(tip)
283
+ if (shape.choice === "land") return { reference: tip.hash }
284
+ if (shape.choice === "refactor-first") return { reference: tip.hash, shape }
285
+ const next = await again({ strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 })
286
+ return next.kind === "overruled" ? { reference: tip.hash } : next
287
+ }
288
+
106
289
  const decide = (): Promise<Decision> =>
107
290
  scope("seam", async () => {
108
- let rejection: t.SeamRejection | undefined
109
- let retries = 0
291
+ const vetting: Vetting = { again: budget(), waived: false }
292
+ let retry: t.NaiveRetry | undefined
110
293
  for (;;) {
111
- await naivePass(rejection)
294
+ await naivePass(retry)
112
295
  const tip = kept("naive")!
113
- const shape = await judgeSeam(tip)
114
- if (shape.choice === "land") return { reference: tip.hash }
115
- if (shape.choice === "refactor-first") {
116
- return { reference: tip.hash, shape }
117
- }
118
- rejection = { strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 }
119
- if (retries < NAIVE_RETRIES) {
120
- retries++
121
- continue
122
- }
123
- await human("steer", {
124
- message: t.seamSteerMessage(rejection),
125
- label: "Steer or overrule the rejected naive pass",
126
- file: REQUIREMENTS,
127
- acceptClean: true,
128
- })
129
- // Any edit steers, with a fresh budget and no stale rejection; clean overrules.
130
- if (changes().length === 0) return { reference: tip.hash }
131
- requireCriteria(REQUIREMENTS)
132
- rejection = undefined
133
- retries = 0
296
+ const outcome = (await vetted(tip, vetting)) ?? (await ruled(tip, vetting.again))
297
+ if (!("kind" in outcome)) return outcome
298
+ retry = outcome.kind === "retry" ? outcome.retry : undefined
134
299
  }
135
300
  })
136
301
 
@@ -965,6 +965,22 @@ describe("the seam phase's texts", () => {
965
965
  expect(prompt).toContain("solved the task the wrong way")
966
966
  })
967
967
 
968
+ it("asks for a breadth-first pass with ID-tagged behaviour tests, green before it ends", () => {
969
+ const prompt = renderText(() => naivePassPrompt())
970
+ expect(prompt).toContain("breadth-first")
971
+ expect(prompt).toContain("`[AC-3] …`")
972
+ expect(prompt).toContain("never a file this pass adds")
973
+ expect(prompt).toContain("green")
974
+ })
975
+
976
+ it("has a red retry pick up the kept attempt instead of starting over", () => {
977
+ const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
978
+ expect(prompt).toContain("left the suite red")
979
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
980
+ expect(prompt).toContain(".gtd/FEEDBACK.md")
981
+ expect(prompt).not.toContain("rejected")
982
+ })
983
+
968
984
  it("cites the reference as reading material only and asks for a new release flag", () => {
969
985
  const prompt = renderText(() => implementBuildingPrompt("abc123"))
970
986
  expect(prompt).toContain("git show abc123")
@@ -1019,6 +1035,30 @@ describe("the seam phase's texts", () => {
1019
1035
  expect(message).toContain("src/flows/helpers.ts")
1020
1036
  })
1021
1037
 
1038
+ it("hands a faulty pass back with the fault and the kept attempt to pick up", () => {
1039
+ const fault = {
1040
+ fault: "acceptance-tests: no test of the naive pass names AC-2",
1041
+ hash: "abc123",
1042
+ }
1043
+ const prompt = renderText(() => naivePassPrompt(fault))
1044
+ expect(prompt).toContain("names AC-2")
1045
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
1046
+ const message = renderText(() => seamSteerMessage(fault))
1047
+ expect(message).toContain("names AC-2")
1048
+ expect(message).toContain("`seam.check` runs its suite")
1049
+ expect(message).toContain("Overrule")
1050
+ })
1051
+
1052
+ it("names the red suite, its report and what overruling it means", () => {
1053
+ const message = renderText(() => seamSteerMessage({ red: "abc123" }))
1054
+ expect(message).toContain("The last\nattempt left the suite red")
1055
+ expect(message).toContain(".gtd/FEEDBACK.md")
1056
+ expect(message).toContain("edit `.gtd/REQUIREMENTS.md`")
1057
+ expect(message).toContain("Overrule")
1058
+ expect(message).toContain("seam judge")
1059
+ expect(message).not.toContain("outside the expected set")
1060
+ })
1061
+
1022
1062
  it("names every unreadable path for a by-hand landing", () => {
1023
1063
  const message = renderText(() =>
1024
1064
  seamByHandMessage(["vendor/a.bin", "src/flows/helpers.ts"], ["vendor/a.bin"], "abc123"),
@@ -1134,31 +1134,72 @@ export interface SeamRejection {
1134
1134
  readonly p: number
1135
1135
  }
1136
1136
 
1137
+ /** A naive pass whose suite `seam.check` found red; `red` is its kept tip. */
1138
+ export interface NaiveRed {
1139
+ readonly red: string
1140
+ }
1141
+
1142
+ /** A naive pass whose tests break the acceptance-test rules; `hash` is its kept tip. */
1143
+ export interface NaiveFault {
1144
+ readonly fault: string
1145
+ readonly hash: string
1146
+ }
1147
+
1148
+ export type NaiveRetry = SeamRejection | NaiveRed | NaiveFault
1149
+
1150
+ const isRed = (retry: NaiveRetry): retry is NaiveRed => "red" in retry
1151
+ const isFault = (retry: NaiveRetry): retry is NaiveFault => "fault" in retry
1152
+
1153
+ const pickUp = (hash: string): string =>
1154
+ `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``
1155
+
1137
1156
  const criteriaBinding = (refused: boolean): string =>
1138
1157
  `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
1139
1158
  the file, never from memory, and stop only when each one holds. They are
1140
1159
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`
1141
1160
 
1142
- export const naivePassPrompt = (rejection?: SeamRejection): string =>
1161
+ const naiveRetry = (retry: NaiveRetry): string =>
1162
+ isFault(retry)
1163
+ ? `
1164
+ Your previous attempt's (\`${retry.hash}\`) tests fall short:
1165
+ ${retry.fault}
1166
+ ${pickUp(retry.hash)} — and fix its tests; do not start over.
1167
+ `
1168
+ : isRed(retry)
1169
+ ? `
1170
+ Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
1171
+ holds the output. ${pickUp(retry.red)}
1172
+ — and fix it until the suite is green; do not start over.
1173
+ `
1174
+ : `
1175
+ Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
1176
+ meaning the naive pass solved the task the wrong way — discard it and
1177
+ re-implement. It changed these files outside the expected set:
1178
+ ${listed(retry.strays)}
1179
+ Start over and choose a different approach; do not rebuild the same diff.
1180
+ `
1181
+
1182
+ export const naivePassPrompt = (retry?: NaiveRetry): string =>
1143
1183
  `${stateFileRules}
1144
1184
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1145
1185
  ${criteriaBinding(false)}
1146
1186
  - Implement them the quickest way that works — the naive version. Do not
1147
1187
  restructure, abstract or refactor around the change; a later step judges
1148
1188
  the shape of your diff and a reviewer makes it fit
1149
- - Add tests as you go, and leave \`.gtd/\` untouched
1189
+ - Work breadth-first: rough out every part of the change before finishing any
1190
+ one, so a run cut off midway still shows the whole shape
1191
+ - Write behaviour-level acceptance tests, at least one per criterion, each
1192
+ naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
1193
+ that existed before this pass — a public entry point, the CLI, an existing
1194
+ module's exports — never a file this pass adds: that is naive structure a
1195
+ later reset and refactor replaces. A pass that leaves a criterion's ID out
1196
+ of its tests, or whose test imports a file it added, is sent back
1197
+ - Run \`${env.testCommand}\` and finish only once it is green with every
1198
+ criterion's test passing: the next step runs it on your diff and sends a red
1199
+ pass back
1200
+ - Leave \`.gtd/\` untouched
1150
1201
  - Leave everything uncommitted and finish your turn — do not commit
1151
- ${
1152
- rejection === undefined
1153
- ? ""
1154
- : `
1155
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
1156
- meaning the naive pass solved the task the wrong way — discard it and
1157
- re-implement. It changed these files outside the expected set:
1158
- ${listed(rejection.strays)}
1159
- Start over and choose a different approach; do not rebuild the same diff.
1160
- `
1161
- }`
1202
+ ${retry === undefined ? "" : naiveRetry(retry)}`
1162
1203
 
1163
1204
  export const implementBuildingPrompt = (
1164
1205
  reference: string | undefined,
@@ -1216,9 +1257,49 @@ ${fixFeedbackPrompt}
1216
1257
  - Leave everything uncommitted and finish your turn — do not commit
1217
1258
  `
1218
1259
 
1219
- export const seamSteerMessage = (rejection: SeamRejection): string =>
1220
- `The naive pass was rejected three times and the retries are spent.
1221
- The last attempt changed these files outside the expected set:
1260
+ const steerChoices = (overrule: string): string => `Two ways forward (then run \`gtd land\`):
1261
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1262
+ pass runs again with a fresh retry budget
1263
+ - **Overrule** — ${overrule}
1264
+
1265
+ An edit together with an approval counts as steer. Only
1266
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1267
+ branch unjudged.
1268
+ `
1269
+
1270
+ export const seamSteerMessage = (retry: NaiveRetry): string =>
1271
+ isFault(retry)
1272
+ ? `The naive pass failed three times and the retries are spent. The last
1273
+ attempt's tests (\`git show ${retry.hash}\`) fall short:
1274
+ ${retry.fault}
1275
+
1276
+ ${steerChoices(`land a clean tree to go on with the last attempt as it is:
1277
+ \`seam.check\` runs its suite and the seam judge rules on its shape. Use it
1278
+ when the rule cannot fit this change (tests the globs do not see, or no
1279
+ entry point that existed before). Risk: a criterion may have no test that
1280
+ survives a later refactor — the choice is recorded in history.`)}`
1281
+ : isRed(retry)
1282
+ ? `The naive pass failed three times and the retries are spent. The last
1283
+ attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
1284
+ the last attempt.
1285
+
1286
+ Two ways forward (then run \`gtd land\`):
1287
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1288
+ pass runs again with a fresh retry budget
1289
+ - **Overrule** — land a clean tree to go on with the last attempt as it is:
1290
+ the seam judge rules on its shape, and the implementation's health loop
1291
+ must turn the suite green. Risk: the criteria's tests never passed on the
1292
+ naive pass — the choice is recorded in history.
1293
+
1294
+ An edit together with an approval counts as steer. Only
1295
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1296
+ branch unjudged.
1297
+ `
1298
+ : seamRejectedMessage(retry)
1299
+
1300
+ const seamRejectedMessage = (rejection: SeamRejection): string =>
1301
+ `The naive pass failed three times and the retries are spent. The judge
1302
+ rejected the last attempt: it changed these files outside the expected set:
1222
1303
  ${listed(rejection.strays)}
1223
1304
 
1224
1305
  Two ways forward (then run \`gtd land\`):