@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +16 -3
- package/dist/chunks/{reserved-CYZ1bSBy.mjs → reserved-CUkk9XgL.mjs} +151 -5
- package/dist/chunks/{review-BewyJflS.mjs → review-BaCXlIT2.mjs} +84 -20
- package/dist/chunks/{steering-BXwjmocB.mjs → steering-DtUVUz9l.mjs} +44 -14
- package/dist/chunks/{tail-C1Oi2YEy.mjs → tail-CX6Pdsql.mjs} +1 -1
- package/dist/gtd.bundle.mjs +22 -5
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1 -1
- package/dist/web/main.js +27 -3
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +144 -11
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +3 -3
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +415 -42
- package/package.json +1 -1
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +60 -2
- package/src/steering/review.ts +66 -9
- package/src/workflows/feature/coverage.feature +1 -0
- package/src/workflows/feature/fast-path.feature +1 -0
- package/src/workflows/feature/feature.ts +4 -1
- package/src/workflows/feature/flag-mode.feature +1 -1
- package/src/workflows/feature/lens-models.feature +1 -0
- package/src/workflows/feature/lens-review.feature +17 -4
- package/src/workflows/feature/one-step-land.feature +1 -0
- package/src/workflows/feature/prototype-rounds.feature +152 -0
- package/src/workflows/feature/prototype.feature +419 -0
- package/src/workflows/feature/prototype.test.ts +43 -0
- package/src/workflows/feature/prototype.ts +181 -0
- package/src/workflows/feature/refactor-absorbs.feature +9 -1
- package/src/workflows/feature/refactor-first.feature +13 -3
- package/src/workflows/feature/refactor-rebuild.feature +29 -5
- package/src/workflows/feature/refactor-review.feature +111 -13
- package/src/workflows/feature/refactor.test.ts +14 -4
- package/src/workflows/feature/refactor.ts +2 -0
- package/src/workflows/feature/review-answers.feature +9 -1
- package/src/workflows/feature/review-gate.feature +21 -3
- package/src/workflows/feature/reviewer-walkthrough.feature +15 -3
- package/src/workflows/feature/seam-rejection.feature +1 -0
- package/src/workflows/feature/seam.feature +1 -0
- package/src/workflows/feature/seam.test.ts +3 -2
- package/src/workflows/feature/seam.ts +19 -5
- package/src/workflows/feature/severity-budget.feature +112 -0
- package/src/workflows/feature/severity-sorted-review.feature +198 -0
- package/src/workflows/feature/slice-findings.feature +15 -2
- package/src/workflows/feature/slicing-cap.feature +9 -0
- package/src/workflows/feature/slicing.feature +1 -0
- package/src/workflows/feature/standards.feature +5 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/prose.ts +6 -0
- package/src/workflows/shared/review.ts +124 -18
- package/src/workflows/shared/skills.test.ts +4 -0
- package/src/workflows/shared/skills.ts +5 -0
- package/src/workflows/shared/steps.ts +1 -0
- package/src/workflows/shared/text.test.ts +90 -0
- package/src/workflows/shared/text.ts +181 -4
- 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 = (
|
|
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 {
|
|
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
|
-
|
|
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: `${
|
|
116
|
+
id: `${id}.security`,
|
|
106
117
|
primitive: "noul",
|
|
107
|
-
instructions: `Does
|
|
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: `${
|
|
122
|
+
id: `${id}.reversibility`,
|
|
112
123
|
primitive: "choice",
|
|
113
|
-
instructions: `Once
|
|
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: `${
|
|
129
|
+
id: `${id}.blastRadius`,
|
|
119
130
|
primitive: "choice",
|
|
120
|
-
instructions: `How far does
|
|
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: `${
|
|
136
|
+
id: `${id}.newConcept`,
|
|
126
137
|
primitive: "noul",
|
|
127
|
-
instructions: `Does
|
|
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) => [
|
|
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
|
|
369
|
+
const written = read(REVIEW)
|
|
267
370
|
// A review the reviewer did not write is the round's own refusal or rest.
|
|
268
|
-
if (
|
|
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
|
-
|
|
375
|
+
written,
|
|
271
376
|
notes,
|
|
272
377
|
changesSince(base).some((c) => isCode(c.path)),
|
|
273
378
|
)
|
|
274
|
-
|
|
275
|
-
|
|
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: "
|
|
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
|
|
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
|
|
422
|
-
test
|
|
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
|
|
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
|
|