@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
|
@@ -11,6 +11,7 @@ const state = vi.hoisted(() => ({
|
|
|
11
11
|
| undefined,
|
|
12
12
|
agentThread: false,
|
|
13
13
|
humanThread: false,
|
|
14
|
+
marks: [] as string[],
|
|
14
15
|
}))
|
|
15
16
|
|
|
16
17
|
vi.mock("../flows/index.js", () => ({
|
|
@@ -24,6 +25,7 @@ vi.mock("../flows/index.js", () => ({
|
|
|
24
25
|
},
|
|
25
26
|
hasThreadFor: (who: string) => (who === "agent" ? state.agentThread : state.humanThread),
|
|
26
27
|
head: () => state.head,
|
|
28
|
+
mark: (name: string, value: string) => void state.marks.push(`${name}=${value}`),
|
|
27
29
|
read: (path: string) => (path === "f.md" ? state.feedback : state.files[path]),
|
|
28
30
|
refuse: (m: string) => {
|
|
29
31
|
throw new Error(m)
|
|
@@ -41,20 +43,29 @@ import {
|
|
|
41
43
|
type ReviewPolicy,
|
|
42
44
|
type Verdict,
|
|
43
45
|
} from "./review.js"
|
|
44
|
-
import type { ReviewNote } from "../steering/index.js"
|
|
46
|
+
import type { ReviewDecision, ReviewNote } from "../steering/index.js"
|
|
45
47
|
|
|
46
48
|
const note = (id: string) => ({ id }) as unknown as ReviewNote
|
|
47
49
|
|
|
48
50
|
const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = false) => {
|
|
49
51
|
const calls: string[] = []
|
|
50
|
-
const
|
|
52
|
+
const judged: ReviewNote[][] = []
|
|
53
|
+
const fixed: ReviewNote[][] = []
|
|
54
|
+
const decided: ReviewDecision[][] = []
|
|
51
55
|
const collected: ReviewNote[][] = []
|
|
52
56
|
const p = {
|
|
53
57
|
file: "r.md",
|
|
54
58
|
feedback: "f.md",
|
|
55
|
-
triage: async () =>
|
|
59
|
+
triage: async (ns: readonly ReviewNote[]) => {
|
|
60
|
+
judged.push([...ns])
|
|
61
|
+
return new Map(Object.entries(verdicts))
|
|
62
|
+
},
|
|
56
63
|
answerQuestions: async () => void calls.push("answer"),
|
|
57
|
-
fixNits: async () =>
|
|
64
|
+
fixNits: async (ns: readonly ReviewNote[]) => {
|
|
65
|
+
calls.push("nits")
|
|
66
|
+
fixed.push([...ns])
|
|
67
|
+
},
|
|
68
|
+
decided: (ds: readonly ReviewDecision[]) => void decided.push([...ds]),
|
|
58
69
|
keepGreen: async () => void calls.push("green"),
|
|
59
70
|
collecting: async () => {
|
|
60
71
|
calls.push("collect")
|
|
@@ -72,10 +83,6 @@ const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = fals
|
|
|
72
83
|
`dropped: ${r.notes.map((n) => n.id).join(",")} / ${r.paths.join(",")}`,
|
|
73
84
|
}
|
|
74
85
|
: {},
|
|
75
|
-
defer: async (ns: readonly ReviewNote[]) => {
|
|
76
|
-
calls.push("defer")
|
|
77
|
-
deferred.push(ns.map((n) => n.id))
|
|
78
|
-
},
|
|
79
86
|
} as unknown as ReviewPolicy
|
|
80
87
|
const r = {
|
|
81
88
|
round: "r1",
|
|
@@ -83,7 +90,7 @@ const setup = (verdicts: Record<string, Verdict>, silent = false, dropped = fals
|
|
|
83
90
|
outcome: (verdict: "signoff" | "feedback") => ({ verdict }) as never,
|
|
84
91
|
unfolded: async () => ({ verdict: "signoff" }) as never,
|
|
85
92
|
}
|
|
86
|
-
return { p, r, calls,
|
|
93
|
+
return { p, r, calls, judged, fixed, decided, collected }
|
|
87
94
|
}
|
|
88
95
|
|
|
89
96
|
beforeEach(() => {
|
|
@@ -95,29 +102,47 @@ beforeEach(() => {
|
|
|
95
102
|
state.head = "h1"
|
|
96
103
|
state.agentThread = false
|
|
97
104
|
state.humanThread = false
|
|
105
|
+
state.marks = []
|
|
98
106
|
})
|
|
99
107
|
|
|
100
|
-
describe("routeNotes
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
108
|
+
describe("routeNotes decisions", () => {
|
|
109
|
+
const decision = (
|
|
110
|
+
anchor: string,
|
|
111
|
+
choice: ReviewDecision["choice"],
|
|
112
|
+
text = "",
|
|
113
|
+
): ReviewDecision => ({ anchor, body: `${anchor} body`, choice, text })
|
|
114
|
+
|
|
115
|
+
it("a fix joins the nit batch unjudged", async () => {
|
|
116
|
+
const t = setup({})
|
|
117
|
+
const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "fix")])
|
|
118
|
+
expect(res).toEqual({ next: "rereview", carry: undefined })
|
|
119
|
+
expect(t.judged).toEqual([])
|
|
120
|
+
expect(t.fixed[0]).toMatchObject([{ anchor: "Rounding", text: "Rounding body" }])
|
|
121
|
+
expect(t.calls).toEqual(["nits", "green", "close"])
|
|
107
122
|
})
|
|
108
123
|
|
|
109
|
-
it("
|
|
110
|
-
const t = setup({
|
|
111
|
-
const res = await routeNotes(t.p, [
|
|
124
|
+
it("an own answer is judged like a note", async () => {
|
|
125
|
+
const t = setup({ "answer-1": "edit" })
|
|
126
|
+
const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "answer", "round up")])
|
|
112
127
|
expect(res).toEqual({ verdict: "feedback" })
|
|
113
|
-
expect(t.
|
|
114
|
-
expect(t.collected[0]!.map((n) => n.id)).toEqual(["
|
|
128
|
+
expect(t.judged[0]).toMatchObject([{ id: "answer-1", anchor: "Rounding", text: "round up" }])
|
|
129
|
+
expect(t.collected[0]!.map((n) => n.id)).toEqual(["answer-1"])
|
|
115
130
|
})
|
|
116
131
|
|
|
117
|
-
it("
|
|
118
|
-
const t = setup({
|
|
119
|
-
await routeNotes(t.p, [
|
|
120
|
-
expect(
|
|
132
|
+
it("a won't-fix is dropped, marked, and signs off a round with nothing else", async () => {
|
|
133
|
+
const t = setup({})
|
|
134
|
+
const res = await routeNotes(t.p, [], t.r, [decision("Rounding", "wontfix")])
|
|
135
|
+
expect(res).toEqual({ verdict: "signoff" })
|
|
136
|
+
expect(t.calls).toEqual([])
|
|
137
|
+
expect(t.judged).toEqual([])
|
|
138
|
+
expect(state.marks).toEqual(["wontfix=Rounding"])
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it("tells the policy every decision before routing", async () => {
|
|
142
|
+
const t = setup({})
|
|
143
|
+
const ds = [decision("A", "fix"), decision("B", "wontfix")]
|
|
144
|
+
await routeNotes(t.p, [], t.r, ds)
|
|
145
|
+
expect(t.decided).toEqual([ds])
|
|
121
146
|
})
|
|
122
147
|
})
|
|
123
148
|
|
|
@@ -221,7 +246,6 @@ const driveRound = async (
|
|
|
221
246
|
},
|
|
222
247
|
answerQuestions: async () => {},
|
|
223
248
|
fixNits: async () => {},
|
|
224
|
-
defer: async () => {},
|
|
225
249
|
close: async () => {},
|
|
226
250
|
captures: {
|
|
227
251
|
edits: (_round: string, from?: string) => (
|
|
@@ -440,3 +464,123 @@ describe("reviewToHandOff", () => {
|
|
|
440
464
|
expect(calls).toEqual(["handOff"])
|
|
441
465
|
})
|
|
442
466
|
})
|
|
467
|
+
|
|
468
|
+
describe("a note's answer boxes", () => {
|
|
469
|
+
const boxes = (fix: string, wont: string): string =>
|
|
470
|
+
doc(
|
|
471
|
+
POINTER,
|
|
472
|
+
"",
|
|
473
|
+
"## Rounding",
|
|
474
|
+
"",
|
|
475
|
+
"Should subtract round?",
|
|
476
|
+
"",
|
|
477
|
+
"Tick exactly one:",
|
|
478
|
+
"",
|
|
479
|
+
`- [${fix}] fix as suggested`,
|
|
480
|
+
`- [${wont}] won't fix`,
|
|
481
|
+
"- [ ] _your answer_",
|
|
482
|
+
)
|
|
483
|
+
const open = boxes(" ", " ")
|
|
484
|
+
|
|
485
|
+
it("a sign-off with a note unanswered is refused", async () => {
|
|
486
|
+
const r = await driveRound(open, open)
|
|
487
|
+
expect(r.result).toBeInstanceOf(Error)
|
|
488
|
+
expect((r.result as Error).message).toContain("answer-completeness: 1 note(s) in r.md")
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
it("two ticks on one note are refused", async () => {
|
|
492
|
+
const r = await driveRound(open, boxes("x", "x"))
|
|
493
|
+
expect((r.result as Error).message).toContain("answer-completeness")
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
it("a won't-fix answer signs off with no judge", async () => {
|
|
497
|
+
const r = await driveRound(open, boxes(" ", "x"))
|
|
498
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
499
|
+
expect(r.triaged).toEqual([])
|
|
500
|
+
expect(state.marks).toEqual(["wontfix=Rounding"])
|
|
501
|
+
})
|
|
502
|
+
|
|
503
|
+
it("a thread round may leave notes unanswered until the agent replied", async () => {
|
|
504
|
+
const base = doc(
|
|
505
|
+
`${POINTER}[^t]`,
|
|
506
|
+
"",
|
|
507
|
+
thread("H: why?"),
|
|
508
|
+
"",
|
|
509
|
+
"## Rounding",
|
|
510
|
+
"",
|
|
511
|
+
"Round?",
|
|
512
|
+
"",
|
|
513
|
+
"Tick exactly one:",
|
|
514
|
+
"",
|
|
515
|
+
"- [ ] fix as suggested",
|
|
516
|
+
"- [ ] won't fix",
|
|
517
|
+
"- [ ] _your answer_",
|
|
518
|
+
)
|
|
519
|
+
const replied = base.replace(" - H: why?", " - H: why?\n - A: because")
|
|
520
|
+
const r = await driveRound(base, replied, { inGate: replied })
|
|
521
|
+
expect(r.rounds).toEqual(["reply"])
|
|
522
|
+
expect((r.result as Error).message).toContain("answer-completeness")
|
|
523
|
+
})
|
|
524
|
+
|
|
525
|
+
const threaded = (wont: string, withThread: boolean): string =>
|
|
526
|
+
doc(
|
|
527
|
+
withThread ? `${POINTER}[^t]` : POINTER,
|
|
528
|
+
...(withThread ? ["", thread("H: why?", "A: because")] : []),
|
|
529
|
+
"",
|
|
530
|
+
"## Rounding",
|
|
531
|
+
"",
|
|
532
|
+
"Round?",
|
|
533
|
+
"",
|
|
534
|
+
"Tick exactly one:",
|
|
535
|
+
"",
|
|
536
|
+
"- [ ] fix as suggested",
|
|
537
|
+
`- [${wont}] won't fix`,
|
|
538
|
+
"- [ ] _your answer_",
|
|
539
|
+
)
|
|
540
|
+
|
|
541
|
+
it("a reply that folds the last thread away rests at the gate again for the answers", async () => {
|
|
542
|
+
const base = threaded(" ", true).replace("\n - A: because", "")
|
|
543
|
+
const r = await driveRound(base, threaded("x", false), { inGate: threaded(" ", false) })
|
|
544
|
+
expect(r.rounds).toEqual(["reply"])
|
|
545
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
546
|
+
expect(state.marks).toEqual(["wontfix=Rounding"])
|
|
547
|
+
})
|
|
548
|
+
|
|
549
|
+
it("an answer ticked beside a thread still routes once the thread settles", async () => {
|
|
550
|
+
const base = threaded(" ", true).replace("\n - A: because", "")
|
|
551
|
+
const ticked = threaded("x", true)
|
|
552
|
+
const r = await driveRound(base, ticked, { inGate: ticked, settled: true, noDropped: true })
|
|
553
|
+
expect(r.result).toEqual({ verdict: "signoff" })
|
|
554
|
+
expect(state.marks).toEqual(["wontfix=Rounding"])
|
|
555
|
+
})
|
|
556
|
+
|
|
557
|
+
it("a hand-edit round still records its answers", async () => {
|
|
558
|
+
const r = await driveRound(open, boxes(" ", "x"), { handEdit: true })
|
|
559
|
+
expect(r.rounds).toEqual(["edits"])
|
|
560
|
+
expect(state.marks).toEqual(["wontfix=Rounding"])
|
|
561
|
+
})
|
|
562
|
+
})
|
|
563
|
+
|
|
564
|
+
describe("deleted answer boxes", () => {
|
|
565
|
+
it("are refused even when a reply turn landed them", async () => {
|
|
566
|
+
const withBoxes = doc(
|
|
567
|
+
`${POINTER}[^t]`,
|
|
568
|
+
"",
|
|
569
|
+
thread("H: why?"),
|
|
570
|
+
"",
|
|
571
|
+
"## Rounding",
|
|
572
|
+
"",
|
|
573
|
+
"Round?",
|
|
574
|
+
"",
|
|
575
|
+
"Tick exactly one:",
|
|
576
|
+
"",
|
|
577
|
+
"- [ ] fix as suggested",
|
|
578
|
+
"- [ ] won't fix",
|
|
579
|
+
"- [ ] _your answer_",
|
|
580
|
+
)
|
|
581
|
+
const gone = doc(POINTER, "", "## Rounding", "", "Round?")
|
|
582
|
+
state.since = [{ path: "r.md", status: "modified", before: withBoxes, after: gone }]
|
|
583
|
+
const r = await driveRound(withBoxes, gone, { inGate: gone })
|
|
584
|
+
expect((r.result as Error).message).toContain("its answer boxes were deleted")
|
|
585
|
+
})
|
|
586
|
+
})
|
|
@@ -3,6 +3,7 @@ import {
|
|
|
3
3
|
changesSince,
|
|
4
4
|
hasThreadFor,
|
|
5
5
|
head,
|
|
6
|
+
mark,
|
|
6
7
|
read,
|
|
7
8
|
refuse,
|
|
8
9
|
requireReplies,
|
|
@@ -11,9 +12,12 @@ import {
|
|
|
11
12
|
} from "../flows/index.js"
|
|
12
13
|
import {
|
|
13
14
|
parseThreads,
|
|
15
|
+
reviewDecisions,
|
|
14
16
|
reviewNotes,
|
|
15
17
|
reviewRisks,
|
|
16
18
|
stripCodeThreads,
|
|
19
|
+
unansweredNotes,
|
|
20
|
+
type ReviewDecision,
|
|
17
21
|
type ReviewNote,
|
|
18
22
|
} from "../steering/index.js"
|
|
19
23
|
|
|
@@ -39,7 +43,7 @@ export interface Requested {
|
|
|
39
43
|
readonly paths: readonly string[]
|
|
40
44
|
}
|
|
41
45
|
|
|
42
|
-
export type Verdict = "edit" | "question" | "nit" | "praise"
|
|
46
|
+
export type Verdict = "edit" | "question" | "nit" | "praise"
|
|
43
47
|
|
|
44
48
|
export type ReviewRisk = ReturnType<typeof reviewRisks>[number]
|
|
45
49
|
|
|
@@ -64,8 +68,8 @@ export interface ReviewPolicy {
|
|
|
64
68
|
readonly triage: (notes: readonly ReviewNote[]) => Promise<ReadonlyMap<string, Verdict>>
|
|
65
69
|
readonly answerQuestions: (notes: readonly ReviewNote[]) => Promise<void>
|
|
66
70
|
readonly fixNits: (notes: readonly ReviewNote[]) => Promise<void>
|
|
67
|
-
/**
|
|
68
|
-
readonly
|
|
71
|
+
/** Told every note the human answered this round, before any of them is routed. */
|
|
72
|
+
readonly decided?: (decisions: readonly ReviewDecision[]) => void
|
|
69
73
|
/** Remove `file` at the end of a round at commit `round`. */
|
|
70
74
|
readonly close: (round: string) => Promise<void>
|
|
71
75
|
/** What a collecting turn reads. */
|
|
@@ -112,6 +116,18 @@ const collect = async (
|
|
|
112
116
|
return refuse(p.refusals.unmatched)
|
|
113
117
|
}
|
|
114
118
|
|
|
119
|
+
/** Refuse while a note's answer boxes hold anything but exactly one tick, or a note's boxes were deleted. */
|
|
120
|
+
const requireDecisions = (file: string, reviewed: string): void => {
|
|
121
|
+
const open = unansweredNotes(read(file) ?? "", changesSince(reviewed).get(file)?.before)
|
|
122
|
+
if (open.length === 0) return
|
|
123
|
+
const list = open
|
|
124
|
+
.map((n) => ` - ${file}${n.line > 0 ? `:${n.line}` : ""}: ${n.title} — ${n.reason}`)
|
|
125
|
+
.join("\n")
|
|
126
|
+
refuse(
|
|
127
|
+
`gtd land: answer-completeness: ${open.length} note(s) in ${file} not answered — tick exactly one box per note: fix as suggested, won't fix, or your own answer written over _your answer_:\n${list}`,
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
const escapeRe = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
116
132
|
|
|
117
133
|
/**
|
|
@@ -144,6 +160,7 @@ const notesOf = (text: string): string => {
|
|
|
144
160
|
const converse = async (
|
|
145
161
|
p: ReviewPolicy,
|
|
146
162
|
base: string,
|
|
163
|
+
reviewed: string,
|
|
147
164
|
collectedBefore?: string,
|
|
148
165
|
): Promise<string | undefined> => {
|
|
149
166
|
let collectedAt = collectedBefore
|
|
@@ -155,11 +172,19 @@ const converse = async (
|
|
|
155
172
|
return "missing"
|
|
156
173
|
}
|
|
157
174
|
requireThreadsClosed(p.file)
|
|
158
|
-
if (!hasThreadFor("agent", p.file))
|
|
175
|
+
if (!hasThreadFor("agent", p.file)) {
|
|
176
|
+
requireDecisions(p.file, reviewed)
|
|
177
|
+
return collectedAt
|
|
178
|
+
}
|
|
159
179
|
await p.collecting(p.captures.edits(head()), "reply")
|
|
160
180
|
collectedAt = head()
|
|
161
181
|
requireReplies(p.file)
|
|
162
|
-
|
|
182
|
+
// A reply that folds the last thread away still leaves the notes to answer at the gate.
|
|
183
|
+
if (
|
|
184
|
+
!hasThreadFor("human", p.file) &&
|
|
185
|
+
unansweredNotes(read(p.file) ?? "", changesSince(reviewed).get(p.file)?.before).length === 0
|
|
186
|
+
)
|
|
187
|
+
return collectedAt
|
|
163
188
|
}
|
|
164
189
|
}
|
|
165
190
|
|
|
@@ -177,16 +202,51 @@ interface Round {
|
|
|
177
202
|
readonly unfolded: () => Promise<Finish>
|
|
178
203
|
}
|
|
179
204
|
|
|
180
|
-
|
|
205
|
+
const decisionNote = (d: ReviewDecision, id: string, text: string): ReviewNote => ({
|
|
206
|
+
id,
|
|
207
|
+
kind: "chunk",
|
|
208
|
+
anchor: d.anchor,
|
|
209
|
+
before: d.body,
|
|
210
|
+
text,
|
|
211
|
+
forced: /^!(\s|$)/.test(text),
|
|
212
|
+
})
|
|
213
|
+
|
|
214
|
+
/** One `Gtd-Mark` value per line: a control character would make mark() throw on every replay. */
|
|
215
|
+
const oneLine = (text: string): string =>
|
|
216
|
+
// eslint-disable-next-line no-control-regex -- the control range IS the point
|
|
217
|
+
text.replace(/[\s\x00-\x1f\x7f-\x9f\u2028\u2029]+/g, " ").trim() || "untitled"
|
|
218
|
+
|
|
219
|
+
/** Tell the policy, and mark each won't-fix on the gate's commit. */
|
|
220
|
+
const record = (p: ReviewPolicy, decisions: readonly ReviewDecision[]): void => {
|
|
221
|
+
p.decided?.(decisions)
|
|
222
|
+
for (const d of decisions) if (d.choice === "wontfix") mark("wontfix", oneLine(d.anchor))
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Judge every note, then run each verdict's route: answers, then nit fixes,
|
|
227
|
+
* then the edits' lap. Of the human's `decisions`, a fix joins the nit batch
|
|
228
|
+
* unjudged, an own answer is judged like a note, and a won't-fix is dropped
|
|
229
|
+
* with a `Gtd-Mark` on the gate's commit.
|
|
230
|
+
*/
|
|
181
231
|
export const routeNotes = async (
|
|
182
232
|
p: ReviewPolicy,
|
|
183
233
|
notes: readonly ReviewNote[],
|
|
184
234
|
r: Round,
|
|
235
|
+
decisions: readonly ReviewDecision[] = [],
|
|
185
236
|
): Promise<Finish> => {
|
|
186
|
-
|
|
187
|
-
const of = (
|
|
188
|
-
const
|
|
189
|
-
|
|
237
|
+
record(p, decisions)
|
|
238
|
+
const of = (c: ReviewDecision["choice"]) => decisions.filter((d) => d.choice === c)
|
|
239
|
+
const fixes = of("fix").map((d, i) => decisionNote(d, `fix-${i + 1}`, d.body))
|
|
240
|
+
const judged = [
|
|
241
|
+
...notes,
|
|
242
|
+
...of("answer").map((d, i) => decisionNote(d, `answer-${i + 1}`, d.text)),
|
|
243
|
+
]
|
|
244
|
+
const verdicts = judged.length === 0 ? new Map<string, Verdict>() : await p.triage(judged)
|
|
245
|
+
const ofVerdict = (v: Verdict): ReviewNote[] =>
|
|
246
|
+
judged.filter((n) => (verdicts.get(n.id) ?? "edit") === v)
|
|
247
|
+
const [edits, questions] = [ofVerdict("edit"), ofVerdict("question")]
|
|
248
|
+
const nits = [...ofVerdict("nit"), ...fixes]
|
|
249
|
+
if (edits.length + questions.length + nits.length === 0) return r.unfolded()
|
|
190
250
|
|
|
191
251
|
let answeredAt: string | undefined
|
|
192
252
|
if (questions.length > 0) {
|
|
@@ -197,7 +257,6 @@ export const routeNotes = async (
|
|
|
197
257
|
await p.fixNits(nits)
|
|
198
258
|
await p.keepGreen()
|
|
199
259
|
}
|
|
200
|
-
if (deferred.length > 0) await p.defer(deferred)
|
|
201
260
|
if (edits.length > 0) {
|
|
202
261
|
await r.close()
|
|
203
262
|
return r.outcome(
|
|
@@ -219,10 +278,13 @@ const isCodeEdit = (p: ReviewPolicy, c: Change): boolean =>
|
|
|
219
278
|
p.isCode(c.path) &&
|
|
220
279
|
stripCodeThreads(c.path, c.before ?? "") !== stripCodeThreads(c.path, c.after ?? "")
|
|
221
280
|
|
|
222
|
-
/** Whether
|
|
281
|
+
/** Whether a note or an answer, not just thread edits and pointer ticks, landed in the review file since `commit`. */
|
|
223
282
|
const notedSince = (p: ReviewPolicy, commit: string): boolean =>
|
|
224
283
|
changesSince(commit).some(
|
|
225
|
-
(c) =>
|
|
284
|
+
(c) =>
|
|
285
|
+
c.path === p.file &&
|
|
286
|
+
(notesOf(c.before ?? "") !== notesOf(read(p.file) ?? "") ||
|
|
287
|
+
reviewDecisions(c.before ?? "", read(p.file) ?? "").length > 0),
|
|
226
288
|
)
|
|
227
289
|
|
|
228
290
|
/**
|
|
@@ -251,13 +313,14 @@ const settledAfter = (
|
|
|
251
313
|
|
|
252
314
|
const finishNotes = async (
|
|
253
315
|
p: ReviewPolicy,
|
|
254
|
-
before: string,
|
|
316
|
+
before: string | undefined,
|
|
317
|
+
decisions: readonly ReviewDecision[],
|
|
255
318
|
reopened: boolean,
|
|
256
319
|
ctx: Parameters<typeof routeNotes>[2],
|
|
257
320
|
): Promise<Finish> => {
|
|
258
321
|
// An unchanged review file has no baseline entry, which would read as all-new.
|
|
259
|
-
const notes = reviewNotes(before, read(p.file) ?? "")
|
|
260
|
-
if (notes.length > 0) return routeNotes(p, notes, ctx)
|
|
322
|
+
const notes = reviewNotes(before ?? "", read(p.file) ?? "")
|
|
323
|
+
if (notes.length + decisions.length > 0) return routeNotes(p, notes, ctx, decisions)
|
|
261
324
|
if (reopened) return ctx.unfolded()
|
|
262
325
|
await ctx.close()
|
|
263
326
|
return ctx.outcome(await collect(p, p.captures.notes(ctx.round), "notes"))
|
|
@@ -271,6 +334,15 @@ const toOutcome = (
|
|
|
271
334
|
const isReopened = (p: ReviewPolicy, r: { settled: boolean; folded: boolean }): boolean =>
|
|
272
335
|
r.settled && !r.folded && p.refusals.dropped !== undefined
|
|
273
336
|
|
|
337
|
+
/** A carried answer is unchanged, never routed twice; one ticked beside a thread still routes once the thread has settled. */
|
|
338
|
+
const decisionsSince = (
|
|
339
|
+
p: ReviewPolicy,
|
|
340
|
+
since: ReturnType<typeof changesSince>,
|
|
341
|
+
): readonly ReviewDecision[] => {
|
|
342
|
+
const after = read(p.file) ?? ""
|
|
343
|
+
return reviewDecisions(since.get(p.file)?.before ?? after, after)
|
|
344
|
+
}
|
|
345
|
+
|
|
274
346
|
const finish = async (
|
|
275
347
|
p: ReviewPolicy,
|
|
276
348
|
reviewed: string,
|
|
@@ -296,8 +368,12 @@ const finish = async (
|
|
|
296
368
|
// Thread-only edits settled the round, but a change note or hand-edit since
|
|
297
369
|
// `reviewed` must still reach a collecting turn when a dropped edit is refused.
|
|
298
370
|
const reopened = isReopened(p, { settled, folded })
|
|
299
|
-
|
|
371
|
+
const decisions = decisionsSince(p, since)
|
|
372
|
+
if ((settled && !reopened && decisions.length === 0) || (edited.length === 0 && !noted))
|
|
373
|
+
return unfolded()
|
|
300
374
|
if (edited.length > 0) {
|
|
375
|
+
// A hand-edit plans a lap; its collecting turn reads the answers in the review.
|
|
376
|
+
record(p, decisions)
|
|
301
377
|
await close()
|
|
302
378
|
return outcome(
|
|
303
379
|
await collect(p, p.captures.edits(round, reopened ? reviewed : undefined), "edits", {
|
|
@@ -306,7 +382,7 @@ const finish = async (
|
|
|
306
382
|
}),
|
|
307
383
|
)
|
|
308
384
|
}
|
|
309
|
-
return finishNotes(p, since.get(p.file)?.before
|
|
385
|
+
return finishNotes(p, since.get(p.file)?.before, decisions, reopened, {
|
|
310
386
|
round,
|
|
311
387
|
close,
|
|
312
388
|
outcome,
|
|
@@ -342,7 +418,7 @@ export const review = async (base: string, p: ReviewPolicy): Promise<ReviewOutco
|
|
|
342
418
|
let reviewed = head()
|
|
343
419
|
let collectedAt: string | undefined
|
|
344
420
|
for (;;) {
|
|
345
|
-
collectedAt = await converse(p, base, collectedAt)
|
|
421
|
+
collectedAt = await converse(p, base, reviewed, collectedAt)
|
|
346
422
|
if (collectedAt === "missing") break
|
|
347
423
|
const result = await finish(p, reviewed, collectedAt)
|
|
348
424
|
if (!("next" in result)) return result
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
type CriteriaSlice,
|
|
20
20
|
} from "./criteria.js"
|
|
21
21
|
import { isTestFile } from "./refactor.js"
|
|
22
|
-
import { isCode } from "
|
|
22
|
+
import { isCode } from "../shared/review.js"
|
|
23
23
|
|
|
24
24
|
/** Below this the judge's `yes` is not trusted: a wrongly covered ID ends the feature without its test. */
|
|
25
25
|
const COVERED_MIN_P = 0.7
|
|
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"
|
|
|
2
2
|
import { runsItsScript, testRepo } from "../../testkit/index.js"
|
|
3
3
|
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
4
4
|
import { reviewDoc } from "../shared/config.fixture.js"
|
|
5
|
+
import type * as featureEntry from "./index.js"
|
|
5
6
|
|
|
6
7
|
describe("an ordinary feature start", () => {
|
|
7
8
|
it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
|
|
@@ -363,3 +364,10 @@ describe("flagMode", () => {
|
|
|
363
364
|
expect(repo.subjects()).toEqual(["chore: initial commit"])
|
|
364
365
|
})
|
|
365
366
|
})
|
|
367
|
+
|
|
368
|
+
// pins the public re-exports from the feature entry point
|
|
369
|
+
export type _PublicBuildTailOptions = [
|
|
370
|
+
featureEntry.BuildTailOptions,
|
|
371
|
+
featureEntry.FeedbackTarget,
|
|
372
|
+
featureEntry.ReviewRange,
|
|
373
|
+
]
|
|
@@ -26,7 +26,7 @@ import {
|
|
|
26
26
|
type StatedCriteria,
|
|
27
27
|
} from "./criteria.js"
|
|
28
28
|
import { design } from "./planning.js"
|
|
29
|
-
import { buildTail, isCode, reUnwind,
|
|
29
|
+
import { buildTail, humanReview, isCode, reUnwind, type ReviewOutcome } from "../shared/review.js"
|
|
30
30
|
import { implement, implementing, seamBuild } from "./seam.js"
|
|
31
31
|
import { sliceCap, sliceCriteria, slicing } from "./slicing.js"
|
|
32
32
|
import { recordRisk, riskTierOn, type RiskTier } from "./tier.js"
|
|
@@ -153,7 +153,7 @@ const settleQuality = async (): Promise<void> => {
|
|
|
153
153
|
|
|
154
154
|
/** The triaged quality lap, then the human review since `base`. */
|
|
155
155
|
const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
|
|
156
|
-
tail((escalations, report) =>
|
|
156
|
+
tail((escalations, report) => humanReview(base, escalations, { report }), {
|
|
157
157
|
green: true,
|
|
158
158
|
lensBase,
|
|
159
159
|
triage: true,
|
|
@@ -11,7 +11,7 @@ export {
|
|
|
11
11
|
type BuildTailOptions,
|
|
12
12
|
type FeedbackTarget,
|
|
13
13
|
type ReviewOutcome,
|
|
14
|
-
} from "
|
|
14
|
+
} from "../shared/review.js"
|
|
15
15
|
export type { ReviewRange } from "../shared/text.js"
|
|
16
16
|
export * from "./feature.js"
|
|
17
17
|
export * from "../shared/reserved.js"
|
|
@@ -3,9 +3,10 @@ Feature: the quality lenses are the only agentic review
|
|
|
3
3
|
|
|
4
4
|
A judge classifies each `## ` finding the lenses wrote to `.gtd/QUALITY.md`:
|
|
5
5
|
a mechanical one is fixed by the fixer, noise is dropped with its `p` kept in
|
|
6
|
-
a trailer, and a design one becomes a note in `.gtd/REVIEW.md
|
|
7
|
-
that file itself — no reviewer turn runs — and lists,
|
|
8
|
-
what was fixed automatically and what was dropped as
|
|
6
|
+
a trailer, and a design one becomes a note in `.gtd/REVIEW.md`, with answer
|
|
7
|
+
boxes. gtd writes that file itself — no reviewer turn runs — and lists,
|
|
8
|
+
without tick boxes, what was fixed automatically and what was dropped as
|
|
9
|
+
noise.
|
|
9
10
|
|
|
10
11
|
Background:
|
|
11
12
|
Given the shipped workflows, as gtd install scaffolds them
|
|
@@ -97,6 +98,12 @@ Feature: the quality lenses are the only agentic review
|
|
|
97
98
|
|
|
98
99
|
Should subtract round? The criterion does not say.
|
|
99
100
|
|
|
101
|
+
Tick exactly one:
|
|
102
|
+
|
|
103
|
+
- [ ] fix as suggested
|
|
104
|
+
- [ ] won't fix
|
|
105
|
+
- [ ] _your answer_
|
|
106
|
+
|
|
100
107
|
## Fixed automatically
|
|
101
108
|
|
|
102
109
|
- subtract has no negative test (
|
|
@@ -108,7 +115,12 @@ Feature: the quality lenses are the only agentic review
|
|
|
108
115
|
|
|
109
116
|
- add could be a method (p=0.85)
|
|
110
117
|
"""
|
|
111
|
-
And ".gtd/REVIEW.md"
|
|
118
|
+
And ".gtd/REVIEW.md" contains:
|
|
119
|
+
"""
|
|
120
|
+
## Fixed automatically
|
|
121
|
+
|
|
122
|
+
- subtract has no negative test (
|
|
123
|
+
"""
|
|
112
124
|
And the commit subjects end with:
|
|
113
125
|
"""
|
|
114
126
|
gtd(check): implement.closing → build.quality.correctness.reviewing
|
|
@@ -20,7 +20,7 @@ import { healthy, type Guard } from "../shared/health.js"
|
|
|
20
20
|
import { coder, FEEDBACK, HANDOFF, REFACTOR } from "../shared/steps.js"
|
|
21
21
|
import * as t from "../shared/text.js"
|
|
22
22
|
import { defaults, testFileGlobs } from "../shared/vars.js"
|
|
23
|
-
import { buildTail, reUnwind, type FeedbackTarget } from "
|
|
23
|
+
import { buildTail, reUnwind, type FeedbackTarget } from "../shared/review.js"
|
|
24
24
|
import { refactorCollecting } from "./steps.js"
|
|
25
25
|
|
|
26
26
|
export const REFACTOR_PACKAGE = REFACTOR
|