@pmelab/gtd 49.0.0 → 51.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.
Files changed (60) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -3
  3. package/dist/chunks/{reserved-CYZ1bSBy.mjs → reserved-CUkk9XgL.mjs} +151 -5
  4. package/dist/chunks/{review-BewyJflS.mjs → review-BaCXlIT2.mjs} +84 -20
  5. package/dist/chunks/{steering-BXwjmocB.mjs → steering-DtUVUz9l.mjs} +44 -14
  6. package/dist/chunks/{tail-C1Oi2YEy.mjs → tail-CX6Pdsql.mjs} +1 -1
  7. package/dist/gtd.bundle.mjs +22 -5
  8. package/dist/steering.mjs +2 -2
  9. package/dist/testkit.mjs +1 -1
  10. package/dist/web/main.js +27 -3
  11. package/dist/workflow-kit.mjs +1 -1
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +144 -11
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +3 -3
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +415 -42
  18. package/package.json +1 -1
  19. package/src/steering/index.ts +1 -0
  20. package/src/steering/review.test.ts +60 -2
  21. package/src/steering/review.ts +66 -9
  22. package/src/workflows/feature/coverage.feature +1 -0
  23. package/src/workflows/feature/fast-path.feature +1 -0
  24. package/src/workflows/feature/feature.ts +4 -1
  25. package/src/workflows/feature/flag-mode.feature +1 -1
  26. package/src/workflows/feature/lens-models.feature +1 -0
  27. package/src/workflows/feature/lens-review.feature +17 -4
  28. package/src/workflows/feature/one-step-land.feature +1 -0
  29. package/src/workflows/feature/prototype-rounds.feature +152 -0
  30. package/src/workflows/feature/prototype.feature +419 -0
  31. package/src/workflows/feature/prototype.test.ts +43 -0
  32. package/src/workflows/feature/prototype.ts +181 -0
  33. package/src/workflows/feature/refactor-absorbs.feature +9 -1
  34. package/src/workflows/feature/refactor-first.feature +13 -3
  35. package/src/workflows/feature/refactor-rebuild.feature +29 -5
  36. package/src/workflows/feature/refactor-review.feature +111 -13
  37. package/src/workflows/feature/refactor.test.ts +14 -4
  38. package/src/workflows/feature/refactor.ts +2 -0
  39. package/src/workflows/feature/review-answers.feature +9 -1
  40. package/src/workflows/feature/review-gate.feature +21 -3
  41. package/src/workflows/feature/reviewer-walkthrough.feature +15 -3
  42. package/src/workflows/feature/seam-rejection.feature +1 -0
  43. package/src/workflows/feature/seam.feature +1 -0
  44. package/src/workflows/feature/seam.test.ts +3 -2
  45. package/src/workflows/feature/seam.ts +19 -5
  46. package/src/workflows/feature/severity-budget.feature +112 -0
  47. package/src/workflows/feature/severity-sorted-review.feature +198 -0
  48. package/src/workflows/feature/slice-findings.feature +15 -2
  49. package/src/workflows/feature/slicing-cap.feature +9 -0
  50. package/src/workflows/feature/slicing.feature +1 -0
  51. package/src/workflows/feature/standards.feature +5 -0
  52. package/src/workflows/shared/options.ts +1 -0
  53. package/src/workflows/shared/prose.ts +6 -0
  54. package/src/workflows/shared/review.ts +124 -18
  55. package/src/workflows/shared/skills.test.ts +4 -0
  56. package/src/workflows/shared/skills.ts +5 -0
  57. package/src/workflows/shared/steps.ts +1 -0
  58. package/src/workflows/shared/text.test.ts +90 -0
  59. package/src/workflows/shared/text.ts +181 -4
  60. package/src/workflows/shared/vars.ts +3 -0
