@pmelab/gtd 40.0.0 → 42.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 +41 -30
- package/dist/chunks/{reserved-Qt5FM6Ia.mjs → reserved-D7MNY9QJ.mjs} +66 -18
- package/dist/chunks/{steering-DVILanVf.mjs → steering-oCDdQlpP.mjs} +1 -1
- package/dist/chunks/tail-D2o2h5cc.mjs +246 -0
- package/dist/flows.mjs +11 -1
- package/dist/gtd.bundle.mjs +12 -2
- package/dist/steering.mjs +1 -1
- package/dist/testkit.mjs +15 -3
- package/dist/web/main.js +1 -1
- package/dist/workflow-kit.mjs +30 -12
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +44 -24
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +282 -51
- package/package.json +1 -1
- package/src/flows/scripts.ts +15 -0
- package/src/steering/review.test.ts +2 -20
- package/src/steering/review.ts +2 -1
- package/src/testkit/FlowScripts.test.ts +17 -0
- package/src/testkit/FlowScripts.ts +15 -0
- package/src/testkit/steps.ts +27 -0
- package/src/workflow-kit/health.ts +42 -13
- package/src/workflows/feature/coverage.feature +3 -3
- package/src/workflows/feature/fast-path.feature +2 -2
- package/src/workflows/feature/feature.test.ts +2 -37
- package/src/workflows/feature/feature.ts +12 -14
- package/src/workflows/feature/flag-mode.feature +2 -2
- package/src/workflows/feature/lens-review.feature +234 -0
- package/src/workflows/feature/one-step-land.feature +2 -2
- package/src/workflows/feature/quality-unresolved.feature +47 -32
- package/src/workflows/feature/refactor-absorbs.feature +3 -3
- package/src/workflows/feature/refactor-first.feature +5 -5
- package/src/workflows/feature/refactor-rebuild.feature +7 -7
- package/src/workflows/feature/refactor-review.feature +19 -100
- package/src/workflows/feature/refactor.test.ts +12 -27
- package/src/workflows/feature/refactor.ts +0 -1
- package/src/workflows/feature/review-gate.feature +6 -6
- package/src/workflows/feature/review.test.ts +29 -25
- package/src/workflows/feature/review.ts +53 -10
- package/src/workflows/feature/seam-rejection.feature +2 -2
- package/src/workflows/feature/seam.feature +2 -2
- package/src/workflows/feature/seam.test.ts +1 -1
- package/src/workflows/feature/slicing.feature +6 -6
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/health.test.ts +30 -0
- package/src/workflows/shared/health.ts +12 -9
- package/src/workflows/shared/lenses.test.ts +39 -0
- package/src/workflows/shared/lenses.ts +176 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/progress.feature +124 -0
- package/src/workflows/shared/tail.ts +19 -4
- package/src/workflows/shared/text.test.ts +68 -1
- package/src/workflows/shared/text.ts +126 -13
- package/src/workflows/shared/vars.ts +1 -0
- package/dist/chunks/tail-tAHx_AHe.mjs +0 -112
|
@@ -48,7 +48,7 @@ interface Drive {
|
|
|
48
48
|
readonly vars?: Readonly<Record<string, string>>
|
|
49
49
|
/** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
|
|
50
50
|
readonly after?: string
|
|
51
|
-
/** The REVIEW.md each `review.
|
|
51
|
+
/** The REVIEW.md each `review.composing` turn writes, in order; also raises the review-turn stop to one past the last. */
|
|
52
52
|
readonly reviewDocs?: readonly (string | undefined)[]
|
|
53
53
|
/** Let the run pass the second gate, stopping at the first range read after it. */
|
|
54
54
|
readonly passSecondGate?: boolean
|
|
@@ -95,7 +95,7 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
95
95
|
let awaits = 0
|
|
96
96
|
let reviews = 0
|
|
97
97
|
const effects: Record<string, () => void> = {
|
|
98
|
-
"review.
|
|
98
|
+
"review.composing": () => {
|
|
99
99
|
const docs = d.reviewDocs
|
|
100
100
|
if (++reviews > (docs?.length ?? 0) + (docs === undefined ? 1 : 0)) throw new Stop()
|
|
101
101
|
const written = docs?.[reviews - 1]
|
|
@@ -116,6 +116,7 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
116
116
|
gateMessages.push(request.options.message ?? "")
|
|
117
117
|
}
|
|
118
118
|
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
119
|
+
if (request.kind === "run") prompts.set(request.name, request.body)
|
|
119
120
|
if (request.kind === "judge") asked.push(...request.questions)
|
|
120
121
|
}
|
|
121
122
|
const context = fixtureContext(
|
|
@@ -184,10 +185,10 @@ describe("the risk-fix pass", () => {
|
|
|
184
185
|
it("a marked risk is fixed, kept green, re-reviewed, then rests at the gate", async () => {
|
|
185
186
|
const run = await drive({ notes: [], reviewDocs: [risky, clean] })
|
|
186
187
|
expect(run.log.slice(0, 5)).toEqual([
|
|
187
|
-
"review.
|
|
188
|
+
"review.composing",
|
|
188
189
|
"review.fix.risks.fixing",
|
|
189
190
|
"health.check",
|
|
190
|
-
"review.
|
|
191
|
+
"review.composing",
|
|
191
192
|
"review.await-review",
|
|
192
193
|
])
|
|
193
194
|
expect(run.prompts.get("review.fix.risks.fixing")).toContain("risk-1")
|
|
@@ -199,10 +200,10 @@ describe("the risk-fix pass", () => {
|
|
|
199
200
|
const run = await drive({ notes: [], reviewDocs: [risky, risky] })
|
|
200
201
|
expect(run.log.filter((n) => n === "review.fix.risks.fixing")).toHaveLength(1)
|
|
201
202
|
expect(run.log.slice(0, 5)).toEqual([
|
|
202
|
-
"review.
|
|
203
|
+
"review.composing",
|
|
203
204
|
"review.fix.risks.fixing",
|
|
204
205
|
"health.check",
|
|
205
|
-
"review.
|
|
206
|
+
"review.composing",
|
|
206
207
|
"review.await-review",
|
|
207
208
|
])
|
|
208
209
|
})
|
|
@@ -232,7 +233,7 @@ describe("the risk-fix pass", () => {
|
|
|
232
233
|
it("no marker means no fix-risks step", async () => {
|
|
233
234
|
const run = await drive({ notes: [], reviewDocs: [clean] })
|
|
234
235
|
expect(run.log).not.toContain("review.fix.risks.fixing")
|
|
235
|
-
expect(run.log[0]).toBe("review.
|
|
236
|
+
expect(run.log[0]).toBe("review.composing")
|
|
236
237
|
expect(run.log[1]).toBe("review.await-review")
|
|
237
238
|
})
|
|
238
239
|
|
|
@@ -243,16 +244,16 @@ describe("the risk-fix pass", () => {
|
|
|
243
244
|
reviewDocs: [undefined, risky, clean],
|
|
244
245
|
})
|
|
245
246
|
expect(run.log).toEqual([
|
|
246
|
-
"review.
|
|
247
|
+
"review.composing",
|
|
247
248
|
"review.await-review",
|
|
248
249
|
"review.triage",
|
|
249
250
|
"review.fix.nits.fixing",
|
|
250
251
|
"health.check",
|
|
251
252
|
"review.closing",
|
|
252
|
-
"review.
|
|
253
|
+
"review.composing",
|
|
253
254
|
"review.fix.risks.fixing",
|
|
254
255
|
"health.check",
|
|
255
|
-
"review.
|
|
256
|
+
"review.composing",
|
|
256
257
|
"review.await-review",
|
|
257
258
|
])
|
|
258
259
|
})
|
|
@@ -265,7 +266,7 @@ describe("the guard", () => {
|
|
|
265
266
|
it("runs right after the risk-fix turn", async () => {
|
|
266
267
|
const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
|
|
267
268
|
expect(run.log.slice(0, 4)).toEqual([
|
|
268
|
-
"review.
|
|
269
|
+
"review.composing",
|
|
269
270
|
"review.fix.risks.fixing",
|
|
270
271
|
"guard",
|
|
271
272
|
"health.check",
|
|
@@ -443,7 +444,7 @@ describe("review verdict routing", () => {
|
|
|
443
444
|
it("an unanswered note counts as edit", async () => {
|
|
444
445
|
const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
|
|
445
446
|
expect(run.log).toEqual([
|
|
446
|
-
"review.
|
|
447
|
+
"review.composing",
|
|
447
448
|
"review.await-review",
|
|
448
449
|
"review.triage",
|
|
449
450
|
"review.closing",
|
|
@@ -478,7 +479,7 @@ describe("review verdict routing", () => {
|
|
|
478
479
|
it("only praise closes and signs off with no agent turn", async () => {
|
|
479
480
|
const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
|
|
480
481
|
expect(run.log).toEqual([
|
|
481
|
-
"review.
|
|
482
|
+
"review.composing",
|
|
482
483
|
"review.await-review",
|
|
483
484
|
"review.triage",
|
|
484
485
|
"review.closing",
|
|
@@ -492,7 +493,7 @@ describe("review verdict routing", () => {
|
|
|
492
493
|
answers: { "note-1": verdict("question"), "note-2": verdict("question") },
|
|
493
494
|
})
|
|
494
495
|
expect(run.log).toEqual([
|
|
495
|
-
"review.
|
|
496
|
+
"review.composing",
|
|
496
497
|
"review.await-review",
|
|
497
498
|
"review.triage",
|
|
498
499
|
"review.answer-review-questions",
|
|
@@ -508,17 +509,17 @@ describe("review verdict routing", () => {
|
|
|
508
509
|
answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
|
|
509
510
|
})
|
|
510
511
|
expect(run.log).toEqual([
|
|
511
|
-
"review.
|
|
512
|
+
"review.composing",
|
|
512
513
|
"review.await-review",
|
|
513
514
|
"review.triage",
|
|
514
515
|
"review.answer-review-questions",
|
|
515
516
|
"review.fix.nits.fixing",
|
|
516
517
|
"health.check",
|
|
517
518
|
"review.closing",
|
|
518
|
-
"review.
|
|
519
|
+
"review.composing",
|
|
519
520
|
])
|
|
520
521
|
expect(run.prompts.get("review.fix.nits.fixing")).not.toContain("why?")
|
|
521
|
-
expect(run.prompts.get("review.
|
|
522
|
+
expect(run.prompts.get("review.composing")).toContain("git show 'c4:.gtd/REVIEW.md'")
|
|
522
523
|
expect(run.result).toBe("stopped")
|
|
523
524
|
})
|
|
524
525
|
|
|
@@ -527,7 +528,8 @@ describe("review verdict routing", () => {
|
|
|
527
528
|
notes: ["add — typo", "sub", "mul"],
|
|
528
529
|
answers: { "note-1": verdict("nit") },
|
|
529
530
|
})
|
|
530
|
-
expect(run.prompts.get("review.
|
|
531
|
+
expect(run.prompts.get("review.composing")).not.toContain("git show")
|
|
532
|
+
expect(run.prompts.get("review.composing")).toContain("## Fixed from your notes")
|
|
531
533
|
})
|
|
532
534
|
|
|
533
535
|
it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
|
|
@@ -536,7 +538,7 @@ describe("review verdict routing", () => {
|
|
|
536
538
|
answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
|
|
537
539
|
})
|
|
538
540
|
expect(run.log).toEqual([
|
|
539
|
-
"review.
|
|
541
|
+
"review.composing",
|
|
540
542
|
"review.await-review",
|
|
541
543
|
"review.triage",
|
|
542
544
|
"review.answer-review-questions",
|
|
@@ -556,7 +558,7 @@ describe("review verdict routing", () => {
|
|
|
556
558
|
it("a noted round with no extractable note collects as one edit, without judging", async () => {
|
|
557
559
|
const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
|
|
558
560
|
expect(run.log).toEqual([
|
|
559
|
-
"review.
|
|
561
|
+
"review.composing",
|
|
560
562
|
"review.await-review",
|
|
561
563
|
"review.closing",
|
|
562
564
|
"review.collecting",
|
|
@@ -679,7 +681,7 @@ describe("severity routing", () => {
|
|
|
679
681
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
680
682
|
})
|
|
681
683
|
expect(run.log).toEqual([
|
|
682
|
-
"review.
|
|
684
|
+
"review.composing",
|
|
683
685
|
"review.await-review",
|
|
684
686
|
"review.triage",
|
|
685
687
|
"review.defer",
|
|
@@ -771,7 +773,7 @@ describe("severity routing", () => {
|
|
|
771
773
|
passSecondGate: true,
|
|
772
774
|
})
|
|
773
775
|
expect(run.log).toEqual([
|
|
774
|
-
"review.
|
|
776
|
+
"review.composing",
|
|
775
777
|
"review.await-review",
|
|
776
778
|
"review.triage",
|
|
777
779
|
"review.answer-review-questions",
|
|
@@ -807,7 +809,7 @@ describe("BuildTailOptions.lensBase", () => {
|
|
|
807
809
|
if (request.kind === "restart") return Promise.resolve()
|
|
808
810
|
if (request.kind === "agent" && request.name.startsWith("quality."))
|
|
809
811
|
prompts.push(request.prompt)
|
|
810
|
-
if (request.name === "review.
|
|
812
|
+
if (request.name === "review.composing") throw new Stop()
|
|
811
813
|
return Promise.resolve()
|
|
812
814
|
},
|
|
813
815
|
pushScope: () => undefined,
|
|
@@ -1048,10 +1050,12 @@ describe("buildTail without rules", () => {
|
|
|
1048
1050
|
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
1049
1051
|
if (request.name === "health.check") checks++
|
|
1050
1052
|
if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
|
|
1051
|
-
if (request.name === "review.
|
|
1053
|
+
if (request.name === "review.composing" || request.name === "health.stop")
|
|
1052
1054
|
throw new Stop()
|
|
1053
1055
|
return Promise.resolve(
|
|
1054
|
-
request.kind === "judge"
|
|
1056
|
+
request.kind === "judge"
|
|
1057
|
+
? { answers: { Q1: { answer: "mechanical", p: 0.9 } }, truncated: [] }
|
|
1058
|
+
: undefined,
|
|
1055
1059
|
)
|
|
1056
1060
|
},
|
|
1057
1061
|
pushScope: () => undefined,
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import {
|
|
2
2
|
answered,
|
|
3
|
+
changesSince,
|
|
3
4
|
head,
|
|
4
5
|
judge,
|
|
5
6
|
numeric,
|
|
6
7
|
requireRevert,
|
|
7
8
|
restoreScript,
|
|
8
9
|
run,
|
|
10
|
+
showScript,
|
|
9
11
|
vars,
|
|
12
|
+
writeScript,
|
|
10
13
|
type JudgeQuestion,
|
|
11
14
|
type Judgment,
|
|
12
15
|
type ScopeAccess,
|
|
@@ -14,13 +17,14 @@ import {
|
|
|
14
17
|
import type { ReviewNote } from "../../steering/index.js"
|
|
15
18
|
import * as kit from "../../workflow-kit/index.js"
|
|
16
19
|
import { guarded, healthy, type EscalationCount } from "../shared/health.js"
|
|
20
|
+
import { NO_FINDINGS, type LensReport } from "../shared/lenses.js"
|
|
17
21
|
import {
|
|
18
22
|
awaitReview,
|
|
19
23
|
fixCheck,
|
|
20
24
|
fixRisks,
|
|
25
|
+
QUALITY,
|
|
21
26
|
REQUIREMENTS,
|
|
22
27
|
REVIEW,
|
|
23
|
-
reviewing,
|
|
24
28
|
reviewMissing,
|
|
25
29
|
} from "../shared/steps.js"
|
|
26
30
|
import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
|
|
@@ -161,10 +165,37 @@ const triage =
|
|
|
161
165
|
export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
162
166
|
|
|
163
167
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
|
|
168
|
+
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
169
|
+
* turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
|
|
170
|
+
* after fixed nits restores the doc as committed at `carry`, answers kept.
|
|
171
|
+
*/
|
|
172
|
+
const composing = (
|
|
173
|
+
base: string,
|
|
174
|
+
report: LensReport,
|
|
175
|
+
carry?: string,
|
|
176
|
+
nitFix?: string,
|
|
177
|
+
): Promise<void> =>
|
|
178
|
+
run(
|
|
179
|
+
"review.composing",
|
|
180
|
+
carry === undefined
|
|
181
|
+
? writeScript(
|
|
182
|
+
REVIEW,
|
|
183
|
+
t.composedReview(base, {
|
|
184
|
+
...report,
|
|
185
|
+
weakened: kit.removedAssertions(changesSince(base)),
|
|
186
|
+
nitFix,
|
|
187
|
+
}),
|
|
188
|
+
[QUALITY],
|
|
189
|
+
)
|
|
190
|
+
: showScript(carry, REVIEW, REVIEW),
|
|
191
|
+
{ label: "Composing the review", file: REVIEW, base },
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* The quality lap's notes reach a human over everything since `base`, who
|
|
196
|
+
* signs off or comments, and each note is judged: edits go to another
|
|
197
|
+
* planning lap, questions are answered in place, nits fixed in one batch,
|
|
198
|
+
* praise dropped.
|
|
168
199
|
*/
|
|
169
200
|
export const review = (
|
|
170
201
|
base: string,
|
|
@@ -182,13 +213,17 @@ export const review = (
|
|
|
182
213
|
// Armed by a defer-only round to pick the next gate's message; a fresh
|
|
183
214
|
// review (`reviewing`) disarms it.
|
|
184
215
|
let deferred: readonly t.NoteInput[] = []
|
|
216
|
+
// Set by a nit fix, so the fresh review that follows names its commit.
|
|
217
|
+
let nitFix: string | undefined
|
|
185
218
|
return kit.review(base, {
|
|
186
219
|
file: REVIEW,
|
|
187
220
|
feedback: feedback.file,
|
|
188
221
|
isCode,
|
|
189
222
|
reviewing: (b, carry) => {
|
|
190
223
|
deferred = []
|
|
191
|
-
|
|
224
|
+
const fixedAt = nitFix
|
|
225
|
+
nitFix = undefined
|
|
226
|
+
return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt)
|
|
192
227
|
},
|
|
193
228
|
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
194
229
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
@@ -212,7 +247,10 @@ export const review = (
|
|
|
212
247
|
return deferNotes(deferred, head())
|
|
213
248
|
},
|
|
214
249
|
answerQuestions: answerReviewQuestions,
|
|
215
|
-
fixNits:
|
|
250
|
+
fixNits: async (notes) => {
|
|
251
|
+
await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
|
|
252
|
+
nitFix = head()
|
|
253
|
+
},
|
|
216
254
|
close: closeReview,
|
|
217
255
|
captures: {
|
|
218
256
|
edits: t.reviewEditsCapture,
|
|
@@ -259,11 +297,13 @@ export interface BuildTailOptions extends TailOptions {
|
|
|
259
297
|
readonly feedback?: FeedbackTarget | undefined
|
|
260
298
|
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
261
299
|
readonly fixAccess?: ScopeAccess | undefined
|
|
262
|
-
/**
|
|
300
|
+
/** @deprecated No reviewer turn reads it since the review is composed. */
|
|
263
301
|
readonly lap?: t.ReviewRange | undefined
|
|
302
|
+
/** What the triaged quality lap left for the review; absent: nothing. */
|
|
303
|
+
readonly report?: LensReport | undefined
|
|
264
304
|
}
|
|
265
305
|
|
|
266
|
-
/** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
|
|
306
|
+
/** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
|
|
267
307
|
export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
|
|
268
308
|
/** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
|
|
269
309
|
export function buildTail(
|
|
@@ -287,7 +327,10 @@ export function buildTail(
|
|
|
287
327
|
)
|
|
288
328
|
}
|
|
289
329
|
const [base, form] = options
|
|
290
|
-
return tail((escalations) => review(base, escalations, form),
|
|
330
|
+
return tail((escalations, report) => review(base, escalations, { ...form, report }), {
|
|
331
|
+
...form,
|
|
332
|
+
triage: true,
|
|
333
|
+
})
|
|
291
334
|
}
|
|
292
335
|
|
|
293
336
|
const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
|
|
@@ -100,7 +100,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
|
|
|
100
100
|
And step "implement.applying" runs its script
|
|
101
101
|
When gtd lands
|
|
102
102
|
And the process is driven
|
|
103
|
-
Then the process rests at "build.review.
|
|
103
|
+
Then the process rests at "build.review.composing"
|
|
104
104
|
And the commit subjects end with:
|
|
105
105
|
"""
|
|
106
106
|
gtd(human): idle → unwind
|
|
@@ -124,7 +124,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
|
|
|
124
124
|
gtd(check): implement.applying → implement.health.check
|
|
125
125
|
gtd(check): implement.health.check → implement.coverage
|
|
126
126
|
gtd(judge): implement.coverage → implement.closing
|
|
127
|
-
gtd(check): implement.closing → build.review.
|
|
127
|
+
gtd(check): implement.closing → build.review.composing
|
|
128
128
|
"""
|
|
129
129
|
And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
|
|
130
130
|
And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
|
|
@@ -152,7 +152,7 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
152
152
|
[{"id":"AC-1","answer":"yes","p":0.9}]
|
|
153
153
|
"""
|
|
154
154
|
When the process is driven
|
|
155
|
-
Then the process rests at "build.review.
|
|
155
|
+
Then the process rests at "build.review.composing"
|
|
156
156
|
And "src/calc.ts" contains "subtract"
|
|
157
157
|
And the commit subjects end with:
|
|
158
158
|
"""
|
|
@@ -160,7 +160,7 @@ Feature: the naive pass and seam judge decide how a feature is built
|
|
|
160
160
|
gtd(check): implement.applying → implement.health.check
|
|
161
161
|
gtd(check): implement.health.check → implement.coverage
|
|
162
162
|
gtd(judge): implement.coverage → implement.closing
|
|
163
|
-
gtd(check): implement.closing → build.review.
|
|
163
|
+
gtd(check): implement.closing → build.review.composing
|
|
164
164
|
"""
|
|
165
165
|
And ".gtd/REQUIREMENTS.md" does not exist
|
|
166
166
|
|
|
@@ -335,7 +335,7 @@ const driveSeam = async (
|
|
|
335
335
|
note: () => void files.set(NOTE, "Seam: a shared op"),
|
|
336
336
|
deriving: () => void files.set(REFACTOR, "package"),
|
|
337
337
|
closing: () => void files.delete(REFACTOR),
|
|
338
|
-
"review.
|
|
338
|
+
"review.composing": () => void files.set(REVIEW, reviewDoc),
|
|
339
339
|
"review.closing": () => {
|
|
340
340
|
files.delete(REVIEW)
|
|
341
341
|
current = "after-refactor"
|
|
@@ -51,7 +51,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
51
51
|
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
|
|
52
52
|
"""
|
|
53
53
|
When the process is driven
|
|
54
|
-
Then the process rests at "build.review.
|
|
54
|
+
Then the process rests at "build.review.composing"
|
|
55
55
|
And the commit subjects end with:
|
|
56
56
|
"""
|
|
57
57
|
gtd(human): design.gate.answer → seam.naive
|
|
@@ -60,7 +60,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
60
60
|
gtd(check): implement.applying → implement.health.check
|
|
61
61
|
gtd(check): implement.health.check → implement.coverage
|
|
62
62
|
gtd(judge): implement.coverage → implement.closing
|
|
63
|
-
gtd(check): implement.closing → build.review.
|
|
63
|
+
gtd(check): implement.closing → build.review.composing
|
|
64
64
|
"""
|
|
65
65
|
And the output lines of gtd "slices" start with:
|
|
66
66
|
"""
|
|
@@ -112,7 +112,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
112
112
|
[{"id":"AC-2","answer":"yes","p":0.9}]
|
|
113
113
|
"""
|
|
114
114
|
When the process is driven
|
|
115
|
-
Then the process rests at "build.review.
|
|
115
|
+
Then the process rests at "build.review.composing"
|
|
116
116
|
And the commit subjects end with:
|
|
117
117
|
"""
|
|
118
118
|
gtd(agent): design.triage → design.gate.answer
|
|
@@ -129,7 +129,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
129
129
|
gtd(check): implement.applying → implement.health.check
|
|
130
130
|
gtd(check): implement.health.check → implement.coverage
|
|
131
131
|
gtd(judge): implement.coverage → implement.closing
|
|
132
|
-
gtd(check): implement.closing → build.review.
|
|
132
|
+
gtd(check): implement.closing → build.review.composing
|
|
133
133
|
"""
|
|
134
134
|
And the output lines of gtd "slices" start with:
|
|
135
135
|
"""
|
|
@@ -268,7 +268,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
268
268
|
"""
|
|
269
269
|
When gtd lands
|
|
270
270
|
And the process is driven
|
|
271
|
-
Then the process rests at "build.review.
|
|
271
|
+
Then the process rests at "build.review.composing"
|
|
272
272
|
And the commit subjects end with:
|
|
273
273
|
"""
|
|
274
274
|
gtd(human): seam.steer → seam.naive
|
|
@@ -277,7 +277,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
|
|
|
277
277
|
gtd(check): implement.applying → implement.health.check
|
|
278
278
|
gtd(check): implement.health.check → implement.coverage
|
|
279
279
|
gtd(judge): implement.coverage → implement.closing
|
|
280
|
-
gtd(check): implement.closing → build.review.
|
|
280
|
+
gtd(check): implement.closing → build.review.composing
|
|
281
281
|
"""
|
|
282
282
|
And ".gtd/REQUIREMENTS.md" does not exist
|
|
283
283
|
And ".gtd/SLICES.md" does not exist
|
package/src/workflows/index.ts
CHANGED
|
@@ -19,6 +19,7 @@ export {
|
|
|
19
19
|
tail,
|
|
20
20
|
type TailOptions,
|
|
21
21
|
} from "./shared/tail.js"
|
|
22
|
+
export { type Finding, type LensReport } from "./shared/lenses.js"
|
|
22
23
|
export { lensEntriesOf, lensesOf, lensOf } from "../workflow-kit/index.js"
|
|
23
24
|
export * from "./feature/steps.js"
|
|
24
25
|
export * from "./feature/criteria.js"
|
|
@@ -116,3 +116,33 @@ describe("healthy describeGuard", () => {
|
|
|
116
116
|
).rejects.toThrow("guarded: nope")
|
|
117
117
|
})
|
|
118
118
|
})
|
|
119
|
+
|
|
120
|
+
describe("healthy at the fix cap", () => {
|
|
121
|
+
// Blank reports are not comparable; at an 8-byte budget the cut trims both.
|
|
122
|
+
it.each([
|
|
123
|
+
["could not compare", "", (n: number) => "\n".repeat(n)],
|
|
124
|
+
["saw only cut", `, judgeBudgetBytes: "8"`, (n: number) => `a long failing report ${n}\n`],
|
|
125
|
+
])("grants no extra turn on progress the judge %s", async (_, extra, report) => {
|
|
126
|
+
let runs = 0
|
|
127
|
+
const repo = testRepo({
|
|
128
|
+
workflow: [
|
|
129
|
+
`import { healthy } from "@pmelab/gtd/workflows"`,
|
|
130
|
+
`import { agent } from "@pmelab/gtd/flows"`,
|
|
131
|
+
`export const defaults = { judgeIdenticalMinP: "0.7"${extra} }`,
|
|
132
|
+
`export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go")) }`,
|
|
133
|
+
`export default held`,
|
|
134
|
+
].join("\n"),
|
|
135
|
+
env: { GTD_TEST_COMMAND: "true" },
|
|
136
|
+
})
|
|
137
|
+
await repo.start("held")
|
|
138
|
+
const rest = await repo.drive({
|
|
139
|
+
first: (_, r) => r.write("start", "1\n"),
|
|
140
|
+
"health.check": (_, r) => r.write(".gtd/FEEDBACK.md", report(++runs)),
|
|
141
|
+
"health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
|
|
142
|
+
fix: (_, r) => r.write("f", `${runs}\n`),
|
|
143
|
+
"health.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "red again\n"),
|
|
144
|
+
})
|
|
145
|
+
expect(rest.state).toBe("health.describe")
|
|
146
|
+
expect(repo.subjects().filter((s) => s.startsWith("gtd(agent): fix")).length).toBe(3)
|
|
147
|
+
})
|
|
148
|
+
})
|
|
@@ -105,7 +105,10 @@ export const retryQuestion = (comparable: boolean): JudgeQuestion =>
|
|
|
105
105
|
"new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure.",
|
|
106
106
|
}
|
|
107
107
|
|
|
108
|
-
const
|
|
108
|
+
const compare = async (
|
|
109
|
+
previous: string,
|
|
110
|
+
current: string,
|
|
111
|
+
): Promise<kit.RetryVerdict | undefined> => {
|
|
109
112
|
const comparable = current.trim() !== "" && previous.trim() !== ""
|
|
110
113
|
const { answers, truncated } = await judge("health.judge", {
|
|
111
114
|
questions: [retryQuestion(comparable)],
|
|
@@ -113,12 +116,11 @@ const sameFailure = async (previous: string, current: string): Promise<boolean>
|
|
|
113
116
|
message: t.healthJudgeMessage(),
|
|
114
117
|
label: "Judging the retry",
|
|
115
118
|
})
|
|
116
|
-
// Evidence the budget cut can make two different reports look alike:
|
|
117
|
-
return
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
)
|
|
119
|
+
// Evidence the budget cut can make two different reports look alike: neither sameness nor progress.
|
|
120
|
+
if (!comparable || truncated.length > 0) return undefined
|
|
121
|
+
const minP = numeric(vars.judgeIdenticalMinP, Infinity)
|
|
122
|
+
if (answered(answers.verdict, "identical", minP)) return "identical"
|
|
123
|
+
return answered(answers.verdict, "progress", minP) ? "progress" : undefined
|
|
122
124
|
}
|
|
123
125
|
|
|
124
126
|
export interface HealthOptions {
|
|
@@ -153,7 +155,8 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
|
|
|
153
155
|
/**
|
|
154
156
|
* Loop until the suite is green: check, and on red run `fix` — escalating
|
|
155
157
|
* first once `FIX_CAP` fixes are spent, or a judge calls the failure
|
|
156
|
-
* identical to the previous round's.
|
|
158
|
+
* identical to the previous round's. A judge calling it progress at the cap
|
|
159
|
+
* buys one extra fix per run of fixes. An escalation re-runs the suite once
|
|
157
160
|
* first; a green re-run ends the loop.
|
|
158
161
|
*/
|
|
159
162
|
export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
|
|
@@ -162,7 +165,7 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
|
|
|
162
165
|
report: FEEDBACK,
|
|
163
166
|
fixCap: FIX_CAP,
|
|
164
167
|
fixesSoFar: options.fixesSoFar ?? 0,
|
|
165
|
-
|
|
168
|
+
compare,
|
|
166
169
|
escalations: options.escalations ?? { rounds: 0 },
|
|
167
170
|
recheck: () =>
|
|
168
171
|
recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext } from "../../flows/index.js"
|
|
3
|
+
import { headingSectionBodies } from "../../steering/index.js"
|
|
4
|
+
import { findingsOf } from "./lenses.js"
|
|
5
|
+
import { fixtureContext } from "./text.fixture.js"
|
|
6
|
+
|
|
7
|
+
afterEach(() => installContext(undefined))
|
|
8
|
+
|
|
9
|
+
const parse = (text: string) => {
|
|
10
|
+
installContext(fixtureContext({}, { sectionBodies: (t) => headingSectionBodies(t) }))
|
|
11
|
+
return findingsOf(text)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe("findingsOf", () => {
|
|
15
|
+
it("numbers one finding per ## chunk, the heading cut from its body", () => {
|
|
16
|
+
expect(parse("# Quality\n\n## a\n\nfix a\n\n## b\n\nfix b\n")).toEqual([
|
|
17
|
+
{ id: "Q1", title: "a", body: "fix a" },
|
|
18
|
+
{ id: "Q2", title: "b", body: "fix b" },
|
|
19
|
+
])
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it("keeps prose outside every chunk as a finding of its own", () => {
|
|
23
|
+
expect(parse("# Quality\n\n- stray\n\n## a\n\nfix a\n")).toEqual([
|
|
24
|
+
{ id: "Q1", title: "Quality findings", body: "- stray" },
|
|
25
|
+
{ id: "Q2", title: "a", body: "fix a" },
|
|
26
|
+
])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it("keeps prose a later lens wrote under a # heading after the chunks", () => {
|
|
30
|
+
expect(parse("## a\n\nfix a\n\n# Conventions\n\n- naming off\n")).toEqual([
|
|
31
|
+
{ id: "Q1", title: "Quality findings", body: "- naming off" },
|
|
32
|
+
{ id: "Q2", title: "a", body: "fix a" },
|
|
33
|
+
])
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it("reads a file with only a title as no finding", () => {
|
|
37
|
+
expect(parse("# Quality\n")).toEqual([])
|
|
38
|
+
})
|
|
39
|
+
})
|