@pmelab/gtd 41.0.0 → 43.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 +107 -53
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
- package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
- package/dist/flows.mjs +11 -1
- package/dist/gtd.bundle.mjs +498 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +460 -28
- package/dist/web/main.js +42 -13
- package/dist/workflow-kit.mjs +61 -25
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +66 -57
- 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 +446 -141
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +33 -1
- package/src/cli/Cli.ts +70 -6
- package/src/cli/History.ts +15 -0
- package/src/cli/Judges.test.ts +373 -0
- package/src/cli/Judges.ts +624 -0
- package/src/cli/Metrics.ts +3 -16
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +9 -0
- package/src/flows/scripts.ts +15 -0
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +109 -20
- package/src/steering/review.ts +160 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/FlowScripts.test.ts +17 -0
- package/src/testkit/FlowScripts.ts +15 -0
- package/src/testkit/steps.ts +31 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- 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 +246 -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-answers.feature +179 -0
- package/src/workflows/feature/review-gate.feature +6 -6
- package/src/workflows/feature/review.test.ts +33 -77
- package/src/workflows/feature/review.ts +79 -47
- 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/feature/steps.test.ts +0 -12
- package/src/workflows/feature/steps.ts +3 -10
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
- package/src/workflows/shared/access.test.ts +2 -11
- package/src/workflows/shared/access.ts +0 -2
- 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/tail.ts +19 -4
- package/src/workflows/shared/text.test.ts +106 -88
- package/src/workflows/shared/text.ts +164 -53
- package/src/workflows/shared/vars.ts +1 -0
- package/dist/chunks/tail-lY1UmJrU.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",
|
|
@@ -673,18 +675,19 @@ describe("severity routing", () => {
|
|
|
673
675
|
expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
|
|
674
676
|
})
|
|
675
677
|
|
|
676
|
-
it("a confident could edit
|
|
678
|
+
it("a confident could edit is still re-planned, labelled could", async () => {
|
|
677
679
|
const run = await drive({
|
|
678
680
|
notes: ["add — quote", "sub", "mul"],
|
|
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
|
-
"review.
|
|
686
|
-
"review.
|
|
687
|
+
"review.closing",
|
|
688
|
+
"review.collecting",
|
|
687
689
|
])
|
|
690
|
+
expect(run.prompts.get("review.collecting")).toContain("(could)")
|
|
688
691
|
})
|
|
689
692
|
|
|
690
693
|
it("severity answers on a nit leave it a nit", async () => {
|
|
@@ -728,68 +731,19 @@ describe("severity routing", () => {
|
|
|
728
731
|
expect(run.judged).toContain("note-1.security")
|
|
729
732
|
})
|
|
730
733
|
|
|
731
|
-
it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
|
|
732
|
-
const run = await drive({
|
|
733
|
-
notes: ["add — quote", "sub", "mul"],
|
|
734
|
-
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
735
|
-
})
|
|
736
|
-
expect(run.gates).toEqual([true, true])
|
|
737
|
-
expect(run.gateMessages[1]).toContain("NOT acted on")
|
|
738
|
-
expect(run.gateMessages[1]).toContain("note-1")
|
|
739
|
-
expect(run.gateMessages[0]).not.toContain("NOT acted on")
|
|
740
|
-
})
|
|
741
|
-
|
|
742
734
|
it("the first round's gate accepts a clean land", async () => {
|
|
743
735
|
const run = await drive({ notes: ["add", "sub", "mul"] })
|
|
744
736
|
expect(run.gates.length).toBeGreaterThan(0)
|
|
745
737
|
expect(run.gates.every((g) => g)).toBe(true)
|
|
746
738
|
})
|
|
747
739
|
|
|
748
|
-
it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
|
|
749
|
-
const run = await drive({
|
|
750
|
-
notes: ["add — quote", "sub — typo", "mul"],
|
|
751
|
-
answers: {
|
|
752
|
-
"note-1": verdict("edit"),
|
|
753
|
-
...sev("note-1", could),
|
|
754
|
-
"note-2": verdict("nit"),
|
|
755
|
-
},
|
|
756
|
-
reviewDocs: [undefined, doc(["add", "sub"])],
|
|
757
|
-
})
|
|
758
|
-
expect(run.log).toContain("review.defer")
|
|
759
|
-
expect(run.log.at(-1)).toBe("review.await-review")
|
|
760
|
-
expect(run.gates).toEqual([true, true])
|
|
761
|
-
})
|
|
762
|
-
|
|
763
|
-
it("a question and a could note in one round: the gate's base is the answering commit", async () => {
|
|
764
|
-
const run = await drive({
|
|
765
|
-
notes: ["add — why?", "sub — quote", "mul"],
|
|
766
|
-
answers: {
|
|
767
|
-
"note-1": verdict("question"),
|
|
768
|
-
"note-2": verdict("edit"),
|
|
769
|
-
...sev("note-2", could),
|
|
770
|
-
},
|
|
771
|
-
passSecondGate: true,
|
|
772
|
-
})
|
|
773
|
-
expect(run.log).toEqual([
|
|
774
|
-
"review.reviewing",
|
|
775
|
-
"review.await-review",
|
|
776
|
-
"review.triage",
|
|
777
|
-
"review.answer-review-questions",
|
|
778
|
-
"review.defer",
|
|
779
|
-
"review.await-review",
|
|
780
|
-
])
|
|
781
|
-
// c4 is the answering commit, not the defer commit (c5) or the round (c2)
|
|
782
|
-
expect(run.since.at(-1)).toBe("c4")
|
|
783
|
-
})
|
|
784
|
-
|
|
785
740
|
it("a blank reviewSeverityMinP makes every confident could critical", async () => {
|
|
786
741
|
const run = await drive({
|
|
787
742
|
notes: ["add — quote", "sub", "mul"],
|
|
788
743
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
789
744
|
vars: { reviewSeverityMinP: "" },
|
|
790
745
|
})
|
|
791
|
-
expect(run.
|
|
792
|
-
expect(run.log).not.toContain("review.defer")
|
|
746
|
+
expect(run.prompts.get("review.collecting")).toContain("(critical: judge unsure)")
|
|
793
747
|
})
|
|
794
748
|
})
|
|
795
749
|
|
|
@@ -807,7 +761,7 @@ describe("BuildTailOptions.lensBase", () => {
|
|
|
807
761
|
if (request.kind === "restart") return Promise.resolve()
|
|
808
762
|
if (request.kind === "agent" && request.name.startsWith("quality."))
|
|
809
763
|
prompts.push(request.prompt)
|
|
810
|
-
if (request.name === "review.
|
|
764
|
+
if (request.name === "review.composing") throw new Stop()
|
|
811
765
|
return Promise.resolve()
|
|
812
766
|
},
|
|
813
767
|
pushScope: () => undefined,
|
|
@@ -1048,10 +1002,12 @@ describe("buildTail without rules", () => {
|
|
|
1048
1002
|
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
1049
1003
|
if (request.name === "health.check") checks++
|
|
1050
1004
|
if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
|
|
1051
|
-
if (request.name === "review.
|
|
1005
|
+
if (request.name === "review.composing" || request.name === "health.stop")
|
|
1052
1006
|
throw new Stop()
|
|
1053
1007
|
return Promise.resolve(
|
|
1054
|
-
request.kind === "judge"
|
|
1008
|
+
request.kind === "judge"
|
|
1009
|
+
? { answers: { Q1: { answer: "mechanical", p: 0.9 } }, truncated: [] }
|
|
1010
|
+
: undefined,
|
|
1055
1011
|
)
|
|
1056
1012
|
},
|
|
1057
1013
|
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,18 +17,19 @@ 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"
|
|
27
31
|
import * as t from "../shared/text.js"
|
|
28
|
-
import { answerReviewQuestions, collecting,
|
|
32
|
+
import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
|
|
29
33
|
|
|
30
34
|
export type { ReviewOutcome } from "../../workflow-kit/index.js"
|
|
31
35
|
|
|
@@ -42,6 +46,12 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
|
|
|
42
46
|
|
|
43
47
|
type Tier = "critical" | "should" | "could"
|
|
44
48
|
|
|
49
|
+
const TIER_ORDER: Readonly<Record<Tier, number>> = { critical: 0, should: 1, could: 2 }
|
|
50
|
+
|
|
51
|
+
/** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
|
|
52
|
+
const captureKey = (s: Severity | undefined): number =>
|
|
53
|
+
s === undefined ? 2 : TIER_ORDER[s.tier] * 3 + s.rank
|
|
54
|
+
|
|
45
55
|
const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
|
|
46
56
|
security: ["yes", "no"],
|
|
47
57
|
reversibility: ["one-way", "two-way"],
|
|
@@ -56,10 +66,7 @@ interface Severity {
|
|
|
56
66
|
readonly rank: 0 | 1 | 2
|
|
57
67
|
}
|
|
58
68
|
|
|
59
|
-
/**
|
|
60
|
-
* Deferring is the risky direction: anything unanswered, unrecognised, cut or
|
|
61
|
-
* below `minP` is `critical`, never `could`.
|
|
62
|
-
*/
|
|
69
|
+
/** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
|
|
63
70
|
export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
|
|
64
71
|
const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
|
|
65
72
|
const a = (q: string): string | undefined => ans(q)?.answer
|
|
@@ -121,7 +128,7 @@ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
|
|
|
121
128
|
* nothing) plus four severity answers. Dismissing a note is the risky
|
|
122
129
|
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
123
130
|
* a cut, unanswered or below-floor note is an `edit`. Severity answers on a
|
|
124
|
-
* non-`edit` note are ignored
|
|
131
|
+
* non-`edit` note are ignored; on an `edit` they order the collecting turn.
|
|
125
132
|
*/
|
|
126
133
|
const triage =
|
|
127
134
|
(severities: Map<string, Severity>) =>
|
|
@@ -151,9 +158,8 @@ const triage =
|
|
|
151
158
|
? undefined
|
|
152
159
|
: VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
|
|
153
160
|
if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
return [n.id, severity.tier === "could" ? "defer" : "edit"]
|
|
161
|
+
severities.set(n.id, severityOf(n, judgment, severityMinP))
|
|
162
|
+
return [n.id, "edit"]
|
|
157
163
|
}),
|
|
158
164
|
)
|
|
159
165
|
}
|
|
@@ -161,10 +167,41 @@ const triage =
|
|
|
161
167
|
export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
162
168
|
|
|
163
169
|
/**
|
|
164
|
-
*
|
|
165
|
-
*
|
|
166
|
-
*
|
|
167
|
-
*
|
|
170
|
+
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
171
|
+
* turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
|
|
172
|
+
* after fixed nits restores the doc as committed at `carry`, answers kept;
|
|
173
|
+
* a fresh one leaves out the notes the human already answered.
|
|
174
|
+
*/
|
|
175
|
+
const composing = (
|
|
176
|
+
base: string,
|
|
177
|
+
report: LensReport,
|
|
178
|
+
carry?: string,
|
|
179
|
+
nitFix?: string,
|
|
180
|
+
answered: ReadonlySet<string> = new Set(),
|
|
181
|
+
): Promise<void> =>
|
|
182
|
+
run(
|
|
183
|
+
"review.composing",
|
|
184
|
+
carry === undefined
|
|
185
|
+
? writeScript(
|
|
186
|
+
REVIEW,
|
|
187
|
+
t.composedReview(base, {
|
|
188
|
+
...report,
|
|
189
|
+
weakened: kit.removedAssertions(changesSince(base)),
|
|
190
|
+
nitFix,
|
|
191
|
+
answered,
|
|
192
|
+
}),
|
|
193
|
+
[QUALITY],
|
|
194
|
+
)
|
|
195
|
+
: showScript(carry, REVIEW, REVIEW),
|
|
196
|
+
{ label: "Composing the review", file: REVIEW, base },
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The quality lap's notes reach a human over everything since `base`, who
|
|
201
|
+
* answers each one — fix as suggested, won't fix, or their own answer — and
|
|
202
|
+
* may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
|
|
203
|
+
* answer or comment is judged: edits go to another planning lap, questions
|
|
204
|
+
* are answered in place, nits fixed in one batch, praise dropped.
|
|
168
205
|
*/
|
|
169
206
|
export const review = (
|
|
170
207
|
base: string,
|
|
@@ -179,40 +216,31 @@ export const review = (
|
|
|
179
216
|
"gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
|
|
180
217
|
}
|
|
181
218
|
const severities = new Map<string, Severity>()
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
219
|
+
// Set by a nit fix, so the fresh review that follows names its commit.
|
|
220
|
+
let nitFix: string | undefined
|
|
221
|
+
// Every note the human answered in this review, left out of a fresh one.
|
|
222
|
+
const answered = new Set<string>()
|
|
185
223
|
return kit.review(base, {
|
|
186
224
|
file: REVIEW,
|
|
187
225
|
feedback: feedback.file,
|
|
188
226
|
isCode,
|
|
189
227
|
reviewing: (b, carry) => {
|
|
190
|
-
|
|
191
|
-
|
|
228
|
+
const fixedAt = nitFix
|
|
229
|
+
nitFix = undefined
|
|
230
|
+
return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered)
|
|
192
231
|
},
|
|
193
232
|
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
194
233
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
195
|
-
awaitReview: (b) =>
|
|
196
|
-
const notes = deferred
|
|
197
|
-
deferred = []
|
|
198
|
-
return notes.length > 0
|
|
199
|
-
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
|
|
200
|
-
: awaitReview(b)
|
|
201
|
-
},
|
|
234
|
+
awaitReview: (b) => awaitReview(b),
|
|
202
235
|
reviewMissing,
|
|
203
236
|
collecting: feedback.collecting,
|
|
204
237
|
triage: triage(severities),
|
|
205
|
-
|
|
206
|
-
deferred = notes.map((n) => ({
|
|
207
|
-
id: n.id,
|
|
208
|
-
anchor: n.anchor,
|
|
209
|
-
text: n.text,
|
|
210
|
-
label: severities.get(n.id)!.reason,
|
|
211
|
-
}))
|
|
212
|
-
return deferNotes(deferred, head())
|
|
213
|
-
},
|
|
238
|
+
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
214
239
|
answerQuestions: answerReviewQuestions,
|
|
215
|
-
fixNits:
|
|
240
|
+
fixNits: async (notes) => {
|
|
241
|
+
await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
|
|
242
|
+
nitFix = head()
|
|
243
|
+
},
|
|
216
244
|
close: closeReview,
|
|
217
245
|
captures: {
|
|
218
246
|
edits: t.reviewEditsCapture,
|
|
@@ -221,14 +249,13 @@ export const review = (
|
|
|
221
249
|
t.reviewEditNotesCapture(
|
|
222
250
|
round,
|
|
223
251
|
edits
|
|
224
|
-
.map((n, i) => ({
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
(
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
)
|
|
252
|
+
.map((n, i) => ({
|
|
253
|
+
n,
|
|
254
|
+
i,
|
|
255
|
+
s: severities.get(n.id),
|
|
256
|
+
key: captureKey(severities.get(n.id)),
|
|
257
|
+
}))
|
|
258
|
+
.sort((x, y) => x.key - y.key || x.i - y.i)
|
|
232
259
|
.map(({ n, s }) => ({
|
|
233
260
|
id: n.id,
|
|
234
261
|
anchor: n.anchor,
|
|
@@ -259,11 +286,13 @@ export interface BuildTailOptions extends TailOptions {
|
|
|
259
286
|
readonly feedback?: FeedbackTarget | undefined
|
|
260
287
|
/** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
|
|
261
288
|
readonly fixAccess?: ScopeAccess | undefined
|
|
262
|
-
/**
|
|
289
|
+
/** @deprecated No reviewer turn reads it since the review is composed. */
|
|
263
290
|
readonly lap?: t.ReviewRange | undefined
|
|
291
|
+
/** What the triaged quality lap left for the review; absent: nothing. */
|
|
292
|
+
readonly report?: LensReport | undefined
|
|
264
293
|
}
|
|
265
294
|
|
|
266
|
-
/** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
|
|
295
|
+
/** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
|
|
267
296
|
export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
|
|
268
297
|
/** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
|
|
269
298
|
export function buildTail(
|
|
@@ -287,7 +316,10 @@ export function buildTail(
|
|
|
287
316
|
)
|
|
288
317
|
}
|
|
289
318
|
const [base, form] = options
|
|
290
|
-
return tail((escalations) => review(base, escalations, form),
|
|
319
|
+
return tail((escalations, report) => review(base, escalations, { ...form, report }), {
|
|
320
|
+
...form,
|
|
321
|
+
triage: true,
|
|
322
|
+
})
|
|
291
323
|
}
|
|
292
324
|
|
|
293
325
|
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
|
|
@@ -111,18 +111,6 @@ describe("the feature workflow's own steps declare skills — a step's rendered
|
|
|
111
111
|
expect(scopes).toEqual([{ name: "review.fix.nits", access }])
|
|
112
112
|
expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
|
|
113
113
|
})
|
|
114
|
-
|
|
115
|
-
it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
|
|
116
|
-
const request = await capture(
|
|
117
|
-
() =>
|
|
118
|
-
steps.deferNotes([{ id: "note-1", anchor: "a", text: "x", label: "could" }], "abcdef123"),
|
|
119
|
-
"build",
|
|
120
|
-
)
|
|
121
|
-
if (request.kind !== "run") throw new Error("unreachable")
|
|
122
|
-
expect(request.name).toBe("review.defer")
|
|
123
|
-
expect(request.body).toContain("## Deferred review notes (abcdef1)")
|
|
124
|
-
expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
|
|
125
|
-
})
|
|
126
114
|
})
|
|
127
115
|
|
|
128
116
|
describe("the feature workflow's planning steps run on the planner model", () => {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { human,
|
|
1
|
+
import { human, scope, type ScopeAccess } from "../../flows/index.js"
|
|
2
2
|
import type { CollectRound } from "../../workflow-kit/index.js"
|
|
3
3
|
import * as t from "../shared/text.js"
|
|
4
4
|
import { riskTierOn } from "./tier.js"
|
|
@@ -52,8 +52,8 @@ export const fixNits = (
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
|
|
55
|
-
export const collecting = (capture: string): Promise<void> =>
|
|
56
|
-
t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture), {
|
|
55
|
+
export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
|
|
56
|
+
t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
|
|
57
57
|
label: "Collecting your feedback",
|
|
58
58
|
file: REQUIREMENTS,
|
|
59
59
|
mode: "qa",
|
|
@@ -79,10 +79,3 @@ export const refactorCollecting = (
|
|
|
79
79
|
allowEmpty: true,
|
|
80
80
|
},
|
|
81
81
|
)
|
|
82
|
-
|
|
83
|
-
/** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
|
|
84
|
-
export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
|
|
85
|
-
run("review.defer", t.deferNotesScript(notes, round), {
|
|
86
|
-
label: "Deferring could-notes to TODO.md",
|
|
87
|
-
file: ".gtd/TODO.md",
|
|
88
|
-
})
|