@@ -70,10 +70,15 @@ interface Severity {
70
70
  readonly reason: string
71
71
  /** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
72
72
  readonly rank: 0 | 1 | 2
73
+ readonly security?: true
73
74
  }
74
75
 
75
76
  /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
76
- export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
77
+ export const severityOf = (
78
+ note: { readonly id: string; readonly forced?: boolean | undefined },
79
+ judgment: Judgment,
80
+ minP: number,
81
+ ): Severity => {
77
82
  const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
78
83
  const a = (q: string): string | undefined => ans(q)?.answer
79
84
  const rank = (): 0 | 1 | 2 =>
@@ -90,7 +95,12 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
90
95
  ...(a("reversibility") === "one-way" ? ["one-way door"] : []),
91
96
  ]
92
97
  if (critical.length > 0)
93
- return { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
98
+ return {
99
+ tier: "critical",
100
+ reason: `critical: ${critical.join(", ")}`,
101
+ rank: rank(),
102
+ ...(a("security") === "yes" ? { security: true as const } : {}),
103
+ }
94
104
  const should = [
95
105
  ...(a("blastRadius") === "contract" ? ["contract"] : []),
96
106
  ...(a("newConcept") === "yes" ? ["new concept"] : []),
@@ -100,31 +110,32 @@ export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number):
100
110
  return { tier: "could", reason: "could", rank: 2 }
101
111
  }
102
112
 
103
- const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
113
+ /** `asks` names the change in full, `change` refers back to it. */
114
+ const severityQuestions = (id: string, asks: string, change: string): JudgeQuestion[] => [
104
115
  {
105
- id: `${note.id}.security`,
116
+ id: `${id}.security`,
106
117
  primitive: "noul",
107
- instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
118
+ instructions: `Does ${asks} touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
108
119
  criteria: "yes: it changes or could weaken a security property. no: it does not.",
109
120
  },
110
121
  {
111
- id: `${note.id}.reversibility`,
122
+ id: `${id}.reversibility`,
112
123
  primitive: "choice",
113
- instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
124
+ instructions: `Once ${change} ships, can it be undone without breaking anyone who already depends on it?`,
114
125
  criteria:
115
126
  "one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
116
127
  },
117
128
  {
118
- id: `${note.id}.blastRadius`,
129
+ id: `${id}.blastRadius`,
119
130
  primitive: "choice",
120
- instructions: `How far does the change in note "${note.id}" reach?`,
131
+ instructions: `How far does ${change} reach?`,
121
132
  criteria:
122
133
  "local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
123
134
  },
124
135
  {
125
- id: `${note.id}.newConcept`,
136
+ id: `${id}.newConcept`,
126
137
  primitive: "noul",
127
- instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
138
+ instructions: `Does ${change} introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
128
139
  criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
129
140
  },
130
141
  ]
@@ -144,7 +155,14 @@ const triage =
144
155
  asked.length === 0
145
156
  ? { answers: {}, truncated: [] }
146
157
  : await judge("review.triage", {
147
- questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
158
+ questions: asked.flatMap((n) => [
159
+ verdictQuestion(n),
160
+ ...severityQuestions(
161
+ n.id,
162
+ `the change the human asks for in note "${n.id}" (on "${n.anchor}")`,
163
+ `the change in note "${n.id}"`,
164
+ ),
165
+ ]),
148
166
  evidence: Object.fromEntries(
149
167
  asked.map((n) => [
150
168
  n.id,
@@ -170,6 +188,91 @@ const triage =
170
188
  )
171
189
  }
172
190
 
191
+ /** Review order: security, one-way doors, the judge unsure, then should, then could. */
192
+ const chunkKey = (s: Severity): number =>
193
+ s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
194
+
195
+ type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
196
+
197
+ /** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
198
+ const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
199
+ [
200
+ text,
201
+ ...files.map(({ path, line, rangeEnd }) => {
202
+ const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
203
+ const lines = read(path.replace(/^\.\//, ""))?.split("\n")
204
+ if (lines === undefined) return `${where}: no longer exists`
205
+ const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
206
+ return `${where}:\n${code.join("\n")}`
207
+ }),
208
+ ].join("\n\n")
209
+
210
+ const bytes = (text: string): number => new TextEncoder().encode(text).length
211
+
212
+ /** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
213
+ const batched = <T extends { readonly evidence: string }>(
214
+ items: readonly T[],
215
+ budget: number,
216
+ ): T[][] => {
217
+ const batches: T[][] = []
218
+ let batch: T[] = []
219
+ let widest = 0
220
+ for (const item of items) {
221
+ const size = bytes(item.evidence)
222
+ if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
223
+ batches.push(batch)
224
+ batch = []
225
+ widest = 0
226
+ }
227
+ batch.push(item)
228
+ widest = Math.max(widest, size)
229
+ }
230
+ return batch.length === 0 ? batches : [...batches, batch]
231
+ }
232
+
233
+ /**
234
+ * Rates every chunk the reviewer wrote — a walkthrough chunk or a carried
235
+ * finding — and sorts \`full\` by it, each chunk led by its tier and reason.
236
+ * gtd's summary chunks are not rated and stay last.
237
+ */
238
+ const ranked = async (review: string, full: string): Promise<string> => {
239
+ const texts = t.chunkTexts(full)
240
+ const chunks = reviewChunks(review).map((c, i) => ({
241
+ id: `C${i + 1}`,
242
+ title: c.title,
243
+ evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
244
+ }))
245
+ if (chunks.length === 0) return full
246
+ const minP = numeric(vars.reviewSeverityMinP, Infinity)
247
+ const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
248
+ const severities = new Map<string, Severity>()
249
+ for (const [i, batch] of batches.entries()) {
250
+ const judgment = await judge("review.ranking", {
251
+ questions: batch.flatMap((c) =>
252
+ severityQuestions(
253
+ c.id,
254
+ `the change review chunk "${c.id}" ("${c.title}") walks through`,
255
+ `the change in review chunk "${c.id}"`,
256
+ ),
257
+ ),
258
+ evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
259
+ message: t.reviewRankingMessage(),
260
+ label:
261
+ batches.length === 1
262
+ ? "Ranking the review"
263
+ : `Ranking the review (${i + 1}/${batches.length})`,
264
+ })
265
+ for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
266
+ }
267
+ return t.rankedReview(
268
+ full,
269
+ chunks
270
+ .map((c, i) => ({ c, i, s: severities.get(c.id)! }))
271
+ .sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
272
+ .map(({ c, s }) => ({ title: c.title, label: s.reason })),
273
+ )
274
+ }
275
+
173
276
  export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
174
277
 
175
278
  const composedOf = (
@@ -263,18 +366,21 @@ const walkthrough = async (
263
366
  ): Promise<void> => {
264
367
  const notes = t.titledNotes(report)
265
368
  await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
266
- const review = read(REVIEW)
369
+ const written = read(REVIEW)
267
370
  // A review the reviewer did not write is the round's own refusal or rest.
268
- if (review === undefined) return
371
+ if (written === undefined) return
372
+ const review = t.unranked(written)
373
+ // Against the file as written, so a refusal's line numbers match it.
269
374
  requirePointers(
270
- review,
375
+ written,
271
376
  notes,
272
377
  changesSince(base).some((c) => isCode(c.path)),
273
378
  )
274
- const full = t.withWalkthrough(review, report)
275
- if (full !== review) {
379
+ // Ranked even when it marks risks: the review written after their fix reaches the human with its own.
380
+ const full = await ranked(review, t.withWalkthrough(review, report))
381
+ if (full !== written) {
276
382
  await run("review.carrying", writeScript(REVIEW, full), {
277
- label: "Adding the quality lap's findings",
383
+ label: "Ordering the review and adding the quality lap's findings",
278
384
  file: REVIEW,
279
385
  base,
280
386
  })
@@ -29,6 +29,10 @@ describe("the bundled workflow's skills export", () => {
29
29
  "implement",
30
30
  "implement.fix.suite",
31
31
  "implement.health",
32
+ "implement.prototype",
33
+ "implement.prototype.fix",
34
+ "implement.prototype.fix.suite",
35
+ "implement.prototype.health",
32
36
  "build.quality.correctness",
33
37
  "build.quality.owasp-security",
34
38
  "build.quality.ponytail-review",
@@ -33,6 +33,11 @@ export const skills = (
33
33
  seam: ["incremental-implementation"],
34
34
  implement: ["test-driven-development", "incremental-implementation"],
35
35
  "implement.fix.suite": ["debugging-and-error-recovery"],
36
+ // The hands-on tester runs the program, never the suite: no TDD.
37
+ "implement.prototype": [],
38
+ "implement.prototype.fix": ["test-driven-development", "incremental-implementation"],
39
+ "implement.prototype.fix.suite": ["debugging-and-error-recovery"],
40
+ "implement.prototype.health": ["debugging-and-error-recovery"],
36
41
  // `health` is shared code reached from both `implement` and `build`.
37
42
  "implement.health": ["debugging-and-error-recovery"],
38
43
  ...lensScopes(
@@ -16,6 +16,7 @@ export const REFACTOR_NOTE = ".gtd/REFACTOR-NOTE.md"
16
16
  export const QUALITY = ".gtd/QUALITY.md"
17
17
  export const SLICES = ".gtd/SLICES.md"
18
18
  export const HANDOFF = ".gtd/HANDOFF.md"
19
+ export const PROTOTYPE = ".gtd/PROTOTYPE.md"
19
20
 
20
21
  export const planner = (): string => env.plannerModel ?? ""
21
22
  export const coder = (): string => env.coderModel ?? ""
@@ -33,6 +33,8 @@ import {
33
33
  titledNotes,
34
34
  walkthroughBlock,
35
35
  withWalkthrough,
36
+ rankedReview,
37
+ unranked,
36
38
  } from "./text.js"
37
39
  import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
38
40
  import { captureStep, renderText } from "./text.fixture.js"
@@ -1517,3 +1519,91 @@ describe("walkthrough", () => {
1517
1519
  )
1518
1520
  })
1519
1521
  })
1522
+
1523
+ describe("rankedReview", () => {
1524
+ const review = [
1525
+ "# Review: abc1234",
1526
+ "",
1527
+ "<!-- base: abc1234 -->",
1528
+ "",
1529
+ "## Rename",
1530
+ "",
1531
+ "- [ ] ./a.ts#1-1",
1532
+ "",
1533
+ "## Token",
1534
+ "",
1535
+ "_could_",
1536
+ "",
1537
+ "Reads the token.",
1538
+ "",
1539
+ "- [ ] ./a.ts#2-2",
1540
+ "",
1541
+ "## Fixed automatically",
1542
+ "",
1543
+ "- x (abc1234)",
1544
+ "",
1545
+ ].join("\n")
1546
+
1547
+ it("puts the ranked chunks first, each led by its tier, and leaves the rest in place", () => {
1548
+ expect(
1549
+ rankedReview(unranked(review), [
1550
+ { title: "Token", label: "critical: security" },
1551
+ { title: "Rename", label: "could" },
1552
+ ]),
1553
+ ).toBe(
1554
+ [
1555
+ "# Review: abc1234",
1556
+ "",
1557
+ "<!-- base: abc1234 -->",
1558
+ "",
1559
+ "## Token",
1560
+ "",
1561
+ "_critical: security_",
1562
+ "",
1563
+ "Reads the token.",
1564
+ "",
1565
+ "- [ ] ./a.ts#2-2",
1566
+ "",
1567
+ "## Rename",
1568
+ "",
1569
+ "_could_",
1570
+ "",
1571
+ "- [ ] ./a.ts#1-1",
1572
+ "",
1573
+ "## Fixed automatically",
1574
+ "",
1575
+ "- x (abc1234)",
1576
+ "",
1577
+ ].join("\n"),
1578
+ )
1579
+ })
1580
+
1581
+ it("keeps a setext heading whole", () => {
1582
+ expect(
1583
+ rankedReview("# Review: a\n\nToken\n-----\n\n- [ ] ./a.ts#1\n", [
1584
+ { title: "Token", label: "could" },
1585
+ ]),
1586
+ ).toBe("# Review: a\n\nToken\n-----\n\n_could_\n\n- [ ] ./a.ts#1\n")
1587
+ })
1588
+
1589
+ it("keeps a chunk's leading indented code block indented", () => {
1590
+ const code = "# Review: abc1234\n\n## Code\n\n const x = 1\n\n- [ ] ./a.ts#1-1\n"
1591
+ expect(rankedReview(code, [{ title: "Code", label: "could" }])).toContain(
1592
+ "_could_\n\n const x = 1",
1593
+ )
1594
+ })
1595
+
1596
+ it("drops a stale tier with no blank line under the heading, never a longer paragraph", () => {
1597
+ expect(unranked("## A\n_could_\n\nbody\n")).toBe("## A\nbody\n")
1598
+ expect(unranked("## A\n\n_could_\n- [ ] ./a.ts#1\n")).toBe("## A\n\n- [ ] ./a.ts#1\n")
1599
+ const code = "## A\n\n _could_\n"
1600
+ expect(unranked(code)).toBe(code)
1601
+ const prose = "## A\n\n_should: x_\nruns on\n"
1602
+ expect(unranked(prose)).toBe(prose)
1603
+ })
1604
+
1605
+ it("drops a stale tier, so a re-review is ranked afresh", () => {
1606
+ expect(unranked(review)).not.toContain("_could_")
1607
+ expect(unranked(unranked(review))).toBe(unranked(review))
1608
+ })
1609
+ })
@@ -19,6 +19,7 @@ import {
19
19
  ANSWER_WONT_FIX,
20
20
  FREE_TEXT_PLACEHOLDER,
21
21
  reviewChunks,
22
+ severityLine,
22
23
  } from "../../steering/index.js"
23
24
  import { escapeRegExp } from "./registry.js"
24
25
  import {
@@ -40,6 +41,7 @@ import {
40
41
  reviewerPersona,
41
42
  builderPersona,
42
43
  finisherPersona,
44
+ testerPersona,
43
45
  escalationPersona,
44
46
  stateFileRules,
45
47
  questionBar,
@@ -301,12 +303,78 @@ export const withWalkthrough = (review: string, report: ComposedReview): string
301
303
  `## ${unique("Dropped as noise")}\n\n${report.noise.map((f) => noiseLine(f, carriedOnly(f))).join("\n")}`,
302
304
  ]),
303
305
  ]
304
- // Nothing to add leaves the review byte for byte, so no carrying step runs.
306
+ // Nothing to add leaves the review byte for byte.
305
307
  if (notes.size === listed && summary.length === 0) return review
306
308
  const body = lines.join("\n").trimEnd()
307
309
  return summary.length === 0 ? `${body}\n` : `${body}\n\n${summary.join("\n\n")}\n`
308
310
  }
309
311
 
312
+ export interface RankedChunk {
313
+ readonly title: string
314
+ readonly label: string
315
+ }
316
+
317
+ const chunkBlocks = (
318
+ review: string,
319
+ ): { head: string[]; blocks: { title: string; lines: string[] }[] } => {
320
+ const lines = review.split("\n")
321
+ const chunks = reviewChunks(review)
322
+ return {
323
+ head: lines.slice(0, chunks[0]?.line ?? lines.length),
324
+ blocks: chunks.map((c, i) => ({
325
+ title: c.title,
326
+ lines: lines.slice(c.line, chunks[i + 1]?.line ?? lines.length),
327
+ })),
328
+ }
329
+ }
330
+
331
+ const isBlank = (line: string | undefined): boolean => line !== undefined && line.trim() === ""
332
+
333
+ /** \`review\` with every chunk's severity line removed, so a re-review is ranked afresh. */
334
+ export const unranked = (review: string): string => {
335
+ const drop = new Set(reviewChunks(review).flatMap((c) => c.severityLine ?? []))
336
+ if (drop.size === 0) return review
337
+ const lines = review.split("\n")
338
+ return lines.filter((_, i) => !drop.has(i) && !(drop.has(i - 1) && isBlank(lines[i]))).join("\n")
339
+ }
340
+
341
+ export const chunkTexts = (review: string): ReadonlyMap<string, string> =>
342
+ new Map(chunkBlocks(review).blocks.map((b) => [b.title, b.lines.join("\n").trim()]))
343
+
344
+ // Blank lines only: a chunk opening with an indented code block keeps its indent.
345
+ const trimBlankLines = (lines: readonly string[]): string[] => {
346
+ let start = 0
347
+ let end = lines.length
348
+ while (start < end && isBlank(lines[start])) start++
349
+ while (end > start && isBlank(lines[end - 1])) end--
350
+ return lines.slice(start, end)
351
+ }
352
+
353
+ /**
354
+ * \`review\` with the \`ranked\` chunks first, in their order, each led by its
355
+ * severity line; every other chunk follows in place.
356
+ */
357
+ export const rankedReview = (review: string, ranked: readonly RankedChunk[]): string => {
358
+ const { head, blocks } = chunkBlocks(review)
359
+ const place = new Map(ranked.map((r, k) => [r.title, k]))
360
+ const labels = new Map(ranked.map((r) => [r.title, r.label]))
361
+ const order = (i: number): number => place.get(blocks[i]!.title) ?? ranked.length
362
+ const chunk = (i: number): string => {
363
+ const { title, lines } = blocks[i]!
364
+ const label = labels.get(title)
365
+ // A setext heading is its title line and its underline.
366
+ const heading = /^ {0,3}-+\s*$/.test(lines[1] ?? "") ? 2 : 1
367
+ const body = trimBlankLines(lines.slice(heading)).join("\n")
368
+ return [
369
+ lines.slice(0, heading).join("\n"),
370
+ ...(label === undefined ? [] : [severityLine(label)]),
371
+ ...(body === "" ? [] : [body]),
372
+ ].join("\n\n")
373
+ }
374
+ const sorted = blocks.map((_, i) => i).sort((x, y) => order(x) - order(y) || x - y)
375
+ return `${[head.join("\n").trim(), ...sorted.map(chunk)].filter((p) => p !== "").join("\n\n")}\n`
376
+ }
377
+
310
378
  /**
311
379
  * \`.gtd/REVIEW.md\` as gtd writes it: one chunk per note, each ending in its
312
380
  * answer boxes, then the fixed and noise lists. Chunk titles are made unique —
@@ -418,8 +486,11 @@ const acceptanceCriteriaBlock = `
418
486
  Whichever lap this is, \`.gtd/REQUIREMENTS.md\` carries a
419
487
  \`## Acceptance Criteria\` section: one \`- AC-<n>: <criterion>\` line
420
488
  per criterion, numbered from \`AC-1\`. They bind every later step —
421
- the build is done when each one holds — so each must be checkable: a
422
- test or a command shows it holds or not. A later lap keeps every ID:
489
+ the build is done when each one holds — so each must be behaviour a
490
+ test can pin: one that fails when it does not hold. A green suite, a
491
+ passing typecheck or lint, a generator or build that runs — the health
492
+ check enforces those on every lap, so none is a criterion; fold what it
493
+ must produce into a behaviour a test can observe. A later lap keeps every ID:
423
494
  reword a criterion in place, append a new one with the next unused
424
495
  number, delete a dropped one — never renumber, never reuse an ID. When
425
496
  the file holds no list (a review loop-back rewrites it), recover the
@@ -533,7 +604,7 @@ export const clearCriteriaQuestion: JudgeQuestion = {
533
604
  instructions:
534
605
  "state.criteria lists the acceptance criteria the human's sketch (state.sketch) stated, as gtd numbered them. Decide whether they can stand as the binding definition of done, with no design conversation.",
535
606
  criteria:
536
- "yes: every criterion is clear and checkable — a test or a command can show it holds or not — and together they say what to build. no: any one criterion is vague, subjective or untestable, or the sketch leaves an open product question. Unsure means no.",
607
+ "yes: every criterion is clear behaviour a test can pin — a test would fail if it did not hold — and together they say what to build. no: any one criterion is vague, subjective or untestable, only a command can check it (a suite, typecheck, lint, build or generator run that passes), or the sketch leaves an open product question. Unsure means no.",
537
608
  }
538
609
 
539
610
  export const clearCriteriaMessage = (): string =>
@@ -1074,6 +1145,15 @@ const wrapItem = (item: string): string[] => {
1074
1145
  return [...out, line]
1075
1146
  }
1076
1147
 
1148
+ export const reviewRankingMessage = (): string =>
1149
+ `Ranking each chunk of the review before the human reads it. Run
1150
+ \`gtd judge answer\` and pipe, per chunk \`C<n>\`: \`C<n>.security\`
1151
+ (yes/no), \`C<n>.reversibility\` (one-way/two-way),
1152
+ \`C<n>.blastRadius\` (local/module/contract) and \`C<n>.newConcept\`
1153
+ (yes/no) — or land without a verdict, which lists every chunk as
1154
+ critical.
1155
+ `
1156
+
1077
1157
  export const buildReviewTriageMessage = (): string =>
1078
1158
  `Judging each note the human added to \`.gtd/REVIEW.md\` (a line note, a
1079
1159
  footnote, or prose under a chunk) with one verdict: \`edit\` (a change
@@ -1904,3 +1984,100 @@ Two ways forward (then run \`gtd land\`):
1904
1984
  - **Overrule** — land a clean tree to finish without them. The slice records
1905
1985
  the IDs it covers and, as \`uncovered\`, the ones it does not
1906
1986
  `
1987
+
1988
+ export const testerSystem = (): string =>
1989
+ `${testerPersona}
1990
+
1991
+ ${agentConduct}`
1992
+
1993
+ const PROTOTYPE_FORMAT = `- Write \`.gtd/PROTOTYPE.md\`, replacing any earlier report: one \`## \` chunk
1994
+ per criterion, headed \`## AC-<n>: pass\`, \`## AC-<n>: fail\` or
1995
+ \`## AC-<n>: blocked\`, then one chunk per problem you found outside any
1996
+ criterion, headed \`## Problem <short title>: fail\` (\`: pass\` once a
1997
+ re-test shows it fixed). Under each: the exact steps you ran, what you
1998
+ observed (output, a screenshot path, the error), and for a failure what
1999
+ you expected instead
2000
+ - \`blocked\` is for a criterion you could not exercise, or one whose
2001
+ expected behaviour itself looks wrong — say which and why: a person
2002
+ decides it, never a fixer`
2003
+
2004
+ export const prototypeTestingPrompt = (
2005
+ slice: readonly string[] | undefined,
2006
+ retest: boolean,
2007
+ ): string =>
2008
+ `${stateFileRules}
2009
+ - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
2010
+ ${sliceLine(slice)}- The change is built and its suite is green. Now test it by hand: use
2011
+ every acceptance criterion the way a person would and watch what
2012
+ happens. Running the test suite is not this — it already ran
2013
+ - Find how this project is exercised for real, in this order: a project
2014
+ skill that launches or drives it (e.g. \`.claude/skills/*\`); a manual
2015
+ testing or running section in \`AGENTS.md\`, \`CLAUDE.md\` or \`README.md\`;
2016
+ failing those, the project's shape — a browser or Playwright for a web
2017
+ project, the built CLI in a scratch directory for a CLI, a request client
2018
+ for an API
2019
+ - Found none and recognised no shape: write only a chunk headed
2020
+ \`## No testing instructions\` saying what you looked for, and finish —
2021
+ a person is asked; never guess
2022
+ - Test in a disposable place only — a scratch directory outside this
2023
+ repository, a throwaway repository, a dev server on a free port — never
2024
+ against the user's real data or this working tree. Point every tool's
2025
+ output (screenshots, traces, test results) outside the repository too
2026
+ ${
2027
+ retest
2028
+ ? `- This is a re-test: \`.gtd/PROTOTYPE.md\` holds the last round's report and
2029
+ a fix turn or a person has changed the code since. Re-test every failed or blocked criterion and
2030
+ every problem it lists in full, then re-run the passed ones quickly
2031
+ `
2032
+ : ""
2033
+ }${PROTOTYPE_FORMAT}
2034
+ - Change nothing but \`.gtd/PROTOTYPE.md\` — a turn that does is refused
2035
+ - Leave everything uncommitted and finish your turn — do not commit
2036
+ `
2037
+
2038
+ export const prototypeFixingPrompt = (slice: readonly string[] | undefined): string =>
2039
+ `${stateFileRules}
2040
+ - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
2041
+ ${sliceLine(slice)}${criteriaBinding(true)}
2042
+ - Read \`.gtd/PROTOTYPE.md\`: a hands-on test of the change. Fix every
2043
+ \`fail\` chunk of this change's criteria${slice === undefined ? "" : " (this slice's only)"} or a problem found
2044
+ beside them, at its cause, with a test that shows it; leave \`blocked\`
2045
+ ones to the person
2046
+ - A \`fail\` the code does not explain (the tester misread what it saw) has
2047
+ nothing to fix: leave it and change nothing for it — the next round re-tests
2048
+ - Leave \`.gtd/PROTOTYPE.md\` as it is: the next round re-tests from it
2049
+ - Run \`${env.testCommand}\` and finish only once it is green
2050
+ - Leave everything uncommitted and finish your turn — do not commit
2051
+ `
2052
+
2053
+ const openItems = (items: readonly { subject: string; verdict: string }[]): string =>
2054
+ items.map((item) => `- ${item.subject}: ${item.verdict}`).join("\n")
2055
+
2056
+ export const prototypeOpenMessage = (
2057
+ items: readonly { subject: string; verdict: string }[],
2058
+ rounds: number,
2059
+ ): string =>
2060
+ `The hands-on test of this slice still has open items${rounds > 0 ? ` after ${rounds} fix round${rounds === 1 ? "" : "s"}` : ", and none a fixer can take"}:
2061
+ ${openItems(items)}
2062
+
2063
+ \`.gtd/PROTOTYPE.md\` holds the last report: the steps run and what was seen.
2064
+
2065
+ Two ways forward (then run \`gtd land\`):
2066
+ - **Fix** — change the code, or the testing instructions, yourself (the
2067
+ criteria are settled: an edit to them is refused); the suite runs and the
2068
+ slice is tested again, with a fresh round budget
2069
+ - **Overrule** — land a clean tree to go on to the quality lap with these
2070
+ items open
2071
+ `
2072
+
2073
+ export const prototypeUnguidedMessage = (): string =>
2074
+ `The hands-on test found no way to exercise this project for real — no
2075
+ project skill, no manual testing section in \`AGENTS.md\`, \`CLAUDE.md\` or
2076
+ \`README.md\`, and no project shape it recognised. \`.gtd/PROTOTYPE.md\` says
2077
+ what it looked for.
2078
+
2079
+ Two ways forward (then run \`gtd land\`):
2080
+ - **Instruct** — add testing instructions (a project skill, or a manual
2081
+ testing section); the slice is tested again
2082
+ - **Skip** — land a clean tree to go on to the quality lap untested
2083
+ `
@@ -41,6 +41,9 @@ export const defaults: Readonly<Record<string, string>> = {
41
41
  // stopped at its context limit before a person is asked, and how many in a
42
42
  // row may close nothing still open.
43
43
  continuations: "3",
44
+ // Fix-and-retest rounds of a slice's prototype test before a person is
45
+ // asked; "0" skips the test.
46
+ prototypeRounds: "3",
44
47
  continuationStall: "2",
45
48
  }
46
49