@pmelab/gtd 54.0.0 → 56.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 +109 -104
- package/dist/chunks/{reserved-CQ7amcKG.mjs → reserved-B8EC-WE8.mjs} +124 -230
- package/dist/chunks/{review-CXzUXJHL.mjs → review-HdrX0FJ7.mjs} +30 -125
- package/dist/chunks/{steering-CLhu8C7q.mjs → steering-CCNvngSH.mjs} +256 -65
- package/dist/chunks/{tail-JZ9PAOwp.mjs → tail-BSLah2mA.mjs} +155 -63
- package/dist/gtd.bundle.mjs +259 -22
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +6 -6
- package/dist/web/main.css +1 -1
- package/dist/web/main.js +551 -66
- package/dist/workflow-kit.mjs +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +47 -102
- 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 +614 -574
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +3 -3
- package/src/cli/Cli.ts +5 -5
- package/src/edge/Edge.test.ts +1 -1
- package/src/steering/FORMATS.md +5 -1
- package/src/steering/SteeringFormats.test.ts +2 -2
- package/src/steering/contract.test.ts +1 -0
- package/src/steering/findings.test.ts +47 -0
- package/src/steering/findings.ts +350 -0
- package/src/steering/fixtures/findings/gate.md +34 -0
- package/src/steering/fixtures/findings/gate.read.json +87 -0
- package/src/steering/index.test.ts +2 -2
- package/src/steering/index.ts +4 -3
- package/src/steering/review.test.ts +1 -75
- package/src/steering/review.ts +1 -74
- package/src/workflow/compile.test.ts +1 -0
- package/src/workflows/feature/criteria-coverage.feature +104 -0
- package/src/workflows/feature/feature.test.ts +1 -1
- package/src/workflows/feature/feature.ts +17 -20
- package/src/workflows/feature/green.ts +2 -5
- package/src/workflows/feature/lens-review.feature +323 -171
- package/src/workflows/feature/naive-acceptance.feature +31 -32
- package/src/workflows/feature/prototype.ts +5 -8
- package/src/workflows/feature/refactor-absorbs.feature +1 -7
- package/src/workflows/feature/refactor-first.feature +2 -10
- package/src/workflows/feature/refactor-rebuild.feature +20 -36
- package/src/workflows/feature/refactor-review.feature +33 -117
- package/src/workflows/feature/refactor.test.ts +0 -2
- package/src/workflows/feature/refactor.ts +0 -1
- package/src/workflows/feature/review-gate.feature +6 -22
- package/src/workflows/feature/reviewer-walkthrough.feature +16 -103
- package/src/workflows/feature/seam.test.ts +19 -101
- package/src/workflows/feature/seam.ts +13 -89
- package/src/workflows/feature/standards.feature +75 -34
- package/src/workflows/fix/fix-lens-origin.feature +20 -7
- package/src/workflows/fix/fix-quality.feature +104 -23
- package/src/workflows/review/review-door.feature +62 -59
- package/src/workflows/shared/access.test.ts +1 -0
- package/src/workflows/shared/learn.test.ts +0 -14
- package/src/workflows/shared/learn.ts +1 -6
- package/src/workflows/shared/lenses.ts +149 -43
- package/src/workflows/shared/review.test.ts +30 -27
- package/src/workflows/shared/review.ts +29 -196
- package/src/workflows/shared/skills.test.ts +3 -1
- package/src/workflows/shared/steps.test.ts +9 -6
- package/src/workflows/shared/steps.ts +12 -3
- package/src/workflows/shared/tail.test.ts +20 -2
- package/src/workflows/shared/tail.ts +38 -16
- package/src/workflows/shared/text.test.ts +122 -294
- package/src/workflows/shared/text.ts +181 -358
- package/src/workflows/shared/vars.ts +1 -1
- package/src/workflows/feature/review-answers.feature +0 -324
- package/src/workflows/feature/severity-budget.feature +0 -116
- package/src/workflows/feature/severity-sorted-review.feature +0 -198
|
@@ -9,7 +9,6 @@ import {
|
|
|
9
9
|
requireRevert,
|
|
10
10
|
restoreScript,
|
|
11
11
|
run,
|
|
12
|
-
showScript,
|
|
13
12
|
vars,
|
|
14
13
|
writeScript,
|
|
15
14
|
type JudgeQuestion,
|
|
@@ -21,7 +20,6 @@ import {
|
|
|
21
20
|
reviewChunks,
|
|
22
21
|
reviewRisks,
|
|
23
22
|
reviewSections,
|
|
24
|
-
sectionedReview,
|
|
25
23
|
type ReviewNote,
|
|
26
24
|
} from "../../steering/index.js"
|
|
27
25
|
import * as kit from "../../workflow-kit/index.js"
|
|
@@ -195,214 +193,59 @@ const triage =
|
|
|
195
193
|
)
|
|
196
194
|
}
|
|
197
195
|
|
|
198
|
-
/** Review order: security, one-way doors, the judge unsure, then should, then could. */
|
|
199
|
-
const chunkKey = (s: Severity): number =>
|
|
200
|
-
s.tier === "could" ? 4 : s.tier === "should" ? 3 : s.security === true ? 0 : s.rank === 0 ? 1 : 2
|
|
201
|
-
|
|
202
|
-
type Pointer = ReturnType<typeof reviewChunks>[number]["files"][number]
|
|
203
|
-
|
|
204
|
-
/** A chunk's text, then the code each of its pointers covers: a bare path the whole file. */
|
|
205
|
-
const chunkEvidence = (text: string, files: readonly Pointer[]): string =>
|
|
206
|
-
[
|
|
207
|
-
text,
|
|
208
|
-
...files.map(({ path, line, rangeEnd }) => {
|
|
209
|
-
const where = `${path}${line === undefined ? "" : `#${line}${rangeEnd === undefined ? "" : `-${rangeEnd}`}`}`
|
|
210
|
-
const lines = read(path.replace(/^\.\//, ""))?.split("\n")
|
|
211
|
-
if (lines === undefined) return `${where}: no longer exists`
|
|
212
|
-
const code = line === undefined ? lines : lines.slice(line - 1, rangeEnd ?? line)
|
|
213
|
-
return `${where}:\n${code.join("\n")}`
|
|
214
|
-
}),
|
|
215
|
-
].join("\n\n")
|
|
216
|
-
|
|
217
|
-
const bytes = (text: string): number => new TextEncoder().encode(text).length
|
|
218
|
-
|
|
219
|
-
/** Fills each batch while the budget's even share still holds its widest evidence, so nothing is cut that fits alone. */
|
|
220
|
-
const batched = <T extends { readonly evidence: string }>(
|
|
221
|
-
items: readonly T[],
|
|
222
|
-
budget: number,
|
|
223
|
-
): T[][] => {
|
|
224
|
-
const batches: T[][] = []
|
|
225
|
-
let batch: T[] = []
|
|
226
|
-
let widest = 0
|
|
227
|
-
for (const item of items) {
|
|
228
|
-
const size = bytes(item.evidence)
|
|
229
|
-
if (batch.length > 0 && Math.floor(budget / (batch.length + 1)) < Math.max(widest, size)) {
|
|
230
|
-
batches.push(batch)
|
|
231
|
-
batch = []
|
|
232
|
-
widest = 0
|
|
233
|
-
}
|
|
234
|
-
batch.push(item)
|
|
235
|
-
widest = Math.max(widest, size)
|
|
236
|
-
}
|
|
237
|
-
return batch.length === 0 ? batches : [...batches, batch]
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
/**
|
|
241
|
-
* Rates every chunk the reviewer wrote and sorts the \`findings\` among them
|
|
242
|
-
* first in \`full\`, each led by its tier and reason; a walkthrough chunk
|
|
243
|
-
* keeps its place and carries no tier. It is still rated, so a review with no
|
|
244
|
-
* findings still reaches \`review.ranking\` and older histories replay.
|
|
245
|
-
*/
|
|
246
|
-
const ranked = async (
|
|
247
|
-
review: string,
|
|
248
|
-
full: string,
|
|
249
|
-
findings: ReadonlySet<string>,
|
|
250
|
-
): Promise<string> => {
|
|
251
|
-
const texts = t.chunkTexts(full)
|
|
252
|
-
const chunks = reviewChunks(review).map((c, i) => ({
|
|
253
|
-
id: `C${i + 1}`,
|
|
254
|
-
title: c.title,
|
|
255
|
-
evidence: chunkEvidence(texts.get(c.title) ?? "", c.files),
|
|
256
|
-
}))
|
|
257
|
-
if (chunks.length === 0) return full
|
|
258
|
-
const minP = numeric(vars.reviewSeverityMinP, Infinity)
|
|
259
|
-
const batches = batched(chunks, numeric(vars.judgeBudgetBytes, 32768))
|
|
260
|
-
const severities = new Map<string, Severity>()
|
|
261
|
-
for (const [i, batch] of batches.entries()) {
|
|
262
|
-
const judgment = await judge("review.ranking", {
|
|
263
|
-
questions: batch.flatMap((c) =>
|
|
264
|
-
severityQuestions(
|
|
265
|
-
c.id,
|
|
266
|
-
`the change review chunk "${c.id}" ("${c.title}") walks through`,
|
|
267
|
-
`the change in review chunk "${c.id}"`,
|
|
268
|
-
),
|
|
269
|
-
),
|
|
270
|
-
evidence: Object.fromEntries(batch.map((c) => [c.id, c.evidence])),
|
|
271
|
-
message: t.reviewRankingMessage(),
|
|
272
|
-
label:
|
|
273
|
-
batches.length === 1
|
|
274
|
-
? "Ranking the review"
|
|
275
|
-
: `Ranking the review (${i + 1}/${batches.length})`,
|
|
276
|
-
})
|
|
277
|
-
for (const c of batch) severities.set(c.id, severityOf(c, judgment, minP))
|
|
278
|
-
}
|
|
279
|
-
return t.rankedReview(
|
|
280
|
-
full,
|
|
281
|
-
chunks
|
|
282
|
-
.map((c, i) => ({ c, i, s: severities.get(c.id)! }))
|
|
283
|
-
.filter(({ c }) => findings.has(c.title))
|
|
284
|
-
.sort((x, y) => chunkKey(x.s) - chunkKey(y.s) || x.i - y.i)
|
|
285
|
-
.map(({ c, s }) => ({ title: c.title, label: s.reason })),
|
|
286
|
-
)
|
|
287
|
-
}
|
|
288
|
-
|
|
289
196
|
export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
290
197
|
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
report: LensReport,
|
|
294
|
-
nitFix: string | undefined,
|
|
295
|
-
answered: ReadonlySet<string>,
|
|
296
|
-
): t.ComposedReview => ({
|
|
297
|
-
...report,
|
|
298
|
-
nitFix,
|
|
299
|
-
answered,
|
|
300
|
-
standards: {
|
|
301
|
-
added: standardsAddedSince(base),
|
|
302
|
-
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
303
|
-
},
|
|
304
|
-
})
|
|
305
|
-
|
|
306
|
-
/**
|
|
307
|
-
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
308
|
-
* turn. A re-review
|
|
309
|
-
* after fixed nits restores the doc as committed at `carry`, answers kept;
|
|
310
|
-
* a fresh one leaves out the notes the human already answered.
|
|
311
|
-
*/
|
|
312
|
-
const composing = (
|
|
313
|
-
base: string,
|
|
314
|
-
report: LensReport,
|
|
315
|
-
carry?: string,
|
|
316
|
-
nitFix?: string,
|
|
317
|
-
answered: ReadonlySet<string> = new Set(),
|
|
318
|
-
): Promise<void> =>
|
|
319
|
-
run(
|
|
320
|
-
"review.composing",
|
|
321
|
-
carry === undefined
|
|
322
|
-
? writeScript(
|
|
323
|
-
REVIEW,
|
|
324
|
-
t.composedReview(base, {
|
|
325
|
-
...composedOf(base, report, nitFix, answered),
|
|
326
|
-
weakened: kit.removedAssertions(changesSince(base)),
|
|
327
|
-
}),
|
|
328
|
-
)
|
|
329
|
-
: showScript(carry, REVIEW, REVIEW),
|
|
330
|
-
{ label: "Composing the review", file: REVIEW, base },
|
|
331
|
-
)
|
|
332
|
-
|
|
333
|
-
/**
|
|
334
|
-
* Refuses a review with a chunk pointing at no code, or a lap note without a
|
|
335
|
-
* chunk of its own. A range with no code change has nothing to point at, so
|
|
336
|
-
* there only a note's chunk needs a pointer.
|
|
337
|
-
*/
|
|
338
|
-
const requirePointers = (
|
|
339
|
-
review: string,
|
|
340
|
-
notes: readonly { title: string }[],
|
|
341
|
-
codeChanged: boolean,
|
|
342
|
-
): void => {
|
|
198
|
+
/** Refuses a review with a chunk pointing at no code; a range with no code change has nothing to point at. */
|
|
199
|
+
const requirePointers = (review: string, codeChanged: boolean): void => {
|
|
343
200
|
const chunks = reviewChunks(review)
|
|
344
|
-
const titles = new Set(chunks.map((c) => c.title))
|
|
345
|
-
const noted = new Set(notes.map((n) => n.title))
|
|
346
201
|
const problems = [
|
|
347
202
|
...chunks
|
|
348
|
-
.filter((c) => c.pointers === 0 &&
|
|
203
|
+
.filter((c) => c.pointers === 0 && codeChanged)
|
|
349
204
|
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — points at no code`),
|
|
350
205
|
...chunks
|
|
351
206
|
.filter((c) => isConflictTitle(c.title))
|
|
352
207
|
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — gtd writes conflicts; leave it out`),
|
|
353
|
-
...notes.filter((n) => !titles.has(n.title)).map((n) => ` - ${n.title} — no chunk of its own`),
|
|
354
208
|
// Notes and answers are matched to their chunk by title.
|
|
355
209
|
...chunks
|
|
356
210
|
.filter((c, i) => chunks.findIndex((d) => d.title === c.title) !== i)
|
|
357
211
|
.map((c) => ` - ${REVIEW}:${c.line + 1}: ${c.title} — a second chunk with this title`),
|
|
358
212
|
...reviewSections(review).map(
|
|
359
213
|
(s) =>
|
|
360
|
-
` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} —
|
|
214
|
+
` - ${REVIEW}:${s.line + 1}: ${REVIEW_SECTIONS[s.section]} — write \`## \` chunks only`,
|
|
361
215
|
),
|
|
362
|
-
// A risk is fixed before the human sees it; a note is the human's to decide.
|
|
363
|
-
...reviewRisks(review)
|
|
364
|
-
.filter((r) => [...noted].some((title) => r.anchor.startsWith(`${title} ./`)))
|
|
365
|
-
.map((r) => ` - ${r.anchor} — no \`Risk:\` in a quality finding's chunk`),
|
|
366
216
|
]
|
|
367
217
|
if (problems.length === 0) return
|
|
368
218
|
refuse(
|
|
369
|
-
`gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code
|
|
219
|
+
`gtd land: review-pointers: ${problems.length} problem(s) in ${REVIEW} — every chunk needs a pointer to its code:\n${problems.join("\n")}`,
|
|
370
220
|
)
|
|
371
221
|
}
|
|
372
222
|
|
|
373
223
|
/**
|
|
374
224
|
* A reviewer walks the human through everything since `base`, every chunk
|
|
375
|
-
* pointing at its code
|
|
376
|
-
* then adds each note's text and answer boxes, and the summary chunks.
|
|
225
|
+
* pointing at its code; gtd then appends the summary chunks.
|
|
377
226
|
*/
|
|
378
227
|
const walkthrough = async (
|
|
379
228
|
base: string,
|
|
380
|
-
|
|
229
|
+
summary: t.ReviewSummary,
|
|
381
230
|
lap: t.ReviewRange,
|
|
382
231
|
carry: string | undefined,
|
|
232
|
+
nitFix: string | undefined,
|
|
383
233
|
afterRiskFix: boolean,
|
|
384
234
|
): Promise<void> => {
|
|
385
|
-
|
|
386
|
-
await reviewing(base, carry, lap, t.walkthroughBlock(notes, report.nitFix))
|
|
235
|
+
await reviewing(base, carry, lap, t.nitFixBlock(nitFix))
|
|
387
236
|
const written = read(REVIEW)
|
|
388
237
|
// A review the reviewer did not write is the round's own refusal or rest.
|
|
389
238
|
if (written === undefined) return
|
|
390
|
-
const review = t.unranked(written)
|
|
391
|
-
// Against the file as written, so a refusal's line numbers match it.
|
|
392
239
|
requirePointers(
|
|
393
240
|
written,
|
|
394
|
-
notes,
|
|
395
241
|
changesSince(base).some((c) => isCode(c.path)),
|
|
396
242
|
)
|
|
397
|
-
// Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's
|
|
243
|
+
// Its risks are fixed and the review written again, so the re-reviewer edits this draft, never gtd's additions.
|
|
398
244
|
if (!afterRiskFix && reviewRisks(written).length > 0) return
|
|
399
|
-
const full =
|
|
400
|
-
await ranked(review, t.withWalkthrough(review, report), new Set(notes.map((n) => n.title))),
|
|
401
|
-
new Set(reviewChunks(review).map((c) => c.title)),
|
|
402
|
-
)
|
|
245
|
+
const full = t.withSummary(written, summary)
|
|
403
246
|
if (full !== written) {
|
|
404
247
|
await run("review.carrying", writeScript(REVIEW, full), {
|
|
405
|
-
label: "
|
|
248
|
+
label: "Adding the quality lap's summary",
|
|
406
249
|
file: REVIEW,
|
|
407
250
|
base,
|
|
408
251
|
})
|
|
@@ -410,12 +253,10 @@ const walkthrough = async (
|
|
|
410
253
|
}
|
|
411
254
|
|
|
412
255
|
/**
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
*
|
|
417
|
-
* caller to build, questions are answered in place, nits fixed in one batch,
|
|
418
|
-
* praise dropped.
|
|
256
|
+
* A reviewer's walkthrough of everything since `base` reaches a human, who
|
|
257
|
+
* may comment: each note is judged — edits end the review as `feedback` for
|
|
258
|
+
* the caller to build, questions are answered in place, nits fixed in one
|
|
259
|
+
* batch, praise dropped.
|
|
419
260
|
*/
|
|
420
261
|
export const humanReview = (
|
|
421
262
|
base: string,
|
|
@@ -432,8 +273,6 @@ export const humanReview = (
|
|
|
432
273
|
const severities = new Map<string, Severity>()
|
|
433
274
|
// Set by a nit fix, so the fresh review that follows names its commit.
|
|
434
275
|
let nitFix: string | undefined
|
|
435
|
-
// Every note the human answered in this review, left out of a fresh one.
|
|
436
|
-
const answered = new Set<string>()
|
|
437
276
|
// Set by a risk fix, so the review written next reaches the human whatever it marks.
|
|
438
277
|
let riskFixed = false
|
|
439
278
|
return kit.review(base, {
|
|
@@ -444,20 +283,17 @@ export const humanReview = (
|
|
|
444
283
|
const fixedAt = nitFix
|
|
445
284
|
const afterRiskFix = riskFixed
|
|
446
285
|
riskFixed = false
|
|
447
|
-
const
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
guard,
|
|
459
|
-
)()
|
|
460
|
-
: composing(b, report, carry, fixedAt, answered)
|
|
286
|
+
const summary: t.ReviewSummary = {
|
|
287
|
+
report: options.report ?? NO_FINDINGS,
|
|
288
|
+
standards: {
|
|
289
|
+
added: standardsAddedSince(b),
|
|
290
|
+
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
291
|
+
},
|
|
292
|
+
}
|
|
293
|
+
return guarded(
|
|
294
|
+
() => walkthrough(b, summary, options.lap ?? "first", carry, fixedAt, afterRiskFix),
|
|
295
|
+
guard,
|
|
296
|
+
)()
|
|
461
297
|
},
|
|
462
298
|
fixRisks: guarded((notes) => {
|
|
463
299
|
riskFixed = true
|
|
@@ -472,7 +308,6 @@ export const humanReview = (
|
|
|
472
308
|
reviewMissing,
|
|
473
309
|
collecting: feedback.collecting,
|
|
474
310
|
triage: triage(severities),
|
|
475
|
-
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
476
311
|
settles: settlesStandard,
|
|
477
312
|
learn: learnStandards,
|
|
478
313
|
pending: standardsPending,
|
|
@@ -526,11 +361,9 @@ export interface BuildTailOptions extends TailOptions {
|
|
|
526
361
|
readonly feedback?: FeedbackTarget | undefined
|
|
527
362
|
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
528
363
|
readonly fixAccess?: ScopeAccess | undefined
|
|
529
|
-
/** Which round
|
|
364
|
+
/** Which round the reviewer writes; absent: `first`. */
|
|
530
365
|
readonly lap?: t.ReviewRange | undefined
|
|
531
|
-
/**
|
|
532
|
-
readonly walkthrough?: boolean | undefined
|
|
533
|
-
/** What the triaged quality lap left for the review; absent: nothing. */
|
|
366
|
+
/** What the quality lap and its finding gate did, for the review's summary; absent: nothing. */
|
|
534
367
|
readonly report?: LensReport | undefined
|
|
535
368
|
}
|
|
536
369
|
|
|
@@ -39,6 +39,7 @@ describe("the bundled workflow's skills export", () => {
|
|
|
39
39
|
"build.quality.test-audit",
|
|
40
40
|
"build.quality.conventions",
|
|
41
41
|
"build.quality.spec-challenge",
|
|
42
|
+
"build.quality.criteria-coverage",
|
|
42
43
|
"refactor.build",
|
|
43
44
|
"refactor.build.health",
|
|
44
45
|
"refactor.build.fix.quality",
|
|
@@ -98,10 +99,11 @@ describe("the bundled workflow's skills export", () => {
|
|
|
98
99
|
expect(Object.keys(none).filter((k) => k.startsWith("refactor.build.quality."))).toEqual([])
|
|
99
100
|
})
|
|
100
101
|
|
|
101
|
-
it("loads one lens skill per quality lens: correctness its review skill, conventions
|
|
102
|
+
it("loads one lens skill per quality lens: correctness its review skill, conventions, spec-challenge and criteria-coverage none, any other itself", () => {
|
|
102
103
|
expect(bundled["build.quality.correctness"]).toEqual(["code-review-and-quality"])
|
|
103
104
|
expect(bundled["build.quality.conventions"]).toEqual([])
|
|
104
105
|
expect(bundled["build.quality.spec-challenge"]).toEqual([])
|
|
106
|
+
expect(bundled["build.quality.criteria-coverage"]).toEqual([])
|
|
105
107
|
expect(bundled["build.quality.owasp-security"]).toEqual(["owasp-security"])
|
|
106
108
|
})
|
|
107
109
|
|
|
@@ -57,12 +57,15 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
|
|
|
57
57
|
expect(body).toContain(point)
|
|
58
58
|
})
|
|
59
59
|
|
|
60
|
-
it.each(["conventions", "spec-challenge"])(
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
steps.
|
|
64
|
-
|
|
65
|
-
|
|
60
|
+
it.each(["conventions", "spec-challenge", "criteria-coverage"])(
|
|
61
|
+
"%s carries its brief",
|
|
62
|
+
async (lens) => {
|
|
63
|
+
const request = await capture(() => steps.reviewQuality(lens), "build")
|
|
64
|
+
expect(JSON.stringify(request)).toContain(
|
|
65
|
+
steps.builtInLenses[lens]!.brief.split("\n")[0]!.slice(0, 40),
|
|
66
|
+
)
|
|
67
|
+
},
|
|
68
|
+
)
|
|
66
69
|
|
|
67
70
|
it("an unknown lens has no brief", async () => {
|
|
68
71
|
const request = await capture(() => steps.reviewQuality("my-lens"), "build")
|
|
@@ -66,15 +66,24 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
|
|
|
66
66
|
correctness: { skills: ["code-review-and-quality"], brief: t.correctnessBrief },
|
|
67
67
|
conventions: { skills: [], brief: t.conventionsBrief },
|
|
68
68
|
"spec-challenge": { skills: [], brief: t.specChallengeBrief },
|
|
69
|
+
"criteria-coverage": { skills: [], brief: t.criteriaCoverageBrief },
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
const coderLenses: ReadonlySet<string> = new Set(["conventions", "ponytail-review", "test-audit"])
|
|
72
73
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
74
|
+
/**
|
|
75
|
+
* One quality review through `lens`; each lens is its own scope, so its own
|
|
76
|
+
* conversation and skills. `requirements`, when given, are quoted in the prompt.
|
|
77
|
+
*/
|
|
78
|
+
export const reviewQuality = (
|
|
79
|
+
lens: string,
|
|
80
|
+
base?: string,
|
|
81
|
+
model?: string,
|
|
82
|
+
requirements?: string,
|
|
83
|
+
): Promise<void> =>
|
|
75
84
|
t.agentWithSkills(
|
|
76
85
|
`quality.${lens}.reviewing`,
|
|
77
|
-
t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
|
|
86
|
+
t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base, requirements),
|
|
78
87
|
{
|
|
79
88
|
label: "Reviewing (one quality lens)",
|
|
80
89
|
file: QUALITY,
|
|
@@ -211,13 +211,17 @@ const prompts: string[] = []
|
|
|
211
211
|
const models: (string | undefined)[] = []
|
|
212
212
|
|
|
213
213
|
describe("the quality lap's lenses", () => {
|
|
214
|
-
const lap = async (
|
|
214
|
+
const lap = async (
|
|
215
|
+
run: () => Promise<unknown>,
|
|
216
|
+
vars: Record<string, string> = {},
|
|
217
|
+
files: Record<string, string> = {},
|
|
218
|
+
) => {
|
|
215
219
|
const log: string[] = []
|
|
216
220
|
prompts.length = 0
|
|
217
221
|
models.length = 0
|
|
218
222
|
installContext(
|
|
219
223
|
fixtureContext(
|
|
220
|
-
{ vars },
|
|
224
|
+
{ vars, read: (path) => files[path] },
|
|
221
225
|
{
|
|
222
226
|
step: (request: StepRequest) => {
|
|
223
227
|
if (request.kind !== "restart") log.push(request.name)
|
|
@@ -309,6 +313,20 @@ describe("the quality lap's lenses", () => {
|
|
|
309
313
|
expect(models).toEqual(["smart", "opus"])
|
|
310
314
|
})
|
|
311
315
|
|
|
316
|
+
it("criteria-coverage runs only with requirements, passed or in .gtd/REQUIREMENTS.md, and quotes them", async () => {
|
|
317
|
+
const lenses = ["criteria-coverage", "conventions"]
|
|
318
|
+
expect(await lap(() => qualityLap(undefined, lenses))).toEqual([
|
|
319
|
+
"quality.conventions.reviewing",
|
|
320
|
+
])
|
|
321
|
+
await lap(() => qualityLap(undefined, lenses), {}, { ".gtd/REQUIREMENTS.md": "- AC-1: file" })
|
|
322
|
+
expect(prompts[0]).toContain(" > - AC-1: file")
|
|
323
|
+
expect(prompts[1]).not.toContain("AC-1")
|
|
324
|
+
await lap(() =>
|
|
325
|
+
tail(() => Promise.resolve(), { green: true, lenses, requirements: "- AC-1: passed" }),
|
|
326
|
+
)
|
|
327
|
+
expect(prompts[0]).toContain(" > - AC-1: passed")
|
|
328
|
+
})
|
|
329
|
+
|
|
312
330
|
it("tail passes lenses to its lap and a guard after each lens turn", async () => {
|
|
313
331
|
let guards = 0
|
|
314
332
|
const log = await lap(() =>
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
vars,
|
|
9
9
|
writeScript,
|
|
10
10
|
} from "../../flows/index.js"
|
|
11
|
+
import { reviewRisks } from "../../steering/index.js"
|
|
11
12
|
import * as kit from "../../workflow-kit/index.js"
|
|
12
13
|
import { guarded, healthy, type EscalationCount, type Guard } from "./health.js"
|
|
13
14
|
import {
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
fixCheck,
|
|
16
17
|
fixRisks,
|
|
17
18
|
QUALITY,
|
|
19
|
+
REQUIREMENTS,
|
|
18
20
|
REVIEW,
|
|
19
21
|
reviewing,
|
|
20
22
|
reviewMissing,
|
|
@@ -35,24 +37,31 @@ export const qualityLap = (
|
|
|
35
37
|
guard?: Guard,
|
|
36
38
|
lenses?: readonly string[],
|
|
37
39
|
base?: string,
|
|
38
|
-
): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, () => {})
|
|
40
|
+
): Promise<"clean" | "findings"> => lensLap(guard, lenses, base, undefined, () => {})
|
|
41
|
+
|
|
42
|
+
const COVERAGE = "criteria-coverage"
|
|
39
43
|
|
|
40
44
|
// `seen` gets each lens and every finding in the file once its turn is done.
|
|
41
45
|
const lensLap = (
|
|
42
46
|
guard: Guard | undefined,
|
|
43
47
|
lenses: readonly string[] | undefined,
|
|
44
48
|
base: string | undefined,
|
|
49
|
+
given: string | undefined,
|
|
45
50
|
seen: (lens: string, findings: readonly Finding[]) => void,
|
|
46
|
-
): Promise<"clean" | "findings"> =>
|
|
47
|
-
|
|
48
|
-
|
|
51
|
+
): Promise<"clean" | "findings"> => {
|
|
52
|
+
const requirements = given ?? read(REQUIREMENTS)
|
|
53
|
+
return kit.qualityLap(
|
|
54
|
+
(lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? "")).filter(
|
|
55
|
+
(entry) => requirements !== undefined || kit.lensOf(entry).lens !== COVERAGE,
|
|
56
|
+
),
|
|
49
57
|
guarded(async (entry: string) => {
|
|
50
58
|
const { lens, model } = kit.lensOf(entry)
|
|
51
|
-
await reviewQuality(lens, base, model)
|
|
59
|
+
await reviewQuality(lens, base, model, lens === COVERAGE ? requirements : undefined)
|
|
52
60
|
seen(lens, findingsOf(read(QUALITY) ?? ""))
|
|
53
61
|
}, guard),
|
|
54
62
|
QUALITY,
|
|
55
63
|
)
|
|
64
|
+
}
|
|
56
65
|
|
|
57
66
|
// What the fixer left is in the lap's report by now; the next lap must not append to it.
|
|
58
67
|
const closeQuality = async (): Promise<void> => {
|
|
@@ -91,12 +100,17 @@ export interface TailOptions {
|
|
|
91
100
|
readonly lensBase?: string | undefined
|
|
92
101
|
/** Appended to every fix and describe prompt of the tail. */
|
|
93
102
|
readonly rules?: string | undefined
|
|
103
|
+
/**
|
|
104
|
+
* The requirements the change was built to, for the `criteria-coverage`
|
|
105
|
+
* lens; absent: `.gtd/REQUIREMENTS.md`, and with neither the lens is skipped.
|
|
106
|
+
*/
|
|
107
|
+
readonly requirements?: string | undefined
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
/**
|
|
97
111
|
* The build tail: fix (when entered red), keep green, the quality lap — a
|
|
98
|
-
* judge sorts its findings
|
|
99
|
-
*
|
|
112
|
+
* judge sorts its findings and the mechanical ones are fixed — then the
|
|
113
|
+
* finding gate over the rest, then `finish` with what became of them all.
|
|
100
114
|
*/
|
|
101
115
|
export function tail<T>(
|
|
102
116
|
finish: (escalations: EscalationCount, report: LensReport) => Promise<T>,
|
|
@@ -156,7 +170,7 @@ const tailForm = <T>(
|
|
|
156
170
|
|
|
157
171
|
const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
158
172
|
scope("build", async () => {
|
|
159
|
-
const { guard, lenses, lensBase, rules } = options
|
|
173
|
+
const { guard, lenses, lensBase, rules, requirements } = options
|
|
160
174
|
// Only undefined defaults: a null reaches every escalation as falsy, as it reaches review's.
|
|
161
175
|
const guardRests = options.guardRests === undefined ? true : options.guardRests
|
|
162
176
|
const escalations: EscalationCount = { rounds: 0 }
|
|
@@ -170,7 +184,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
|
170
184
|
// The lap runs once a tail: after its findings are fixed, `finish` follows.
|
|
171
185
|
// Lenses append, so a title the file did not hold before a lens's turn is that lens's.
|
|
172
186
|
const byTitle: { title: string; lens: string }[] = []
|
|
173
|
-
const lap = await lensLap(guard, lenses, lensBase, (lens, findings) => {
|
|
187
|
+
const lap = await lensLap(guard, lenses, lensBase, requirements, (lens, findings) => {
|
|
174
188
|
const before = byTitle.map((f) => f.title)
|
|
175
189
|
for (const { title } of findings) {
|
|
176
190
|
const at = before.indexOf(title)
|
|
@@ -201,22 +215,29 @@ export const handOffReview = (
|
|
|
201
215
|
escalations: EscalationCount,
|
|
202
216
|
guard?: Guard,
|
|
203
217
|
report: LensReport = NO_FINDINGS,
|
|
204
|
-
): Promise<void> =>
|
|
205
|
-
|
|
218
|
+
): Promise<void> => {
|
|
219
|
+
// Set by a risk fix, so the re-review written next is summarised whatever it marks.
|
|
220
|
+
let riskFixed = false
|
|
221
|
+
return kit.reviewToHandOff(base, {
|
|
206
222
|
file: REVIEW,
|
|
207
223
|
reviewing: guarded(async (b, carry) => {
|
|
224
|
+
const afterRiskFix = riskFixed
|
|
225
|
+
riskFixed = false
|
|
208
226
|
await reviewing(b, carry)
|
|
209
|
-
// gtd adds the lap's chunks itself: .gtd/QUALITY.md is gone, so a reviewer leaving one out would lose it.
|
|
210
227
|
const review = read(REVIEW)
|
|
211
|
-
|
|
212
|
-
|
|
228
|
+
// A risk-marked draft is fixed and written again first: the re-reviewer edits it, never gtd's summary.
|
|
229
|
+
if (review === undefined || (!afterRiskFix && reviewRisks(review).length > 0)) return
|
|
230
|
+
const full = t.withSummary(review, { report })
|
|
213
231
|
if (full !== review) {
|
|
214
232
|
await run("review.carrying", writeScript(REVIEW, full), {
|
|
215
|
-
label: "Adding the quality lap's
|
|
233
|
+
label: "Adding the quality lap's summary",
|
|
216
234
|
})
|
|
217
235
|
}
|
|
218
236
|
}, guard),
|
|
219
|
-
fixRisks: guarded(
|
|
237
|
+
fixRisks: guarded((risks) => {
|
|
238
|
+
riskFixed = true
|
|
239
|
+
return fixRisks(risks)
|
|
240
|
+
}, guard),
|
|
220
241
|
keepGreen: () => healthy(fixCheck, { escalations, guard }),
|
|
221
242
|
awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
|
|
222
243
|
reviewMissing,
|
|
@@ -233,6 +254,7 @@ export const handOffReview = (
|
|
|
233
254
|
deleted:
|
|
234
255
|
"gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix.",
|
|
235
256
|
})
|
|
257
|
+
}
|
|
236
258
|
|
|
237
259
|
/** The tail the fix workflow runs: ends at the hand-off review since `base`, which gets what the lap left. */
|
|
238
260
|
export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
|