@pmelab/gtd 42.0.0 → 44.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 +96 -48
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
- package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
- package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
- package/dist/gtd.bundle.mjs +488 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +447 -27
- 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 +7 -290
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +27 -7
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +287 -161
- 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/steering/FORMATS.md +4 -4
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +107 -0
- package/src/steering/review.ts +159 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/steps.ts +4 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- package/src/workflows/feature/coverage.ts +1 -1
- package/src/workflows/feature/feature.test.ts +8 -0
- package/src/workflows/feature/feature.ts +2 -2
- package/src/workflows/feature/index.ts +1 -1
- package/src/workflows/feature/lens-review.feature +16 -4
- package/src/workflows/feature/refactor.ts +1 -1
- package/src/workflows/feature/review-answers.feature +179 -0
- package/src/workflows/feature/seam.ts +1 -1
- package/src/workflows/feature/steps.test.ts +2 -76
- package/src/workflows/feature/steps.ts +5 -48
- package/src/workflows/fix/fix.feature +20 -21
- package/src/workflows/index.ts +1 -1
- package/src/workflows/review/review-door.feature +149 -0
- package/src/workflows/review/review.test.ts +1 -73
- package/src/workflows/review/review.ts +34 -6
- 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/{feature → shared}/review.test.ts +9 -61
- package/src/workflows/{feature → shared}/review.ts +40 -48
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +2 -0
- package/src/workflows/shared/steps.test.ts +64 -2
- package/src/workflows/shared/steps.ts +40 -1
- package/src/workflows/shared/tail.ts +2 -2
- package/src/workflows/shared/text.test.ts +40 -89
- package/src/workflows/shared/text.ts +63 -54
|
@@ -1,18 +1,46 @@
|
|
|
1
|
-
import { start, type WorkflowBase } from "../../flows/index.js"
|
|
1
|
+
import { read, refuse, removeScript, run, start, type WorkflowBase } from "../../flows/index.js"
|
|
2
|
+
import { buildTail, reUnwind } from "../shared/review.js"
|
|
2
3
|
import { gate } from "../shared/health.js"
|
|
3
4
|
import { shipped } from "../shared/options.js"
|
|
4
|
-
import {
|
|
5
|
+
import { coder, REQUIREMENTS } from "../shared/steps.js"
|
|
6
|
+
import { refusePendingHandOff } from "../shared/tail.js"
|
|
5
7
|
import * as t from "../shared/text.js"
|
|
6
8
|
|
|
9
|
+
/** Build the concerns a review round collected into `.gtd/REQUIREMENTS.md`, then remove it. */
|
|
10
|
+
const fixFeedback = async (): Promise<void> => {
|
|
11
|
+
await t.agentWithSkills("feedback.fixing", t.reviewFeedbackFixPrompt(), {
|
|
12
|
+
label: "Fixing your review feedback",
|
|
13
|
+
file: REQUIREMENTS,
|
|
14
|
+
model: coder(),
|
|
15
|
+
system: t.finisherSystem(),
|
|
16
|
+
// An earlier nit fix may already have built the concern.
|
|
17
|
+
allowEmpty: true,
|
|
18
|
+
})
|
|
19
|
+
await run("feedback.closing", removeScript([REQUIREMENTS]), { label: "Closing the feedback" })
|
|
20
|
+
}
|
|
21
|
+
|
|
7
22
|
/**
|
|
8
|
-
*
|
|
9
|
-
* lap, then the human review
|
|
10
|
-
*
|
|
23
|
+
* The feature's review of everything since `reviewBase` — a green gate, the
|
|
24
|
+
* triaged quality lap, then the human review — with no design phase: a
|
|
25
|
+
* review that asks for changes is fixed in place and reviewed again, until
|
|
26
|
+
* sign-off.
|
|
11
27
|
*/
|
|
12
28
|
export const reviewFlow = async (): Promise<void> => {
|
|
13
29
|
refusePendingHandOff()
|
|
30
|
+
// A review's feedback is whatever sits in this file, so a leftover one would be built, then deleted.
|
|
31
|
+
if (read(REQUIREMENTS) !== undefined) {
|
|
32
|
+
refuse(`gtd: ${REQUIREMENTS} is left over — finish or remove it before a review.`)
|
|
33
|
+
}
|
|
14
34
|
await gate("review-gate", t.reviewGateBlockedMessage())
|
|
15
|
-
|
|
35
|
+
const base = start()
|
|
36
|
+
// The gate just proved the tree green.
|
|
37
|
+
let outcome = await buildTail(base, { green: true })
|
|
38
|
+
while (outcome.verdict === "feedback") {
|
|
39
|
+
await reUnwind(outcome)
|
|
40
|
+
await fixFeedback()
|
|
41
|
+
// The human reviews the whole change again; the lenses only what the fix changed.
|
|
42
|
+
outcome = await buildTail(base, { lensBase: outcome.base })
|
|
43
|
+
}
|
|
16
44
|
}
|
|
17
45
|
|
|
18
46
|
export const review = shipped(
|
|
@@ -45,11 +45,12 @@ You are judging and classifying a round of review feedback.
|
|
|
45
45
|
- This turn writes `.gtd/REQUIREMENTS.md` (the folded concerns) and
|
|
46
46
|
replies inside `.gtd/REVIEW.md` (thread replies only; the file stays
|
|
47
47
|
in the tree) — you classify, you do not build
|
|
48
|
-
- Fold every concluded thread
|
|
49
|
-
|
|
50
|
-
`.gtd/
|
|
51
|
-
|
|
52
|
-
|
|
48
|
+
- Fold every concluded thread and hand-edit — and each `edit` note
|
|
49
|
+
the capture lists, no other note — into `.gtd/REQUIREMENTS.md` and
|
|
50
|
+
delete the folded threads from `.gtd/REVIEW.md`; append one `- A:`
|
|
51
|
+
reply to each thread whose last entry is a `- H:` question
|
|
52
|
+
- Never fold a note's ticked answer box the capture does not list: gtd
|
|
53
|
+
routes those itself
|
|
53
54
|
- When the capture names an answering commit, carry each answered
|
|
54
55
|
question into `.gtd/REQUIREMENTS.md`'s `## Answered Questions` as
|
|
55
56
|
`### <the note>` plus its answer — the lap replaces `.gtd/REVIEW.md`
|
|
@@ -11,7 +11,6 @@ describe("the bundled workflow's access export", () => {
|
|
|
11
11
|
expect(bundled).toEqual({
|
|
12
12
|
design: { write: [] },
|
|
13
13
|
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
14
|
-
"build.review.defer": { write: [".gtd/TODO.md"] },
|
|
15
14
|
"build.review.fix.nits": {},
|
|
16
15
|
"build.review.fix.risks": {},
|
|
17
16
|
"build.quality.correctness": { write: [] },
|
|
@@ -21,7 +20,6 @@ describe("the bundled workflow's access export", () => {
|
|
|
21
20
|
"build.quality.conventions": { write: [] },
|
|
22
21
|
"build.quality.spec-challenge": { write: [] },
|
|
23
22
|
"refactor.build.review": { write: [".gtd/REVIEW.md"] },
|
|
24
|
-
"refactor.build.review.defer": { write: [".gtd/TODO.md"] },
|
|
25
23
|
"refactor.build.quality.correctness": { write: [] },
|
|
26
24
|
"refactor.build.quality.ponytail-review": { write: [] },
|
|
27
25
|
"refactor.build.quality.conventions": { write: [] },
|
|
@@ -32,7 +30,6 @@ describe("the bundled workflow's access export", () => {
|
|
|
32
30
|
expect(bundled).not.toHaveProperty(["refactor.build.review.fix.nits"])
|
|
33
31
|
expect(bundled).not.toHaveProperty(["refactor.build.review.fix.risks"])
|
|
34
32
|
expect(bundled["refactor.build.review"]).toEqual({ write: [".gtd/REVIEW.md"] })
|
|
35
|
-
expect(bundled["refactor.build.review.defer"]).toEqual({ write: [".gtd/TODO.md"] })
|
|
36
33
|
})
|
|
37
34
|
|
|
38
35
|
it("keys one lens scope per qualityReviews entry", () => {
|
|
@@ -54,17 +51,11 @@ describe("the bundled workflow's access export", () => {
|
|
|
54
51
|
"refactor.build.quality.correctness",
|
|
55
52
|
"refactor.build.quality.my-lens",
|
|
56
53
|
"refactor.build.review",
|
|
57
|
-
"refactor.build.review.defer",
|
|
58
54
|
])
|
|
59
55
|
})
|
|
60
56
|
|
|
61
|
-
it("keys only turn
|
|
62
|
-
|
|
63
|
-
const known = [
|
|
64
|
-
...Object.keys(workflow.skills(defaults)),
|
|
65
|
-
"build.review.defer",
|
|
66
|
-
"refactor.build.review.defer",
|
|
67
|
-
]
|
|
57
|
+
it("keys only turn scopes", () => {
|
|
58
|
+
const known = Object.keys(workflow.skills(defaults))
|
|
68
59
|
for (const key of Object.keys(bundled)) expect(known).toContain(key)
|
|
69
60
|
})
|
|
70
61
|
})
|
|
@@ -13,12 +13,10 @@ export const access = (
|
|
|
13
13
|
): Readonly<Record<string, ScopeAccess>> => ({
|
|
14
14
|
design: { write: [] },
|
|
15
15
|
"build.review": { write: [".gtd/REVIEW.md"] },
|
|
16
|
-
"build.review.defer": { write: [".gtd/TODO.md"] },
|
|
17
16
|
// `{}` reopens what `build.review` restricted: the fixes edit code.
|
|
18
17
|
"build.review.fix.nits": {},
|
|
19
18
|
"build.review.fix.risks": {},
|
|
20
19
|
"refactor.build.review": { write: [".gtd/REVIEW.md"] },
|
|
21
|
-
"refactor.build.review.defer": { write: [".gtd/TODO.md"] },
|
|
22
20
|
...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] })),
|
|
23
21
|
...lensScopes("refactor.build.quality", lensesOf(vars.refactorQualityReviews ?? ""), () => ({
|
|
24
22
|
write: [],
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "../../flows/index.js"
|
|
10
10
|
import {
|
|
11
11
|
buildTail,
|
|
12
|
-
|
|
12
|
+
humanReview,
|
|
13
13
|
reUnwind,
|
|
14
14
|
severityOf,
|
|
15
15
|
type BuildTailOptions,
|
|
@@ -17,8 +17,7 @@ import {
|
|
|
17
17
|
} from "./review.js"
|
|
18
18
|
import type { ReviewNote } from "../../steering/index.js"
|
|
19
19
|
import * as bundled from "../index.js"
|
|
20
|
-
import
|
|
21
|
-
import { fixtureContext } from "../shared/text.fixture.js"
|
|
20
|
+
import { fixtureContext } from "./text.fixture.js"
|
|
22
21
|
import { toRequest } from "../../judges/index.js"
|
|
23
22
|
|
|
24
23
|
afterEach(() => installContext(undefined))
|
|
@@ -155,7 +154,7 @@ const drive = async (d: Drive): Promise<Run> => {
|
|
|
155
154
|
installContext(context)
|
|
156
155
|
let result: ReviewOutcome | "stopped"
|
|
157
156
|
try {
|
|
158
|
-
result = await
|
|
157
|
+
result = await humanReview("base", undefined, {
|
|
159
158
|
...d.options,
|
|
160
159
|
...(d.guarded ? { guard: () => void log.push("guard") } : {}),
|
|
161
160
|
})
|
|
@@ -675,7 +674,7 @@ describe("severity routing", () => {
|
|
|
675
674
|
expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
|
|
676
675
|
})
|
|
677
676
|
|
|
678
|
-
it("a confident could edit
|
|
677
|
+
it("a confident could edit is still re-planned, labelled could", async () => {
|
|
679
678
|
const run = await drive({
|
|
680
679
|
notes: ["add — quote", "sub", "mul"],
|
|
681
680
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
@@ -684,9 +683,10 @@ describe("severity routing", () => {
|
|
|
684
683
|
"review.composing",
|
|
685
684
|
"review.await-review",
|
|
686
685
|
"review.triage",
|
|
687
|
-
"review.
|
|
688
|
-
"review.
|
|
686
|
+
"review.closing",
|
|
687
|
+
"review.collecting",
|
|
689
688
|
])
|
|
689
|
+
expect(run.prompts.get("review.collecting")).toContain("(could)")
|
|
690
690
|
})
|
|
691
691
|
|
|
692
692
|
it("severity answers on a nit leave it a nit", async () => {
|
|
@@ -730,68 +730,19 @@ describe("severity routing", () => {
|
|
|
730
730
|
expect(run.judged).toContain("note-1.security")
|
|
731
731
|
})
|
|
732
732
|
|
|
733
|
-
it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
|
|
734
|
-
const run = await drive({
|
|
735
|
-
notes: ["add — quote", "sub", "mul"],
|
|
736
|
-
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
737
|
-
})
|
|
738
|
-
expect(run.gates).toEqual([true, true])
|
|
739
|
-
expect(run.gateMessages[1]).toContain("NOT acted on")
|
|
740
|
-
expect(run.gateMessages[1]).toContain("note-1")
|
|
741
|
-
expect(run.gateMessages[0]).not.toContain("NOT acted on")
|
|
742
|
-
})
|
|
743
|
-
|
|
744
733
|
it("the first round's gate accepts a clean land", async () => {
|
|
745
734
|
const run = await drive({ notes: ["add", "sub", "mul"] })
|
|
746
735
|
expect(run.gates.length).toBeGreaterThan(0)
|
|
747
736
|
expect(run.gates.every((g) => g)).toBe(true)
|
|
748
737
|
})
|
|
749
738
|
|
|
750
|
-
it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
|
|
751
|
-
const run = await drive({
|
|
752
|
-
notes: ["add — quote", "sub — typo", "mul"],
|
|
753
|
-
answers: {
|
|
754
|
-
"note-1": verdict("edit"),
|
|
755
|
-
...sev("note-1", could),
|
|
756
|
-
"note-2": verdict("nit"),
|
|
757
|
-
},
|
|
758
|
-
reviewDocs: [undefined, doc(["add", "sub"])],
|
|
759
|
-
})
|
|
760
|
-
expect(run.log).toContain("review.defer")
|
|
761
|
-
expect(run.log.at(-1)).toBe("review.await-review")
|
|
762
|
-
expect(run.gates).toEqual([true, true])
|
|
763
|
-
})
|
|
764
|
-
|
|
765
|
-
it("a question and a could note in one round: the gate's base is the answering commit", async () => {
|
|
766
|
-
const run = await drive({
|
|
767
|
-
notes: ["add — why?", "sub — quote", "mul"],
|
|
768
|
-
answers: {
|
|
769
|
-
"note-1": verdict("question"),
|
|
770
|
-
"note-2": verdict("edit"),
|
|
771
|
-
...sev("note-2", could),
|
|
772
|
-
},
|
|
773
|
-
passSecondGate: true,
|
|
774
|
-
})
|
|
775
|
-
expect(run.log).toEqual([
|
|
776
|
-
"review.composing",
|
|
777
|
-
"review.await-review",
|
|
778
|
-
"review.triage",
|
|
779
|
-
"review.answer-review-questions",
|
|
780
|
-
"review.defer",
|
|
781
|
-
"review.await-review",
|
|
782
|
-
])
|
|
783
|
-
// c4 is the answering commit, not the defer commit (c5) or the round (c2)
|
|
784
|
-
expect(run.since.at(-1)).toBe("c4")
|
|
785
|
-
})
|
|
786
|
-
|
|
787
739
|
it("a blank reviewSeverityMinP makes every confident could critical", async () => {
|
|
788
740
|
const run = await drive({
|
|
789
741
|
notes: ["add — quote", "sub", "mul"],
|
|
790
742
|
answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
|
|
791
743
|
vars: { reviewSeverityMinP: "" },
|
|
792
744
|
})
|
|
793
|
-
expect(run.
|
|
794
|
-
expect(run.log).not.toContain("review.defer")
|
|
745
|
+
expect(run.prompts.get("review.collecting")).toContain("(critical: judge unsure)")
|
|
795
746
|
})
|
|
796
747
|
})
|
|
797
748
|
|
|
@@ -1023,11 +974,8 @@ describe("BuildTailOptions.lensBase", () => {
|
|
|
1023
974
|
})
|
|
1024
975
|
})
|
|
1025
976
|
|
|
1026
|
-
// pins the public re-exports from the
|
|
977
|
+
// pins the public re-exports from the published workflows entry
|
|
1027
978
|
export type _PublicBuildTailOptions = [
|
|
1028
|
-
featureEntry.BuildTailOptions,
|
|
1029
|
-
featureEntry.FeedbackTarget,
|
|
1030
|
-
featureEntry.ReviewRange,
|
|
1031
979
|
bundled.BuildTailOptions,
|
|
1032
980
|
bundled.FeedbackTarget,
|
|
1033
981
|
bundled.ReviewRange,
|
|
@@ -16,20 +16,22 @@ import {
|
|
|
16
16
|
} from "../../flows/index.js"
|
|
17
17
|
import type { ReviewNote } from "../../steering/index.js"
|
|
18
18
|
import * as kit from "../../workflow-kit/index.js"
|
|
19
|
-
import { guarded, healthy, type EscalationCount } from "
|
|
20
|
-
import { NO_FINDINGS, type LensReport } from "
|
|
19
|
+
import { guarded, healthy, type EscalationCount } from "./health.js"
|
|
20
|
+
import { NO_FINDINGS, type LensReport } from "./lenses.js"
|
|
21
21
|
import {
|
|
22
|
+
answerReviewQuestions,
|
|
22
23
|
awaitReview,
|
|
24
|
+
collecting,
|
|
23
25
|
fixCheck,
|
|
26
|
+
fixNits,
|
|
24
27
|
fixRisks,
|
|
25
28
|
QUALITY,
|
|
26
29
|
REQUIREMENTS,
|
|
27
30
|
REVIEW,
|
|
28
31
|
reviewMissing,
|
|
29
|
-
} from "
|
|
30
|
-
import { closeReview, isOptionalObject, tail, type TailOptions } from "
|
|
31
|
-
import * as t from "
|
|
32
|
-
import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
|
|
32
|
+
} from "./steps.js"
|
|
33
|
+
import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
|
|
34
|
+
import * as t from "./text.js"
|
|
33
35
|
|
|
34
36
|
export type { ReviewOutcome } from "../../workflow-kit/index.js"
|
|
35
37
|
|
|
@@ -46,6 +48,12 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
|
|
|
46
48
|
|
|
47
49
|
type Tier = "critical" | "should" | "could"
|
|
48
50
|
|
|
51
|
+
const TIER_ORDER: Readonly<Record<Tier, number>> = { critical: 0, should: 1, could: 2 }
|
|
52
|
+
|
|
53
|
+
/** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
|
|
54
|
+
const captureKey = (s: Severity | undefined): number =>
|
|
55
|
+
s === undefined ? 2 : TIER_ORDER[s.tier] * 3 + s.rank
|
|
56
|
+
|
|
49
57
|
const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
|
|
50
58
|
security: ["yes", "no"],
|
|
51
59
|
reversibility: ["one-way", "two-way"],
|
|
@@ -60,10 +68,7 @@ interface Severity {
|
|
|
60
68
|
readonly rank: 0 | 1 | 2
|
|
61
69
|
}
|
|
62
70
|
|
|
63
|
-
/**
|
|
64
|
-
* Deferring is the risky direction: anything unanswered, unrecognised, cut or
|
|
65
|
-
* below `minP` is `critical`, never `could`.
|
|
66
|
-
*/
|
|
71
|
+
/** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
|
|
67
72
|
export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
|
|
68
73
|
const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
|
|
69
74
|
const a = (q: string): string | undefined => ans(q)?.answer
|
|
@@ -125,7 +130,7 @@ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
|
|
|
125
130
|
* nothing) plus four severity answers. Dismissing a note is the risky
|
|
126
131
|
* direction, so only a confident non-`edit` verdict on uncut evidence counts;
|
|
127
132
|
* a cut, unanswered or below-floor note is an `edit`. Severity answers on a
|
|
128
|
-
* non-`edit` note are ignored
|
|
133
|
+
* non-`edit` note are ignored; on an `edit` they order the collecting turn.
|
|
129
134
|
*/
|
|
130
135
|
const triage =
|
|
131
136
|
(severities: Map<string, Severity>) =>
|
|
@@ -155,9 +160,8 @@ const triage =
|
|
|
155
160
|
? undefined
|
|
156
161
|
: VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
|
|
157
162
|
if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
return [n.id, severity.tier === "could" ? "defer" : "edit"]
|
|
163
|
+
severities.set(n.id, severityOf(n, judgment, severityMinP))
|
|
164
|
+
return [n.id, "edit"]
|
|
161
165
|
}),
|
|
162
166
|
)
|
|
163
167
|
}
|
|
@@ -167,13 +171,15 @@ export const isCode = (path: string): boolean => path !== ".gtd" && !path.starts
|
|
|
167
171
|
/**
|
|
168
172
|
* gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
|
|
169
173
|
* 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
|
|
174
|
+
* after fixed nits restores the doc as committed at `carry`, answers kept;
|
|
175
|
+
* a fresh one leaves out the notes the human already answered.
|
|
171
176
|
*/
|
|
172
177
|
const composing = (
|
|
173
178
|
base: string,
|
|
174
179
|
report: LensReport,
|
|
175
180
|
carry?: string,
|
|
176
181
|
nitFix?: string,
|
|
182
|
+
answered: ReadonlySet<string> = new Set(),
|
|
177
183
|
): Promise<void> =>
|
|
178
184
|
run(
|
|
179
185
|
"review.composing",
|
|
@@ -184,6 +190,7 @@ const composing = (
|
|
|
184
190
|
...report,
|
|
185
191
|
weakened: kit.removedAssertions(changesSince(base)),
|
|
186
192
|
nitFix,
|
|
193
|
+
answered,
|
|
187
194
|
}),
|
|
188
195
|
[QUALITY],
|
|
189
196
|
)
|
|
@@ -193,11 +200,13 @@ const composing = (
|
|
|
193
200
|
|
|
194
201
|
/**
|
|
195
202
|
* The quality lap's notes reach a human over everything since `base`, who
|
|
196
|
-
*
|
|
197
|
-
*
|
|
203
|
+
* answers each one — fix as suggested, won't fix, or their own answer — and
|
|
204
|
+
* may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
|
|
205
|
+
* answer or comment is judged: edits end the review as `feedback` for the
|
|
206
|
+
* caller to build, questions are answered in place, nits fixed in one batch,
|
|
198
207
|
* praise dropped.
|
|
199
208
|
*/
|
|
200
|
-
export const
|
|
209
|
+
export const humanReview = (
|
|
201
210
|
base: string,
|
|
202
211
|
escalations: EscalationCount = { rounds: 0 },
|
|
203
212
|
options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
|
|
@@ -210,42 +219,26 @@ export const review = (
|
|
|
210
219
|
"gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
|
|
211
220
|
}
|
|
212
221
|
const severities = new Map<string, Severity>()
|
|
213
|
-
// Armed by a defer-only round to pick the next gate's message; a fresh
|
|
214
|
-
// review (`reviewing`) disarms it.
|
|
215
|
-
let deferred: readonly t.NoteInput[] = []
|
|
216
222
|
// Set by a nit fix, so the fresh review that follows names its commit.
|
|
217
223
|
let nitFix: string | undefined
|
|
224
|
+
// Every note the human answered in this review, left out of a fresh one.
|
|
225
|
+
const answered = new Set<string>()
|
|
218
226
|
return kit.review(base, {
|
|
219
227
|
file: REVIEW,
|
|
220
228
|
feedback: feedback.file,
|
|
221
229
|
isCode,
|
|
222
230
|
reviewing: (b, carry) => {
|
|
223
|
-
deferred = []
|
|
224
231
|
const fixedAt = nitFix
|
|
225
232
|
nitFix = undefined
|
|
226
|
-
return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt)
|
|
233
|
+
return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered)
|
|
227
234
|
},
|
|
228
235
|
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
229
236
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
230
|
-
awaitReview: (b) =>
|
|
231
|
-
const notes = deferred
|
|
232
|
-
deferred = []
|
|
233
|
-
return notes.length > 0
|
|
234
|
-
? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
|
|
235
|
-
: awaitReview(b)
|
|
236
|
-
},
|
|
237
|
+
awaitReview: (b) => awaitReview(b),
|
|
237
238
|
reviewMissing,
|
|
238
239
|
collecting: feedback.collecting,
|
|
239
240
|
triage: triage(severities),
|
|
240
|
-
|
|
241
|
-
deferred = notes.map((n) => ({
|
|
242
|
-
id: n.id,
|
|
243
|
-
anchor: n.anchor,
|
|
244
|
-
text: n.text,
|
|
245
|
-
label: severities.get(n.id)!.reason,
|
|
246
|
-
}))
|
|
247
|
-
return deferNotes(deferred, head())
|
|
248
|
-
},
|
|
241
|
+
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
249
242
|
answerQuestions: answerReviewQuestions,
|
|
250
243
|
fixNits: async (notes) => {
|
|
251
244
|
await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
|
|
@@ -259,14 +252,13 @@ export const review = (
|
|
|
259
252
|
t.reviewEditNotesCapture(
|
|
260
253
|
round,
|
|
261
254
|
edits
|
|
262
|
-
.map((n, i) => ({
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
(
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
)
|
|
255
|
+
.map((n, i) => ({
|
|
256
|
+
n,
|
|
257
|
+
i,
|
|
258
|
+
s: severities.get(n.id),
|
|
259
|
+
key: captureKey(severities.get(n.id)),
|
|
260
|
+
}))
|
|
261
|
+
.sort((x, y) => x.key - y.key || x.i - y.i)
|
|
270
262
|
.map(({ n, s }) => ({
|
|
271
263
|
id: n.id,
|
|
272
264
|
anchor: n.anchor,
|
|
@@ -327,7 +319,7 @@ export function buildTail(
|
|
|
327
319
|
)
|
|
328
320
|
}
|
|
329
321
|
const [base, form] = options
|
|
330
|
-
return tail((escalations, report) =>
|
|
322
|
+
return tail((escalations, report) => humanReview(base, escalations, { ...form, report }), {
|
|
331
323
|
...form,
|
|
332
324
|
triage: true,
|
|
333
325
|
})
|
|
@@ -21,6 +21,7 @@ describe("the bundled workflow's skills export", () => {
|
|
|
21
21
|
"build.review.fix.nits",
|
|
22
22
|
"build.review.fix.risks",
|
|
23
23
|
"notes",
|
|
24
|
+
"feedback",
|
|
24
25
|
"refactor",
|
|
25
26
|
"refactor.fix.suite",
|
|
26
27
|
"refactor.health",
|
|
@@ -71,6 +72,7 @@ describe("the bundled workflow's skills export", () => {
|
|
|
71
72
|
"build.review.fix.nits": ["incremental-implementation", "code-simplification"],
|
|
72
73
|
"build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
|
|
73
74
|
notes: ["incremental-implementation", "code-simplification"],
|
|
75
|
+
feedback: ["test-driven-development", "incremental-implementation"],
|
|
74
76
|
refactor: ["ponytail"],
|
|
75
77
|
"refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
|
|
76
78
|
"refactor.health": ["ponytail", "debugging-and-error-recovery"],
|
|
@@ -22,6 +22,8 @@ export const skills = (
|
|
|
22
22
|
design: ["spec-driven-development", "planning-and-task-breakdown"],
|
|
23
23
|
// `fix`'s turn over the notes a review handed on.
|
|
24
24
|
notes: ["incremental-implementation", "code-simplification"],
|
|
25
|
+
// `review`'s turn building what a review round collected.
|
|
26
|
+
feedback: ["test-driven-development", "incremental-implementation"],
|
|
25
27
|
// The refactor's own turns load `ponytail`; `refactor.build.*` carry the `build.*` lists.
|
|
26
28
|
...build,
|
|
27
29
|
...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import type
|
|
3
|
-
import { captureStep } from "./text.fixture.js"
|
|
2
|
+
import { installContext, type ScopeOptions, type StepRequest } from "../../flows/index.js"
|
|
3
|
+
import { captureStep, fixtureContext } from "./text.fixture.js"
|
|
4
4
|
import * as steps from "./steps.js"
|
|
5
5
|
|
|
6
6
|
const capture = (
|
|
@@ -151,4 +151,66 @@ describe("the bundled workflow's model hints", () => {
|
|
|
151
151
|
expect(await modelOf(() => steps.fixQuality())).toBe("base")
|
|
152
152
|
expect(await modelOf(() => steps.fixCheck(), { env: { coderModel: "sonnet" } })).toBe("sonnet")
|
|
153
153
|
})
|
|
154
|
+
|
|
155
|
+
it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
|
|
156
|
+
const request = await capture(
|
|
157
|
+
() =>
|
|
158
|
+
steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
|
|
159
|
+
"build",
|
|
160
|
+
)
|
|
161
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
162
|
+
expect(request.name).toBe("review.answer-review-questions")
|
|
163
|
+
expect(request.options).toMatchObject({
|
|
164
|
+
file: ".gtd/REVIEW.md",
|
|
165
|
+
mode: "review",
|
|
166
|
+
})
|
|
167
|
+
expect(request.prompt).toContain("code-review-and-quality")
|
|
168
|
+
expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
|
|
169
|
+
expect(request.prompt).toContain("`A: ` line")
|
|
170
|
+
expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
|
|
174
|
+
const request = await capture(
|
|
175
|
+
() =>
|
|
176
|
+
steps.fixNits([
|
|
177
|
+
{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
|
|
178
|
+
{ id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
|
|
179
|
+
]),
|
|
180
|
+
"build",
|
|
181
|
+
)
|
|
182
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
183
|
+
expect(request.name).toBe("review.fix.nits.fixing")
|
|
184
|
+
expect(request.prompt).toContain("incremental-implementation, code-simplification")
|
|
185
|
+
expect(request.prompt).toContain("typo")
|
|
186
|
+
expect(request.prompt).toContain("semicolon")
|
|
187
|
+
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
|
|
191
|
+
const scopes: ScopeOptions[] = []
|
|
192
|
+
const steps_: StepRequest[] = []
|
|
193
|
+
const access = { write: ["src/a.ts", ".gtd/**"] }
|
|
194
|
+
installContext(
|
|
195
|
+
fixtureContext(
|
|
196
|
+
{ scope: "refactor.build" },
|
|
197
|
+
{
|
|
198
|
+
step: (request) => {
|
|
199
|
+
steps_.push(request)
|
|
200
|
+
return Promise.resolve()
|
|
201
|
+
},
|
|
202
|
+
pushScope: (s) => void scopes.push(s),
|
|
203
|
+
popScope: () => undefined,
|
|
204
|
+
changesSince: () => [],
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
try {
|
|
209
|
+
await steps.fixNits([], access)
|
|
210
|
+
} finally {
|
|
211
|
+
installContext(undefined)
|
|
212
|
+
}
|
|
213
|
+
expect(scopes).toEqual([{ name: "review.fix.nits", access }])
|
|
214
|
+
expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
|
|
215
|
+
})
|
|
154
216
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { changesSince, env, human, scope, type ScopeAccess } from "../../flows/index.js"
|
|
2
|
-
import { removedAssertions } from "../../workflow-kit/index.js"
|
|
2
|
+
import { removedAssertions, type CollectRound } from "../../workflow-kit/index.js"
|
|
3
3
|
import * as t from "./text.js"
|
|
4
4
|
|
|
5
5
|
// The bundled workflow's single steps. A step's name is relative to the
|
|
@@ -145,6 +145,45 @@ export const awaitReview = (
|
|
|
145
145
|
acceptClean: true,
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
/** Answer every `question` note inline in `.gtd/REVIEW.md`. */
|
|
149
|
+
export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
150
|
+
t.agentWithSkills("review.answer-review-questions", t.buildReviewAnswerQuestionsPrompt(notes), {
|
|
151
|
+
label: "Answering your questions",
|
|
152
|
+
file: REVIEW,
|
|
153
|
+
mode: "review",
|
|
154
|
+
model: planner(),
|
|
155
|
+
system: t.reviewerSystem(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** Fix every `nit` note in one turn. */
|
|
159
|
+
export const fixNits = (
|
|
160
|
+
notes: readonly t.NoteInput[],
|
|
161
|
+
access?: ScopeAccess,
|
|
162
|
+
rules?: string,
|
|
163
|
+
): Promise<void> => {
|
|
164
|
+
const fixing = (name: string): Promise<void> =>
|
|
165
|
+
t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
|
|
166
|
+
label: "Fixing your nits",
|
|
167
|
+
file: REVIEW,
|
|
168
|
+
model: planner(),
|
|
169
|
+
system: t.reviewerSystem(),
|
|
170
|
+
})
|
|
171
|
+
return access === undefined
|
|
172
|
+
? fixing("review.fix.nits.fixing")
|
|
173
|
+
: scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
|
|
177
|
+
export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
|
|
178
|
+
t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
|
|
179
|
+
label: "Collecting your feedback",
|
|
180
|
+
file: REQUIREMENTS,
|
|
181
|
+
mode: "qa",
|
|
182
|
+
model: planner(),
|
|
183
|
+
system: t.reviewerSystem(),
|
|
184
|
+
allowEmpty: true,
|
|
185
|
+
})
|
|
186
|
+
|
|
148
187
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
149
188
|
export const reviewMissing = (round: string): Promise<void> =>
|
|
150
189
|
human("review.review-missing", {
|
|
@@ -190,7 +190,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* The
|
|
193
|
+
* The fix workflow's human review since `base`: a sign-off closes
|
|
194
194
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
195
195
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
196
196
|
*/
|
|
@@ -220,6 +220,6 @@ export const handOffReview = (
|
|
|
220
220
|
"gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix.",
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
-
/** The tail the
|
|
223
|
+
/** The tail the fix workflow runs: ends at the hand-off review since `base`. */
|
|
224
224
|
export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
|
|
225
225
|
tail((escalations) => handOffReview(base, escalations, guard), { fixFirst, guard })
|