@pmelab/gtd 24.2.0 → 24.4.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 -6
- package/dist/chunks/{Schema-Dh-mkwkq.mjs → Schema-B5yK7QrT.mjs} +47 -1
- package/dist/chunks/{reserved-ejn-LVYO.mjs → reserved-B9pG93Fc.mjs} +53 -13
- package/dist/chunks/{steering-D-PvRC5W.mjs → steering-DVILanVf.mjs} +16 -5
- package/dist/flows.mjs +9 -1
- package/dist/gtd.bundle.mjs +1114 -865
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +1135 -218
- package/dist/workflow-kit.mjs +9 -7
- package/dist/workflows/feature.mjs +152 -35
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +1 -1
- package/dist/workflows.mjs +223 -54
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +57 -0
- package/src/cli/Cli.ts +89 -9
- package/src/cli/History.test.ts +225 -0
- package/src/cli/History.ts +143 -0
- package/src/cli/Output.ts +68 -0
- package/src/cli/Program.test.ts +10 -9
- package/src/cli/Program.ts +24 -72
- package/src/edge/Edge.test.ts +261 -3
- package/src/edge/Edge.ts +218 -41
- package/src/edge/index.ts +1 -0
- package/src/emit/GitScript.test.ts +27 -0
- package/src/emit/GitScript.ts +18 -2
- package/src/emit/index.ts +2 -0
- package/src/flows/scripts.ts +6 -0
- package/src/platform/Git.test.ts +6 -9
- package/src/platform/Git.ts +48 -10
- package/src/platform/RetainedHistory.test.ts +14 -0
- package/src/platform/RetainedHistory.ts +5 -0
- package/src/platform/index.ts +7 -1
- package/src/replay/Trailers.test.ts +32 -0
- package/src/replay/Trailers.ts +17 -0
- package/src/steering/FORMATS.md +1 -1
- package/src/steering/SteeringFormat.test.ts +9 -1
- package/src/steering/SteeringFormat.ts +7 -0
- package/src/steering/contract.test.ts +3 -0
- package/src/steering/index.ts +1 -0
- package/src/steering/review.test.ts +30 -0
- package/src/steering/review.ts +8 -2
- package/src/step/LandStep.ts +3 -1
- package/src/step/RepoSnapshot.ts +7 -1
- package/src/step/planStart.test.ts +6 -3
- package/src/step/planStep.test.ts +29 -1
- package/src/step/planStep.ts +8 -0
- package/src/testkit/EmittedScriptRecognizer.test.ts +29 -30
- package/src/testkit/EmittedScriptRecognizer.ts +149 -52
- package/src/testkit/EmittedScripts.test.ts +166 -0
- package/src/testkit/FakeGitOperations.ts +6 -2
- package/src/testkit/FlowScripts.test.ts +242 -0
- package/src/testkit/FlowScripts.ts +326 -0
- package/src/testkit/GitTiers.ts +269 -47
- package/src/testkit/InMemRepo.ts +61 -8
- package/src/testkit/Layers.ts +10 -2
- package/src/testkit/TestRepo.ts +4 -1
- package/src/testkit/index.ts +1 -1
- package/src/workflow/discovery.test.ts +147 -0
- package/src/workflow/discovery.ts +5 -0
- package/src/workflow/fingerprint.test.ts +20 -4
- package/src/workflow/fingerprint.ts +8 -9
- package/src/workflow/index.ts +1 -6
- package/src/workflow/load.test.ts +5 -19
- package/src/workflow/load.ts +94 -10
- package/src/workflow-kit/review.test.ts +84 -0
- package/src/workflow-kit/review.ts +9 -6
- package/src/workflows/feature/review.test.ts +238 -12
- package/src/workflows/feature/review.ts +172 -31
- package/src/workflows/feature/steps.test.ts +12 -0
- package/src/workflows/feature/steps.ts +8 -1
- package/src/workflows/shared/access.test.ts +4 -2
- package/src/workflows/shared/access.ts +1 -0
- package/src/workflows/shared/options.test.ts +8 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/steps.ts +2 -0
- package/src/workflows/shared/tail.ts +3 -11
- package/src/workflows/shared/text.test.ts +106 -0
- package/src/workflows/shared/text.ts +57 -1
- package/src/workflows/shared/vars.ts +1 -0
|
@@ -5,7 +5,8 @@ import {
|
|
|
5
5
|
type JudgeAnswer,
|
|
6
6
|
type StepRequest,
|
|
7
7
|
} from "../../flows/index.js"
|
|
8
|
-
import { review, type ReviewOutcome } from "./review.js"
|
|
8
|
+
import { review, severityOf, type ReviewOutcome } from "./review.js"
|
|
9
|
+
import type { ReviewNote } from "../../steering/index.js"
|
|
9
10
|
import * as bundled from "../index.js"
|
|
10
11
|
import { fixtureContext } from "../shared/text.fixture.js"
|
|
11
12
|
|
|
@@ -38,6 +39,8 @@ interface Drive {
|
|
|
38
39
|
readonly after?: string
|
|
39
40
|
/** The REVIEW.md each `review.reviewing` turn writes, in order; also raises the review-turn stop to one past the last. */
|
|
40
41
|
readonly reviewDocs?: readonly (string | undefined)[]
|
|
42
|
+
/** Let the run pass the second gate, stopping at the first range read after it. */
|
|
43
|
+
readonly passSecondGate?: boolean
|
|
41
44
|
}
|
|
42
45
|
|
|
43
46
|
interface Run {
|
|
@@ -45,6 +48,11 @@ interface Run {
|
|
|
45
48
|
readonly prompts: Map<string, string>
|
|
46
49
|
readonly result: ReviewOutcome | "stopped"
|
|
47
50
|
readonly judged: readonly string[]
|
|
51
|
+
/** `acceptClean` of each `review.await-review` gate, in order. */
|
|
52
|
+
readonly gates: readonly boolean[]
|
|
53
|
+
readonly gateMessages: readonly string[]
|
|
54
|
+
/** Every `changesSince` argument, in order. */
|
|
55
|
+
readonly since: readonly string[]
|
|
48
56
|
}
|
|
49
57
|
|
|
50
58
|
/** Run `review` against a scripted context; a second pass at the gate or a fresh review stops the run. */
|
|
@@ -52,6 +60,9 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
52
60
|
const log: string[] = []
|
|
53
61
|
const prompts = new Map<string, string>()
|
|
54
62
|
const judged: string[] = []
|
|
63
|
+
const gates: boolean[] = []
|
|
64
|
+
const gateMessages: string[] = []
|
|
65
|
+
const since: string[] = []
|
|
55
66
|
const files = new Map<string, string>([[REVIEW, d.after ?? doc(d.notes)]])
|
|
56
67
|
let steps = 0
|
|
57
68
|
let nitsFixed = false
|
|
@@ -65,7 +76,7 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
65
76
|
if (written !== undefined) files.set(REVIEW, written)
|
|
66
77
|
},
|
|
67
78
|
"review.await-review": () => {
|
|
68
|
-
if (++awaits > 1) throw new Stop()
|
|
79
|
+
if (++awaits > (d.passSecondGate ? 2 : 1)) throw new Stop()
|
|
69
80
|
},
|
|
70
81
|
"review.closing": () => void files.delete(REVIEW),
|
|
71
82
|
"review.collecting": () => void files.set(REQUIREMENTS, "## Concern"),
|
|
@@ -73,6 +84,14 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
73
84
|
nitsFixed = true
|
|
74
85
|
},
|
|
75
86
|
}
|
|
87
|
+
const record = (request: StepRequest): void => {
|
|
88
|
+
if (request.kind === "human" && request.name === "review.await-review") {
|
|
89
|
+
gates.push(request.options.acceptClean === true)
|
|
90
|
+
gateMessages.push(request.options.message ?? "")
|
|
91
|
+
}
|
|
92
|
+
if (request.kind === "agent") prompts.set(request.name, request.prompt)
|
|
93
|
+
if (request.kind === "judge") judged.push(...request.questions.map((q) => q.id))
|
|
94
|
+
}
|
|
76
95
|
const context = fixtureContext(
|
|
77
96
|
{ vars: d.vars ?? {} },
|
|
78
97
|
{
|
|
@@ -80,10 +99,9 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
80
99
|
steps++
|
|
81
100
|
if (request.kind === "restart") return Promise.resolve()
|
|
82
101
|
log.push(request.name)
|
|
83
|
-
|
|
102
|
+
record(request)
|
|
84
103
|
effects[request.name]?.()
|
|
85
104
|
if (request.kind !== "judge") return Promise.resolve()
|
|
86
|
-
judged.push(...request.questions.map((q) => q.id))
|
|
87
105
|
return Promise.resolve({ answers: d.answers ?? {}, truncated: d.truncated ?? [] })
|
|
88
106
|
},
|
|
89
107
|
refuse: (message: string): never => {
|
|
@@ -92,12 +110,16 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
92
110
|
pushScope: () => undefined,
|
|
93
111
|
popScope: () => undefined,
|
|
94
112
|
read: (path) => files.get(path),
|
|
95
|
-
changesSince: (): readonly Change[] =>
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
:
|
|
100
|
-
|
|
113
|
+
changesSince: (from: string): readonly Change[] => {
|
|
114
|
+
since.push(from)
|
|
115
|
+
if (d.passSecondGate && awaits === 2) throw new Stop()
|
|
116
|
+
return [
|
|
117
|
+
{ path: REVIEW, status: "modified", before: BEFORE, after: files.get(REVIEW) },
|
|
118
|
+
...(nitsFixed
|
|
119
|
+
? [{ path: "src/calc.ts", status: "modified" as const, before: "a", after: "b" }]
|
|
120
|
+
: []),
|
|
121
|
+
]
|
|
122
|
+
},
|
|
101
123
|
head: () => `c${steps}`,
|
|
102
124
|
start: () => "c0",
|
|
103
125
|
},
|
|
@@ -110,7 +132,7 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
110
132
|
if (!(error instanceof Stop)) throw error
|
|
111
133
|
result = "stopped"
|
|
112
134
|
}
|
|
113
|
-
return { log, prompts, result, judged }
|
|
135
|
+
return { log, prompts, result, judged, gates, gateMessages, since }
|
|
114
136
|
}
|
|
115
137
|
|
|
116
138
|
const verdict = (answer: string, p = 0.9): JudgeAnswer => ({ answer, p })
|
|
@@ -179,7 +201,18 @@ describe("review verdict routing", () => {
|
|
|
179
201
|
|
|
180
202
|
it("judges one note per added note, as one judge rest", async () => {
|
|
181
203
|
const run = await drive({ notes: ["add — a", "sub — b", "mul"] })
|
|
182
|
-
expect(run.judged).toEqual([
|
|
204
|
+
expect(run.judged).toEqual([
|
|
205
|
+
"note-1",
|
|
206
|
+
"note-1.security",
|
|
207
|
+
"note-1.reversibility",
|
|
208
|
+
"note-1.blastRadius",
|
|
209
|
+
"note-1.newConcept",
|
|
210
|
+
"note-2",
|
|
211
|
+
"note-2.security",
|
|
212
|
+
"note-2.reversibility",
|
|
213
|
+
"note-2.blastRadius",
|
|
214
|
+
"note-2.newConcept",
|
|
215
|
+
])
|
|
183
216
|
expect(run.log.filter((n) => n === "review.triage")).toHaveLength(1)
|
|
184
217
|
})
|
|
185
218
|
|
|
@@ -383,3 +416,196 @@ describe("scenario wording in review's fix turns", () => {
|
|
|
383
416
|
expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
|
|
384
417
|
})
|
|
385
418
|
})
|
|
419
|
+
|
|
420
|
+
describe("severityOf", () => {
|
|
421
|
+
const note = (forced = false): ReviewNote => ({
|
|
422
|
+
id: "n",
|
|
423
|
+
kind: "pointer",
|
|
424
|
+
anchor: "a",
|
|
425
|
+
before: "",
|
|
426
|
+
text: "t",
|
|
427
|
+
forced,
|
|
428
|
+
})
|
|
429
|
+
const ans = (o: Partial<Record<string, string>> = {}, p = 0.9) => {
|
|
430
|
+
const base: Record<string, string> = {
|
|
431
|
+
security: "no",
|
|
432
|
+
reversibility: "two-way",
|
|
433
|
+
blastRadius: "local",
|
|
434
|
+
newConcept: "no",
|
|
435
|
+
...o,
|
|
436
|
+
}
|
|
437
|
+
return Object.fromEntries(Object.entries(base).map(([k, v]) => [`n.${k}`, { answer: v, p }]))
|
|
438
|
+
}
|
|
439
|
+
const j = (answers: ReturnType<typeof ans>, truncated: string[] = []) => ({ answers, truncated })
|
|
440
|
+
|
|
441
|
+
it("security or one-way is critical and names each answer", () => {
|
|
442
|
+
expect(severityOf(note(), j(ans({ security: "yes" })), 0.7)).toMatchObject({
|
|
443
|
+
tier: "critical",
|
|
444
|
+
reason: "critical: security",
|
|
445
|
+
})
|
|
446
|
+
expect(
|
|
447
|
+
severityOf(note(), j(ans({ security: "yes", reversibility: "one-way" })), 0.7),
|
|
448
|
+
).toMatchObject({ tier: "critical", reason: "critical: security, one-way door", rank: 0 })
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
it("contract or new concept is should; neither is could", () => {
|
|
452
|
+
expect(severityOf(note(), j(ans({ blastRadius: "contract" })), 0.7).tier).toBe("should")
|
|
453
|
+
expect(severityOf(note(), j(ans({ newConcept: "yes" })), 0.7)).toMatchObject({
|
|
454
|
+
tier: "should",
|
|
455
|
+
reason: "should: new concept",
|
|
456
|
+
rank: 1,
|
|
457
|
+
})
|
|
458
|
+
expect(severityOf(note(), j(ans()), 0.7)).toMatchObject({ tier: "could", rank: 2 })
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
it("unsure is critical: unanswered, unrecognised, cut, low p, blank floor", () => {
|
|
462
|
+
const a = ans()
|
|
463
|
+
delete (a as Record<string, unknown>)["n.security"]
|
|
464
|
+
for (const x of [
|
|
465
|
+
severityOf(note(), j(a), 0.7),
|
|
466
|
+
severityOf(note(), j(ans({ security: "unsure" })), 0.7),
|
|
467
|
+
severityOf(note(), j(ans(), ["n"]), 0.7),
|
|
468
|
+
severityOf(note(), j(ans({}, 0.5)), 0.7),
|
|
469
|
+
severityOf(note(), j(ans()), Infinity),
|
|
470
|
+
])
|
|
471
|
+
expect(x).toMatchObject({ tier: "critical", reason: "critical: judge unsure" })
|
|
472
|
+
})
|
|
473
|
+
|
|
474
|
+
it("forced is critical regardless of answers", () => {
|
|
475
|
+
expect(severityOf(note(true), j({}), 0.7)).toMatchObject({
|
|
476
|
+
tier: "critical",
|
|
477
|
+
reason: "critical: forced by `!`",
|
|
478
|
+
})
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
|
|
482
|
+
describe("severity routing", () => {
|
|
483
|
+
const sev = (id: string, a: Record<string, string>): Record<string, JudgeAnswer> =>
|
|
484
|
+
Object.fromEntries(Object.entries(a).map(([k, v]) => [`${id}.${k}`, verdict(v)]))
|
|
485
|
+
const could = {
|
|
486
|
+
security: "no",
|
|
487
|
+
reversibility: "two-way",
|
|
488
|
+
blastRadius: "local",
|
|
489
|
+
newConcept: "no",
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
it("a forced note is asked nothing, not even the verdict, and is a critical edit", async () => {
|
|
493
|
+
const run = await drive({ notes: ["! rename it — now", "sub"] })
|
|
494
|
+
expect(run.judged.filter((id) => id.startsWith("note-1"))).toEqual([])
|
|
495
|
+
expect(run.log).toContain("review.collecting")
|
|
496
|
+
expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
|
|
497
|
+
})
|
|
498
|
+
|
|
499
|
+
it("a confident could edit defers and, alone, returns to the review gate", async () => {
|
|
500
|
+
const run = await drive({
|
|
501
|
+
notes: ["add — quote", "sub", "mul"],
|
|
502
|
+
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
503
|
+
})
|
|
504
|
+
expect(run.log).toEqual([
|
|
505
|
+
"review.reviewing",
|
|
506
|
+
"review.await-review",
|
|
507
|
+
"review.triage",
|
|
508
|
+
"review.defer",
|
|
509
|
+
"review.await-review",
|
|
510
|
+
])
|
|
511
|
+
})
|
|
512
|
+
|
|
513
|
+
it("severity answers on a nit leave it a nit", async () => {
|
|
514
|
+
const run = await drive({
|
|
515
|
+
notes: ["add — typo", "sub", "mul"],
|
|
516
|
+
answers: { "note-1": verdict("nit"), ...sev("note-1", { reversibility: "one-way" }) },
|
|
517
|
+
})
|
|
518
|
+
expect(run.log).toContain("review.fix.nits.fixing")
|
|
519
|
+
expect(run.log).not.toContain("review.collecting")
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
it("edits are captured critical before should, doors first, each labelled", async () => {
|
|
523
|
+
const run = await drive({
|
|
524
|
+
notes: ["add — plain", "sub — concept", "mul — door"],
|
|
525
|
+
answers: {
|
|
526
|
+
"note-1": verdict("edit"),
|
|
527
|
+
...sev("note-1", { ...could, security: "yes" }),
|
|
528
|
+
...sev("note-2", { ...could, newConcept: "yes" }),
|
|
529
|
+
...sev("note-3", { ...could, reversibility: "one-way" }),
|
|
530
|
+
},
|
|
531
|
+
})
|
|
532
|
+
const capture = run.prompts.get("review.collecting") ?? ""
|
|
533
|
+
const at = (id: string): number => capture.indexOf(`- ${id} —`)
|
|
534
|
+
expect(at("note-3")).toBeLessThan(at("note-1"))
|
|
535
|
+
expect(at("note-1")).toBeLessThan(at("note-2"))
|
|
536
|
+
expect(capture).toContain("(critical: one-way door)")
|
|
537
|
+
expect(capture).toContain("(should: new concept)")
|
|
538
|
+
expect(capture).toContain("(critical: security)")
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it("a round of only forced notes judges nothing and tiers each critical", async () => {
|
|
542
|
+
const run = await drive({ notes: ["! rename it", "! drop it", "mul"] })
|
|
543
|
+
expect(run.log).not.toContain("review.triage")
|
|
544
|
+
expect(run.judged).toEqual([])
|
|
545
|
+
expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
|
|
546
|
+
expect(run.prompts.get("review.collecting")).not.toContain("! rename")
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it("a note opening with != is not forced and keeps its text", async () => {
|
|
550
|
+
const run = await drive({ notes: ["!= should be ===", "sub"] })
|
|
551
|
+
expect(run.judged).toContain("note-1.security")
|
|
552
|
+
})
|
|
553
|
+
|
|
554
|
+
it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
|
|
555
|
+
const run = await drive({
|
|
556
|
+
notes: ["add — quote", "sub", "mul"],
|
|
557
|
+
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
558
|
+
})
|
|
559
|
+
expect(run.gates).toEqual([false, true])
|
|
560
|
+
expect(run.gateMessages[1]).toContain("NOT acted on")
|
|
561
|
+
expect(run.gateMessages[1]).toContain("note-1")
|
|
562
|
+
expect(run.gateMessages[0]).not.toContain("NOT acted on")
|
|
563
|
+
})
|
|
564
|
+
|
|
565
|
+
it("a later gate does not accept a clean land after a deferral round with a nit", async () => {
|
|
566
|
+
const run = await drive({
|
|
567
|
+
notes: ["add — quote", "sub — typo", "mul"],
|
|
568
|
+
answers: {
|
|
569
|
+
"note-1": verdict("edit"),
|
|
570
|
+
...sev("note-1", could),
|
|
571
|
+
"note-2": verdict("nit"),
|
|
572
|
+
},
|
|
573
|
+
reviewDocs: [undefined, doc(["add", "sub"])],
|
|
574
|
+
})
|
|
575
|
+
expect(run.log).toContain("review.defer")
|
|
576
|
+
expect(run.log.at(-1)).toBe("review.await-review")
|
|
577
|
+
expect(run.gates).toEqual([false, false])
|
|
578
|
+
})
|
|
579
|
+
|
|
580
|
+
it("a question and a could note in one round: the gate's base is the answering commit", async () => {
|
|
581
|
+
const run = await drive({
|
|
582
|
+
notes: ["add — why?", "sub — quote", "mul"],
|
|
583
|
+
answers: {
|
|
584
|
+
"note-1": verdict("question"),
|
|
585
|
+
"note-2": verdict("edit"),
|
|
586
|
+
...sev("note-2", could),
|
|
587
|
+
},
|
|
588
|
+
passSecondGate: true,
|
|
589
|
+
})
|
|
590
|
+
expect(run.log).toEqual([
|
|
591
|
+
"review.reviewing",
|
|
592
|
+
"review.await-review",
|
|
593
|
+
"review.triage",
|
|
594
|
+
"review.answer-review-questions",
|
|
595
|
+
"review.defer",
|
|
596
|
+
"review.await-review",
|
|
597
|
+
])
|
|
598
|
+
// c4 is the answering commit, not the defer commit (c5) or the round (c2)
|
|
599
|
+
expect(run.since.at(-1)).toBe("c4")
|
|
600
|
+
})
|
|
601
|
+
|
|
602
|
+
it("a blank reviewSeverityMinP makes every confident could critical", async () => {
|
|
603
|
+
const run = await drive({
|
|
604
|
+
notes: ["add — quote", "sub", "mul"],
|
|
605
|
+
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
606
|
+
vars: { reviewSeverityMinP: "" },
|
|
607
|
+
})
|
|
608
|
+
expect(run.log).toContain("review.collecting")
|
|
609
|
+
expect(run.log).not.toContain("review.defer")
|
|
610
|
+
})
|
|
611
|
+
})
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
answered,
|
|
3
|
+
head,
|
|
4
|
+
judge,
|
|
5
|
+
numeric,
|
|
6
|
+
vars,
|
|
7
|
+
type JudgeQuestion,
|
|
8
|
+
type Judgment,
|
|
9
|
+
} from "../../flows/index.js"
|
|
2
10
|
import type { ReviewNote } from "../../steering/index.js"
|
|
3
11
|
import * as kit from "../../workflow-kit/index.js"
|
|
4
12
|
import { healthy, type EscalationCount } from "../shared/health.js"
|
|
@@ -15,7 +23,7 @@ import {
|
|
|
15
23
|
import { closeReview, tail } from "../shared/tail.js"
|
|
16
24
|
import * as t from "../shared/text.js"
|
|
17
25
|
import type { BuiltPlan } from "./packages.js"
|
|
18
|
-
import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
|
|
26
|
+
import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
|
|
19
27
|
|
|
20
28
|
export type { ReviewOutcome } from "../../workflow-kit/index.js"
|
|
21
29
|
|
|
@@ -30,33 +38,124 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
|
|
|
30
38
|
"edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do.",
|
|
31
39
|
})
|
|
32
40
|
|
|
41
|
+
type Tier = "critical" | "should" | "could"
|
|
42
|
+
|
|
43
|
+
const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
|
|
44
|
+
security: ["yes", "no"],
|
|
45
|
+
reversibility: ["one-way", "two-way"],
|
|
46
|
+
blastRadius: ["local", "module", "contract"],
|
|
47
|
+
newConcept: ["yes", "no"],
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Severity {
|
|
51
|
+
readonly tier: Tier
|
|
52
|
+
readonly reason: string
|
|
53
|
+
/** Capture order: 0 one-way door, 1 new concept, 2 everything else. */
|
|
54
|
+
readonly rank: 0 | 1 | 2
|
|
55
|
+
}
|
|
56
|
+
|
|
33
57
|
/**
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
* a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
|
|
37
|
-
* every note one.
|
|
58
|
+
* Deferring is the risky direction: anything unanswered, unrecognised, cut or
|
|
59
|
+
* below `minP` is `critical`, never `could`.
|
|
38
60
|
*/
|
|
39
|
-
const
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
51
|
-
const
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
})
|
|
57
|
-
|
|
61
|
+
export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
|
|
62
|
+
const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
|
|
63
|
+
const a = (q: string): string | undefined => ans(q)?.answer
|
|
64
|
+
const rank = (): 0 | 1 | 2 =>
|
|
65
|
+
a("reversibility") === "one-way" ? 0 : a("newConcept") === "yes" ? 1 : 2
|
|
66
|
+
if (note.forced) return { tier: "critical", reason: "critical: forced by `!`", rank: 2 }
|
|
67
|
+
const unsure =
|
|
68
|
+
judgment.truncated.includes(note.id) ||
|
|
69
|
+
Object.entries(SEVERITY_ANSWERS).some(
|
|
70
|
+
([q, allowed]) => !allowed.includes(a(q) ?? "") || !((ans(q)?.p ?? NaN) >= minP),
|
|
71
|
+
)
|
|
72
|
+
if (unsure) return { tier: "critical", reason: "critical: judge unsure", rank: 2 }
|
|
73
|
+
const critical = [
|
|
74
|
+
...(a("security") === "yes" ? ["security"] : []),
|
|
75
|
+
...(a("reversibility") === "one-way" ? ["one-way door"] : []),
|
|
76
|
+
]
|
|
77
|
+
if (critical.length > 0)
|
|
78
|
+
return { tier: "critical", reason: `critical: ${critical.join(", ")}`, rank: rank() }
|
|
79
|
+
const should = [
|
|
80
|
+
...(a("blastRadius") === "contract" ? ["contract"] : []),
|
|
81
|
+
...(a("newConcept") === "yes" ? ["new concept"] : []),
|
|
82
|
+
]
|
|
83
|
+
if (should.length > 0)
|
|
84
|
+
return { tier: "should", reason: `should: ${should.join(", ")}`, rank: rank() }
|
|
85
|
+
return { tier: "could", reason: "could", rank: 2 }
|
|
58
86
|
}
|
|
59
87
|
|
|
88
|
+
const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
|
|
89
|
+
{
|
|
90
|
+
id: `${note.id}.security`,
|
|
91
|
+
primitive: "noul",
|
|
92
|
+
instructions: `Does the change the human asks for in note "${note.id}" (on "${note.anchor}") touch security — authentication, authorisation, secrets, untrusted input or data exposure?`,
|
|
93
|
+
criteria: "yes: it changes or could weaken a security property. no: it does not.",
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
id: `${note.id}.reversibility`,
|
|
97
|
+
primitive: "choice",
|
|
98
|
+
instructions: `Once the change in note "${note.id}" ships, can it be undone without breaking anyone who already depends on it?`,
|
|
99
|
+
criteria:
|
|
100
|
+
"one-way: a published API, file format, persisted data, protocol or other contract that cannot be taken back. two-way: can be reverted freely.",
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
id: `${note.id}.blastRadius`,
|
|
104
|
+
primitive: "choice",
|
|
105
|
+
instructions: `How far does the change in note "${note.id}" reach?`,
|
|
106
|
+
criteria:
|
|
107
|
+
"local: one function or file. module: a whole module or several files. contract: a public interface other code or users depend on.",
|
|
108
|
+
},
|
|
109
|
+
{
|
|
110
|
+
id: `${note.id}.newConcept`,
|
|
111
|
+
primitive: "noul",
|
|
112
|
+
instructions: `Does the change in note "${note.id}" introduce a concept new to this codebase — a new abstraction, entity or term? Judge from the evidence, never a glossary; reusing or extending an existing concept is not new.`,
|
|
113
|
+
criteria: "yes: a genuinely new concept. no: reuses or extends an existing one.",
|
|
114
|
+
},
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* One judge rest: a verdict per unforced note (a forced note is asked
|
|
119
|
+
* nothing) plus four severity answers. Dismissing a note is the risky
|
|
120
|
+
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
121
|
+
* a cut, unanswered or below-floor note is an `edit`. Severity answers on a
|
|
122
|
+
* non-`edit` note are ignored. A confident `could` edit becomes `defer`.
|
|
123
|
+
*/
|
|
124
|
+
const triage =
|
|
125
|
+
(severities: Map<string, Severity>) =>
|
|
126
|
+
async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, kit.Verdict>> => {
|
|
127
|
+
const asked = notes.filter((n) => !n.forced)
|
|
128
|
+
const judgment: Judgment =
|
|
129
|
+
asked.length === 0
|
|
130
|
+
? { answers: {}, truncated: [] }
|
|
131
|
+
: await judge("review.triage", {
|
|
132
|
+
questions: asked.flatMap((n) => [verdictQuestion(n), ...severityQuestions(n)]),
|
|
133
|
+
evidence: Object.fromEntries(
|
|
134
|
+
asked.map((n) => [
|
|
135
|
+
n.id,
|
|
136
|
+
`Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
|
|
137
|
+
]),
|
|
138
|
+
),
|
|
139
|
+
message: t.buildReviewTriageMessage(),
|
|
140
|
+
label: "Judging each note",
|
|
141
|
+
})
|
|
142
|
+
const { answers, truncated } = judgment
|
|
143
|
+
const minP = numeric(vars.reviewNoteActionable, Infinity)
|
|
144
|
+
const severityMinP = numeric(vars.reviewSeverityMinP, Infinity)
|
|
145
|
+
severities.clear()
|
|
146
|
+
return new Map(
|
|
147
|
+
notes.map((n): [string, kit.Verdict] => {
|
|
148
|
+
const verdict = n.forced
|
|
149
|
+
? undefined
|
|
150
|
+
: VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
|
|
151
|
+
if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
|
|
152
|
+
const severity = severityOf(n, judgment, severityMinP)
|
|
153
|
+
severities.set(n.id, severity)
|
|
154
|
+
return [n.id, severity.tier === "could" ? "defer" : "edit"]
|
|
155
|
+
}),
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
|
|
60
159
|
const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
|
|
61
160
|
|
|
62
161
|
/**
|
|
@@ -69,25 +168,66 @@ export const review = (
|
|
|
69
168
|
base: string,
|
|
70
169
|
escalations: EscalationCount = { rounds: 0 },
|
|
71
170
|
frozen?: FrozenScenarios,
|
|
72
|
-
): Promise<kit.ReviewOutcome> =>
|
|
73
|
-
|
|
171
|
+
): Promise<kit.ReviewOutcome> => {
|
|
172
|
+
const severities = new Map<string, Severity>()
|
|
173
|
+
// Armed by a defer-only round and spent at the very next gate; a fresh
|
|
174
|
+
// review (`reviewing`) disarms it, so a later gate never accepts a clean land.
|
|
175
|
+
let deferred: readonly t.NoteInput[] = []
|
|
176
|
+
return kit.review(base, {
|
|
74
177
|
file: REVIEW,
|
|
75
178
|
feedback: REQUIREMENTS,
|
|
76
179
|
isCode,
|
|
77
|
-
reviewing,
|
|
180
|
+
reviewing: (b, carry) => {
|
|
181
|
+
deferred = []
|
|
182
|
+
return reviewing(b, carry)
|
|
183
|
+
},
|
|
78
184
|
fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
|
|
79
185
|
keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
|
|
80
|
-
awaitReview
|
|
186
|
+
awaitReview: (b) => {
|
|
187
|
+
const notes = deferred
|
|
188
|
+
deferred = []
|
|
189
|
+
return notes.length > 0
|
|
190
|
+
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes), true)
|
|
191
|
+
: awaitReview(b)
|
|
192
|
+
},
|
|
81
193
|
reviewMissing,
|
|
82
194
|
collecting,
|
|
83
|
-
triage,
|
|
195
|
+
triage: triage(severities),
|
|
196
|
+
defer: (notes) => {
|
|
197
|
+
deferred = notes.map((n) => ({
|
|
198
|
+
id: n.id,
|
|
199
|
+
anchor: n.anchor,
|
|
200
|
+
text: n.text,
|
|
201
|
+
label: severities.get(n.id)!.reason,
|
|
202
|
+
}))
|
|
203
|
+
return deferNotes(deferred, head())
|
|
204
|
+
},
|
|
84
205
|
answerQuestions: answerReviewQuestions,
|
|
85
206
|
fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
|
|
86
207
|
close: closeReview,
|
|
87
208
|
captures: {
|
|
88
209
|
edits: t.reviewEditsCapture,
|
|
89
210
|
notes: t.reviewNotesCapture,
|
|
90
|
-
editNotes:
|
|
211
|
+
editNotes: (round, edits, answeredAt) =>
|
|
212
|
+
t.reviewEditNotesCapture(
|
|
213
|
+
round,
|
|
214
|
+
edits
|
|
215
|
+
.map((n, i) => ({ n, i, s: severities.get(n.id) }))
|
|
216
|
+
// critical before should, then one-way doors, then new concepts
|
|
217
|
+
.sort(
|
|
218
|
+
(x, y) =>
|
|
219
|
+
Number(x.s?.tier === "should") - Number(y.s?.tier === "should") ||
|
|
220
|
+
(x.s?.rank ?? 2) - (y.s?.rank ?? 2) ||
|
|
221
|
+
x.i - y.i,
|
|
222
|
+
)
|
|
223
|
+
.map(({ n, s }) => ({
|
|
224
|
+
id: n.id,
|
|
225
|
+
anchor: n.anchor,
|
|
226
|
+
text: n.forced ? n.text.replace(/^!\s*/, "") : n.text,
|
|
227
|
+
...(s === undefined ? {} : { label: s.reason }),
|
|
228
|
+
})),
|
|
229
|
+
answeredAt,
|
|
230
|
+
),
|
|
91
231
|
},
|
|
92
232
|
refusals: {
|
|
93
233
|
deleted:
|
|
@@ -96,6 +236,7 @@ export const review = (
|
|
|
96
236
|
"gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
|
|
97
237
|
},
|
|
98
238
|
})
|
|
239
|
+
}
|
|
99
240
|
|
|
100
241
|
/** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
|
|
101
242
|
export const buildTail = (
|
|
@@ -79,4 +79,16 @@ describe("the feature workflow's own steps declare skills — a step's rendered
|
|
|
79
79
|
expect(request.prompt).toContain("semicolon")
|
|
80
80
|
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
81
81
|
})
|
|
82
|
+
|
|
83
|
+
it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
|
|
84
|
+
const request = await capture(
|
|
85
|
+
() =>
|
|
86
|
+
steps.deferNotes([{ id: "note-1", anchor: "a", text: "x", label: "could" }], "abcdef123"),
|
|
87
|
+
"build",
|
|
88
|
+
)
|
|
89
|
+
if (request.kind !== "run") throw new Error("unreachable")
|
|
90
|
+
expect(request.name).toBe("review.defer")
|
|
91
|
+
expect(request.body).toContain("## Deferred review notes (abcdef1)")
|
|
92
|
+
expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
|
|
93
|
+
})
|
|
82
94
|
})
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { human } from "../../flows/index.js"
|
|
1
|
+
import { human, run } from "../../flows/index.js"
|
|
2
2
|
import * as t from "../shared/text.js"
|
|
3
3
|
import { ARCHITECTURE, coder, FEEDBACK, planner, REQUIREMENTS, REVIEW } from "../shared/steps.js"
|
|
4
4
|
|
|
@@ -90,3 +90,10 @@ export const collecting = (capture: string): Promise<void> =>
|
|
|
90
90
|
system: t.reviewerSystem(),
|
|
91
91
|
allowEmpty: true,
|
|
92
92
|
})
|
|
93
|
+
|
|
94
|
+
/** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
|
|
95
|
+
export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
|
|
96
|
+
run("review.defer", t.deferNotesScript(notes, round), {
|
|
97
|
+
label: "Deferring could-notes to TODO.md",
|
|
98
|
+
file: ".gtd/TODO.md",
|
|
99
|
+
})
|
|
@@ -13,6 +13,7 @@ describe("the bundled workflow's access export", () => {
|
|
|
13
13
|
architecture: { write: [".gtd/REQUIREMENTS.md"] },
|
|
14
14
|
"architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
|
|
15
15
|
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
16
|
+
"build.review.defer": { write: [".gtd/TODO.md"] },
|
|
16
17
|
"build.review.fix.nits": {},
|
|
17
18
|
"build.review.fix.risks": {},
|
|
18
19
|
"build.quality.correctness": { write: [] },
|
|
@@ -33,8 +34,9 @@ describe("the bundled workflow's access export", () => {
|
|
|
33
34
|
).toEqual(["build.quality.correctness", "build.quality.my-lens"])
|
|
34
35
|
})
|
|
35
36
|
|
|
36
|
-
it("keys only
|
|
37
|
-
|
|
37
|
+
it("keys only turn or script scopes", () => {
|
|
38
|
+
// `build.review.defer` is a script scope: no turn, so no skills entry.
|
|
39
|
+
const known = [...Object.keys(workflow.skills(defaults)), "build.review.defer"]
|
|
38
40
|
for (const key of Object.keys(bundled)) expect(known).toContain(key)
|
|
39
41
|
})
|
|
40
42
|
})
|
|
@@ -13,6 +13,7 @@ export const access = (
|
|
|
13
13
|
architecture: { write: [".gtd/REQUIREMENTS.md"] },
|
|
14
14
|
"architecture.decompose": { write: [".gtd/packages/**", ".gtd/ARCHITECTURE.md"] },
|
|
15
15
|
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
16
|
+
"build.review.defer": { write: [".gtd/TODO.md"] },
|
|
16
17
|
// `{}` reopens what `build.review` restricted: the fixes edit code.
|
|
17
18
|
"build.review.fix.nits": {},
|
|
18
19
|
"build.review.fix.risks": {},
|
|
@@ -22,6 +22,14 @@ describe("the shipped workflow factories", () => {
|
|
|
22
22
|
})
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
+
it("default reviewSeverityMinP to 0.7 and let a factory override win", () => {
|
|
26
|
+
expect(defaults.reviewSeverityMinP).toBe("0.7")
|
|
27
|
+
expect(configure(feature({ reviewSeverityMinP: "0.9" }))).toMatchObject({
|
|
28
|
+
ok: true,
|
|
29
|
+
build: { defaults: { reviewSeverityMinP: "0.9" } },
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
25
33
|
it("build the shipped settings unchanged with no options", () => {
|
|
26
34
|
for (const factory of [feature, fix, review]) {
|
|
27
35
|
expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
|