@pmelab/gtd 41.0.0 → 43.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +107 -53
- package/claude/hooks/sketch.ts +1 -1
- package/dist/chunks/{reserved-Ch4LLrEj.mjs → reserved-COEMFEpy.mjs} +84 -51
- package/dist/chunks/{steering-DVILanVf.mjs → steering-D4LUQ-av.mjs} +95 -12
- package/dist/chunks/tail-C_xn8U7U.mjs +246 -0
- package/dist/flows.mjs +11 -1
- package/dist/gtd.bundle.mjs +498 -30
- package/dist/steering.mjs +2 -2
- package/dist/testkit.mjs +460 -28
- package/dist/web/main.js +42 -13
- package/dist/workflow-kit.mjs +61 -25
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +66 -57
- package/dist/workflows/fix.mjs +3 -3
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +446 -141
- package/package.json +1 -1
- package/src/cli/Cli.test.ts +33 -1
- package/src/cli/Cli.ts +70 -6
- package/src/cli/History.ts +15 -0
- package/src/cli/Judges.test.ts +373 -0
- package/src/cli/Judges.ts +624 -0
- package/src/cli/Metrics.ts +3 -16
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +9 -0
- package/src/flows/scripts.ts +15 -0
- package/src/steering/index.ts +12 -1
- package/src/steering/review.test.ts +109 -20
- package/src/steering/review.ts +160 -9
- package/src/testkit/EmittedScriptRecognizer.ts +0 -14
- package/src/testkit/FlowScripts.test.ts +17 -0
- package/src/testkit/FlowScripts.ts +15 -0
- package/src/testkit/steps.ts +31 -0
- package/src/workflow-kit/review.test.ts +170 -26
- package/src/workflow-kit/review.ts +95 -19
- package/src/workflows/feature/coverage.feature +3 -3
- package/src/workflows/feature/fast-path.feature +2 -2
- package/src/workflows/feature/feature.test.ts +2 -37
- package/src/workflows/feature/feature.ts +12 -14
- package/src/workflows/feature/flag-mode.feature +2 -2
- package/src/workflows/feature/lens-review.feature +246 -0
- package/src/workflows/feature/one-step-land.feature +2 -2
- package/src/workflows/feature/quality-unresolved.feature +47 -32
- package/src/workflows/feature/refactor-absorbs.feature +3 -3
- package/src/workflows/feature/refactor-first.feature +5 -5
- package/src/workflows/feature/refactor-rebuild.feature +7 -7
- package/src/workflows/feature/refactor-review.feature +19 -100
- package/src/workflows/feature/refactor.test.ts +12 -27
- package/src/workflows/feature/refactor.ts +0 -1
- package/src/workflows/feature/review-answers.feature +179 -0
- package/src/workflows/feature/review-gate.feature +6 -6
- package/src/workflows/feature/review.test.ts +33 -77
- package/src/workflows/feature/review.ts +79 -47
- package/src/workflows/feature/seam-rejection.feature +2 -2
- package/src/workflows/feature/seam.feature +2 -2
- package/src/workflows/feature/seam.test.ts +1 -1
- package/src/workflows/feature/slicing.feature +6 -6
- package/src/workflows/feature/steps.test.ts +0 -12
- package/src/workflows/feature/steps.ts +3 -10
- package/src/workflows/index.ts +1 -0
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
- package/src/workflows/shared/access.test.ts +2 -11
- package/src/workflows/shared/access.ts +0 -2
- package/src/workflows/shared/lenses.test.ts +39 -0
- package/src/workflows/shared/lenses.ts +176 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/shared/tail.ts +19 -4
- package/src/workflows/shared/text.test.ts +106 -88
- package/src/workflows/shared/text.ts +164 -53
- package/src/workflows/shared/vars.ts +1 -0
- package/dist/chunks/tail-lY1UmJrU.mjs +0 -112
|
@@ -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
|
|
@@ -61,13 +61,13 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
61
61
|
[{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
|
|
62
62
|
"""
|
|
63
63
|
When the process is driven
|
|
64
|
-
Then the process rests at "build.review.
|
|
64
|
+
Then the process rests at "build.review.composing"
|
|
65
65
|
And the commit subjects end with:
|
|
66
66
|
"""
|
|
67
67
|
gtd(check): implement.applying → implement.health.check
|
|
68
68
|
gtd(check): implement.health.check → implement.coverage
|
|
69
69
|
gtd(judge): implement.coverage → implement.closing
|
|
70
|
-
gtd(check): implement.closing → build.review.
|
|
70
|
+
gtd(check): implement.closing → build.review.composing
|
|
71
71
|
"""
|
|
72
72
|
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
73
73
|
And the commit "gtd(judge): implement.coverage → implement.closing" has a body containing "Gtd-Mark: covers=AC-2"
|
|
@@ -144,7 +144,7 @@ Feature: a feature is done only when every acceptance criterion has a passing te
|
|
|
144
144
|
"""
|
|
145
145
|
And step "implement.uncovered" changes nothing
|
|
146
146
|
When the process is driven
|
|
147
|
-
Then the process rests at "build.review.
|
|
147
|
+
Then the process rests at "build.review.composing"
|
|
148
148
|
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: covers=AC-1"
|
|
149
149
|
And the commit "gtd(human): implement.uncovered → implement.closing" has a body containing "Gtd-Mark: uncovered=AC-2"
|
|
150
150
|
|
|
@@ -64,7 +64,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
64
64
|
"""
|
|
65
65
|
And step "implement.applying" runs its script
|
|
66
66
|
When the process is driven
|
|
67
|
-
Then the process rests at "build.review.
|
|
67
|
+
Then the process rests at "build.review.composing"
|
|
68
68
|
And "src/greeting.ts" contains "Hello, world"
|
|
69
69
|
And the commit subjects end with:
|
|
70
70
|
"""
|
|
@@ -80,7 +80,7 @@ Feature: a trivial change takes the same few beats at either risk tier
|
|
|
80
80
|
gtd(check): implement.health.check → implement.coverage
|
|
81
81
|
gtd(judge): implement.coverage → implement.closing
|
|
82
82
|
gtd(check): implement.closing → build.quality.correctness.reviewing
|
|
83
|
-
gtd(agent): build.quality.correctness.reviewing → build.review.
|
|
83
|
+
gtd(agent): build.quality.correctness.reviewing → build.review.composing
|
|
84
84
|
"""
|
|
85
85
|
And the output of gtd "summary" <summary> "needs a human reviewer"
|
|
86
86
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { runsItsScript, testRepo
|
|
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
5
|
|
|
@@ -75,10 +75,7 @@ describe("afterTail feedback lap", () => {
|
|
|
75
75
|
`}`,
|
|
76
76
|
].join("\n")
|
|
77
77
|
|
|
78
|
-
const prompts: string[] = []
|
|
79
|
-
|
|
80
78
|
const feedbackLap = async () => {
|
|
81
|
-
prompts.length = 0
|
|
82
79
|
const repo = testRepo({
|
|
83
80
|
workflow: TAIL,
|
|
84
81
|
env: {
|
|
@@ -93,10 +90,7 @@ describe("afterTail feedback lap", () => {
|
|
|
93
90
|
await repo.drive({
|
|
94
91
|
"review-gate.check": () => undefined,
|
|
95
92
|
"build.health.check": () => undefined,
|
|
96
|
-
"build.review.
|
|
97
|
-
prompts.push(beat.content)
|
|
98
|
-
r.write(".gtd/REVIEW.md", review())
|
|
99
|
-
},
|
|
93
|
+
"build.review.composing": (_, r) => r.write(".gtd/REVIEW.md", review()),
|
|
100
94
|
})
|
|
101
95
|
repo.write(".gtd/REVIEW.md", review("also export multiply"))
|
|
102
96
|
await repo.land()
|
|
@@ -137,35 +131,6 @@ describe("afterTail feedback lap", () => {
|
|
|
137
131
|
expect(rest.content).not.toContain("write the change fresh")
|
|
138
132
|
expect(repo.subjects().some((subject) => subject.includes("seam."))).toBe(false)
|
|
139
133
|
})
|
|
140
|
-
|
|
141
|
-
it("the first review says everything since base, the one after feedback only what is new", async () => {
|
|
142
|
-
const repo = await feedbackLap()
|
|
143
|
-
expect(prompts[0]).toContain("it covers everything since the base")
|
|
144
|
-
expect(prompts[0]).not.toContain("only what is new since the previous review")
|
|
145
|
-
const writes: Record<string, () => [string, string]> = {
|
|
146
|
-
"design.triage": () => [
|
|
147
|
-
".gtd/REQUIREMENTS.md",
|
|
148
|
-
"# Requirements\n\n- multiply, designed\n\n## Acceptance Criteria\n\n- AC-1: done\n",
|
|
149
|
-
],
|
|
150
|
-
"implement.building": () => ["src/calc.ts", `${CALC}// again\n`],
|
|
151
|
-
}
|
|
152
|
-
const actors = new Proxy({} as Record<string, Actor>, {
|
|
153
|
-
get: (_, state: string): Actor | undefined =>
|
|
154
|
-
state === "build.review.await-review" || state === "idle"
|
|
155
|
-
? undefined
|
|
156
|
-
: (beat, r) => {
|
|
157
|
-
if (state === "implement.coverage") return '[{"id":"AC-1","answer":"yes","p":0.9}]'
|
|
158
|
-
if (state === "build.review.reviewing") {
|
|
159
|
-
prompts.push(beat.content)
|
|
160
|
-
r.write(".gtd/REVIEW.md", review())
|
|
161
|
-
} else if (writes[state]) r.write(...writes[state]())
|
|
162
|
-
else if (state.endsWith("closing") || state === "re-unwind") r.runScript(beat.content)
|
|
163
|
-
},
|
|
164
|
-
})
|
|
165
|
-
await repo.drive(actors, { maxBeats: 80 })
|
|
166
|
-
expect(prompts[1]).toContain("it covers only what is new since the previous review")
|
|
167
|
-
expect(prompts[1]).not.toContain("everything since the base")
|
|
168
|
-
})
|
|
169
134
|
})
|
|
170
135
|
|
|
171
136
|
describe("an in-flight 28.0.0 naive-pass process", () => {
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
slice,
|
|
10
10
|
start,
|
|
11
11
|
} from "../../flows/index.js"
|
|
12
|
-
import { gate, healthy
|
|
12
|
+
import { gate, healthy } from "../shared/health.js"
|
|
13
13
|
import { flagModeOn, shipped } from "../shared/options.js"
|
|
14
14
|
import { FEEDBACK, fixCheck, QUALITY, REQUIREMENTS } from "../shared/steps.js"
|
|
15
15
|
import { refusePendingHandOff, tail } from "../shared/tail.js"
|
|
@@ -135,8 +135,8 @@ const buildSlice = async (sliceBase: string, build: SliceBuild): Promise<void> =
|
|
|
135
135
|
})
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
-
//
|
|
139
|
-
//
|
|
138
|
+
// Between slices nothing reviews the lap's leftovers, and the next lap appends to the file.
|
|
139
|
+
// Empty counts as resolved, as the lap reads it.
|
|
140
140
|
const settleQuality = async (): Promise<void> => {
|
|
141
141
|
let rested = false
|
|
142
142
|
while ((read(QUALITY) ?? "").length > 0) {
|
|
@@ -151,15 +151,13 @@ const settleQuality = async (): Promise<void> => {
|
|
|
151
151
|
if (rested) await healthy(() => fixCheck())
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
/** The quality lap, then the human review since `base`. */
|
|
155
|
-
const reviewedTail = (base: string, lensBase: string
|
|
156
|
-
tail(
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
{ green: true, lensBase },
|
|
162
|
-
)
|
|
154
|
+
/** The triaged quality lap, then the human review since `base`. */
|
|
155
|
+
const reviewedTail = (base: string, lensBase: string): Promise<ReviewOutcome> =>
|
|
156
|
+
tail((escalations, report) => review(base, escalations, { report }), {
|
|
157
|
+
green: true,
|
|
158
|
+
lensBase,
|
|
159
|
+
triage: true,
|
|
160
|
+
})
|
|
163
161
|
|
|
164
162
|
// seam.naive / seam.judge never run on a feedback lap: its design → implement is built unjudged
|
|
165
163
|
// and unrefactored, held to every criterion's coverage since the process start.
|
|
@@ -181,7 +179,7 @@ const featureFeedback = async (
|
|
|
181
179
|
})
|
|
182
180
|
const tier = riskTierOn() ? recordRisk() : undefined
|
|
183
181
|
closed = await implementing({ tier, amend: true })
|
|
184
|
-
outcome = await reviewedTail(outcome.base, lensBase
|
|
182
|
+
outcome = await reviewedTail(outcome.base, lensBase)
|
|
185
183
|
}
|
|
186
184
|
}
|
|
187
185
|
|
|
@@ -201,7 +199,7 @@ export const afterTail = async (first: ReviewOutcome): Promise<void> => {
|
|
|
201
199
|
await design(outcome.base)
|
|
202
200
|
const tier = riskTierOn() ? recordRisk() : undefined
|
|
203
201
|
await implement({ tier, amend: true })
|
|
204
|
-
outcome = await buildTail(outcome.base, { green: true
|
|
202
|
+
outcome = await buildTail(outcome.base, { green: true })
|
|
205
203
|
}
|
|
206
204
|
}
|
|
207
205
|
|