@pmelab/gtd 44.0.0 → 45.1.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 +40 -9
- package/dist/chunks/{reserved-DJk_6-ju.mjs → reserved-ulcSkND6.mjs} +142 -5
- package/dist/chunks/{review-X7Jfz0dY.mjs → review-Da2ndnB1.mjs} +177 -7
- package/dist/chunks/{tail-5w5mxGTq.mjs → tail-BW030xz_.mjs} +1 -1
- package/dist/gtd.bundle.mjs +7 -5
- package/dist/testkit.mjs +7 -5
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +3 -3
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +4 -15
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows/tests.mjs +193 -0
- package/dist/workflows.mjs +533 -40
- package/package.json +6 -1
- package/src/install/ConfigScaffold.ts +2 -1
- package/src/install/Install.ts +5 -4
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/index.ts +2 -1
- package/src/workflows/review/review-door.feature +11 -2
- package/src/workflows/review/review.ts +3 -16
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/lenses.ts +5 -1
- package/src/workflows/shared/options.ts +22 -1
- package/src/workflows/shared/review.ts +29 -2
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +3 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -0
- package/src/workflows/shared/text.test.ts +58 -1
- package/src/workflows/shared/text.ts +33 -3
- package/src/workflows/shared/vars.ts +10 -0
- package/src/workflows/tests/index.ts +6 -0
- package/src/workflows/tests/tests-options.feature +52 -0
- package/src/workflows/tests/tests.feature +266 -0
- package/src/workflows/tests/tests.test.ts +51 -0
- package/src/workflows/tests/tests.ts +270 -0
|
@@ -1,24 +1,11 @@
|
|
|
1
|
-
import { read, refuse,
|
|
2
|
-
import { buildTail, reUnwind } from "../shared/review.js"
|
|
1
|
+
import { read, refuse, start, type WorkflowBase } from "../../flows/index.js"
|
|
2
|
+
import { buildTail, fixFeedback, reUnwind } from "../shared/review.js"
|
|
3
3
|
import { gate } from "../shared/health.js"
|
|
4
4
|
import { shipped } from "../shared/options.js"
|
|
5
|
-
import {
|
|
5
|
+
import { REQUIREMENTS } from "../shared/steps.js"
|
|
6
6
|
import { refusePendingHandOff } from "../shared/tail.js"
|
|
7
7
|
import * as t from "../shared/text.js"
|
|
8
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
|
-
|
|
22
9
|
/**
|
|
23
10
|
* The feature's review of everything since `reviewBase` — a green gate, the
|
|
24
11
|
* triaged quality lap, then the human review — with no design phase: a
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest"
|
|
2
|
+
|
|
3
|
+
const state = vi.hoisted(() => ({
|
|
4
|
+
files: {} as Record<string, string | undefined>,
|
|
5
|
+
vars: { standardMinP: "0.8" } as Record<string, string>,
|
|
6
|
+
answers: {} as Record<string, { answer: string; p: number } | undefined>,
|
|
7
|
+
judged: [] as string[][],
|
|
8
|
+
written: {} as Record<string, string>,
|
|
9
|
+
}))
|
|
10
|
+
|
|
11
|
+
vi.mock("../../flows/index.js", async (original) => ({
|
|
12
|
+
...(await original<typeof import("../../flows/index.js")>()),
|
|
13
|
+
read: (path: string) => state.files[path],
|
|
14
|
+
vars: state.vars,
|
|
15
|
+
judge: async (_: string, spec: { questions: { id: string }[] }) => {
|
|
16
|
+
state.judged.push(spec.questions.map((q) => q.id))
|
|
17
|
+
return { answers: state.answers, truncated: [] }
|
|
18
|
+
},
|
|
19
|
+
run: async (_: string, script: string) => {
|
|
20
|
+
const path = /> '([^']+)'/.exec(script)?.[1] ?? ""
|
|
21
|
+
const content = /printf '%s' '((?:[^']|'\\'')*)'/.exec(script)?.[1] ?? ""
|
|
22
|
+
state.written[path] = content.replaceAll("'\\''", "'")
|
|
23
|
+
},
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
import type { ReviewDecision } from "../../steering/index.js"
|
|
27
|
+
import { learnStandards } from "./learn.js"
|
|
28
|
+
|
|
29
|
+
const STANDARDS = ".gtd/STANDARDS.md"
|
|
30
|
+
|
|
31
|
+
const file = (...lines: string[]): string =>
|
|
32
|
+
[
|
|
33
|
+
"# Standards",
|
|
34
|
+
"",
|
|
35
|
+
"Kept from your review answers for every agent turn. One standard per bullet.",
|
|
36
|
+
"",
|
|
37
|
+
...lines,
|
|
38
|
+
"",
|
|
39
|
+
].join("\n")
|
|
40
|
+
|
|
41
|
+
const answer = (anchor: string, text: string): ReviewDecision => ({
|
|
42
|
+
anchor,
|
|
43
|
+
body: "",
|
|
44
|
+
choice: "answer",
|
|
45
|
+
text,
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const yes = { answer: "yes", p: 0.9 }
|
|
49
|
+
const no = { answer: "no", p: 0.9 }
|
|
50
|
+
const kept = () => state.written[STANDARDS]
|
|
51
|
+
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
state.files = { ".gtd/REVIEW.md": "# Review\n" }
|
|
54
|
+
state.vars.standardMinP = "0.8"
|
|
55
|
+
state.answers = {}
|
|
56
|
+
state.judged = []
|
|
57
|
+
state.written = {}
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe("learnStandards", () => {
|
|
61
|
+
it("asks a later candidate about each earlier one, and holds a contradiction for the human", async () => {
|
|
62
|
+
state.answers = { "standard-1": yes, "standard-2": yes, "standard-2.contradicts-new-1": yes }
|
|
63
|
+
await learnStandards([answer("A", "Use classes."), answer("B", "Never use classes.")])
|
|
64
|
+
expect(state.judged[0]).toContain("standard-2.contradicts-new-1")
|
|
65
|
+
expect(kept()).toBe(
|
|
66
|
+
file(
|
|
67
|
+
"- Use classes. (re: A)",
|
|
68
|
+
"",
|
|
69
|
+
"## Undecided",
|
|
70
|
+
"",
|
|
71
|
+
"- Never use classes. (re: B)",
|
|
72
|
+
" - contradicts: Use classes. (re: A)",
|
|
73
|
+
),
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it("raises a contradiction ruled out below the threshold", async () => {
|
|
78
|
+
state.files[STANDARDS] = file("- Old.")
|
|
79
|
+
state.answers = { "standard-1": yes, "standard-1.contradicts-1": { answer: "no", p: 0.5 } }
|
|
80
|
+
await learnStandards([answer("A", "New.")])
|
|
81
|
+
expect(kept()).toBe(
|
|
82
|
+
file("- Old.", "", "## Undecided", "", "- New. (re: A)", " - contradicts: Old."),
|
|
83
|
+
)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it("keeps one the judge confidently calls compatible", async () => {
|
|
87
|
+
state.files[STANDARDS] = file("- Old.")
|
|
88
|
+
state.answers = { "standard-1": yes, "standard-1.contradicts-1": no }
|
|
89
|
+
await learnStandards([answer("A", "New.")])
|
|
90
|
+
expect(kept()).toBe(file("- Old.", "- New. (re: A)"))
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
it("asks no judge when standardMinP is blank", async () => {
|
|
94
|
+
state.vars.standardMinP = ""
|
|
95
|
+
await learnStandards([answer("A", "New.")])
|
|
96
|
+
expect(state.judged).toEqual([])
|
|
97
|
+
expect(state.written).toEqual({})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it("drops a replaced standard from another open conflict", async () => {
|
|
101
|
+
state.files[STANDARDS] = file(
|
|
102
|
+
"- O.",
|
|
103
|
+
"",
|
|
104
|
+
"## Undecided",
|
|
105
|
+
"",
|
|
106
|
+
"- A.",
|
|
107
|
+
" - contradicts: O.",
|
|
108
|
+
"- B.",
|
|
109
|
+
" - contradicts: O.",
|
|
110
|
+
)
|
|
111
|
+
// The conflict note's title hashes its new standard, "A.".
|
|
112
|
+
await learnStandards([
|
|
113
|
+
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
114
|
+
])
|
|
115
|
+
expect(kept()).toBe(file("- A.", "", "## Undecided", "", "- B."))
|
|
116
|
+
})
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
describe("learnStandards, from a won't-fix", () => {
|
|
120
|
+
it("states the finding, not gtd's banner", async () => {
|
|
121
|
+
state.answers = { "standard-1": yes }
|
|
122
|
+
await learnStandards([
|
|
123
|
+
{
|
|
124
|
+
anchor: "Slow loop",
|
|
125
|
+
// As the review parser hands it over: paragraphs joined by a space.
|
|
126
|
+
body: "_The fixer left this unresolved._ The loop is quadratic.",
|
|
127
|
+
choice: "wontfix",
|
|
128
|
+
text: "",
|
|
129
|
+
},
|
|
130
|
+
])
|
|
131
|
+
expect(kept()).toBe(file("- Won't fix: Slow loop — The loop is quadratic."))
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("replaces an old standard hand-edited in case only", async () => {
|
|
135
|
+
state.files[STANDARDS] = file(
|
|
136
|
+
"- use classes.",
|
|
137
|
+
"",
|
|
138
|
+
"## Undecided",
|
|
139
|
+
"",
|
|
140
|
+
"- A.",
|
|
141
|
+
" - contradicts: Use classes.",
|
|
142
|
+
)
|
|
143
|
+
await learnStandards([
|
|
144
|
+
{ anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
|
|
145
|
+
])
|
|
146
|
+
expect(kept()).toBe(file("- A."))
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it("does not learn again what a formatter rewrote", async () => {
|
|
150
|
+
state.files[STANDARDS] = file("- Prefer _named_ exports. (re: A)")
|
|
151
|
+
await learnStandards([answer("A", "Prefer *named* exports.")])
|
|
152
|
+
expect(state.judged).toEqual([])
|
|
153
|
+
})
|
|
154
|
+
})
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import {
|
|
2
|
+
answered,
|
|
3
|
+
changesSince,
|
|
4
|
+
judge,
|
|
5
|
+
numeric,
|
|
6
|
+
read,
|
|
7
|
+
run,
|
|
8
|
+
vars,
|
|
9
|
+
writeScript,
|
|
10
|
+
type JudgeQuestion,
|
|
11
|
+
} from "../../flows/index.js"
|
|
12
|
+
import type { ReviewDecision } from "../../steering/index.js"
|
|
13
|
+
import {
|
|
14
|
+
conflictBody,
|
|
15
|
+
conflictFor,
|
|
16
|
+
conflictTitle,
|
|
17
|
+
isConflictTitle,
|
|
18
|
+
oneLine,
|
|
19
|
+
parseStandards,
|
|
20
|
+
standardKey,
|
|
21
|
+
renderStandards,
|
|
22
|
+
STANDARDS,
|
|
23
|
+
type Conflict,
|
|
24
|
+
type Standards,
|
|
25
|
+
} from "./standards.js"
|
|
26
|
+
import { REVIEW } from "./steps.js"
|
|
27
|
+
|
|
28
|
+
interface Candidate {
|
|
29
|
+
readonly decision: ReviewDecision
|
|
30
|
+
readonly text: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const SUMMARY_MAX = 200
|
|
34
|
+
|
|
35
|
+
// The banners composedReview opens a note with say nothing about the finding; the
|
|
36
|
+
// parsed body has its paragraphs joined, so they are matched by their own text.
|
|
37
|
+
const BANNER =
|
|
38
|
+
/^_(?:The fixer left this unresolved\.|A fix turn removed this while renaming another finding — check whether it was fixed\.|No detail given\.)_\s*/
|
|
39
|
+
|
|
40
|
+
const summary = (body: string): string => {
|
|
41
|
+
const text = oneLine(body).replace(BANNER, "")
|
|
42
|
+
return text.length > SUMMARY_MAX ? `${text.slice(0, SUMMARY_MAX - 1)}…` : text
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The standard a won't-fix or an own answer would make; a fix states none. */
|
|
46
|
+
const candidateOf = (d: ReviewDecision): Candidate[] => {
|
|
47
|
+
const anchor = oneLine(d.anchor)
|
|
48
|
+
if (d.choice === "wontfix") {
|
|
49
|
+
const why = summary(d.body)
|
|
50
|
+
return [{ decision: d, text: `Won't fix: ${anchor}${why === "" ? "" : ` — ${why}`}` }]
|
|
51
|
+
}
|
|
52
|
+
if (d.choice === "answer") return [{ decision: d, text: `${oneLine(d.text)} (re: ${anchor})` }]
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// By key, as everywhere else: a hand-edit to case or emphasis still names the same standard.
|
|
57
|
+
const without = (list: readonly string[], drop: readonly string[]): string[] => {
|
|
58
|
+
const keys = new Set(drop.map(standardKey))
|
|
59
|
+
return list.filter((s) => !keys.has(standardKey(s)))
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const adding = (list: readonly string[], text: string): string[] =>
|
|
63
|
+
list.some((s) => standardKey(s) === standardKey(text)) ? [...list] : [...list, text]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Keep the new one, keep the old one, or replace both with the human's own
|
|
67
|
+
* wording. Another open conflict stops naming a replaced standard: the human
|
|
68
|
+
* never judged it against the replacement, so keeping it must not drop that.
|
|
69
|
+
*/
|
|
70
|
+
const resolve = (standards: Standards, d: ReviewDecision): Standards => {
|
|
71
|
+
const c = conflictFor(standards, d.anchor)
|
|
72
|
+
if (c === undefined) return standards
|
|
73
|
+
const others = standards.undecided.filter((u) => u !== c)
|
|
74
|
+
if (d.choice === "wontfix") return { decided: standards.decided, undecided: others }
|
|
75
|
+
const kept = d.choice === "fix" ? c.text : oneLine(d.text)
|
|
76
|
+
const undecided = others.map((u) => ({ text: u.text, olds: without(u.olds, c.olds) }))
|
|
77
|
+
return { decided: adding(without(standards.decided, c.olds), kept), undecided }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const contradicts = (i: number, id: string, other: string): JudgeQuestion => ({
|
|
81
|
+
id: `standard-${i}.contradicts-${id}`,
|
|
82
|
+
primitive: "noul",
|
|
83
|
+
instructions: `Does the "Standard:" line of evidence "standard-${i}" contradict ${other}?`,
|
|
84
|
+
criteria:
|
|
85
|
+
"yes: following one breaks the other. no: both can be followed — they are compatible or unrelated.",
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Each candidate against every kept standard and every candidate before it.
|
|
89
|
+
const questionsFor = (i: number, anchor: string, decided: readonly string[]): JudgeQuestion[] => [
|
|
90
|
+
{
|
|
91
|
+
id: `standard-${i}`,
|
|
92
|
+
primitive: "noul",
|
|
93
|
+
instructions: `Is the human's answer to review note "${anchor}" (evidence "standard-${i}") a reusable standard for this codebase — a rule later changes and reviews should follow — rather than a call about this one note? Its "Standard:" line is the wording gtd would keep.`,
|
|
94
|
+
criteria:
|
|
95
|
+
"yes: it states a rule or preference that applies beyond this note. no: it is specific to this note, a one-off call, or unclear.",
|
|
96
|
+
},
|
|
97
|
+
...decided.map((_, k) =>
|
|
98
|
+
contradicts(i, `${k + 1}`, `kept standard ${k + 1} (evidence "kept-${k + 1}")`),
|
|
99
|
+
),
|
|
100
|
+
...Array.from({ length: i - 1 }, (_, j) =>
|
|
101
|
+
contradicts(i, `new-${j + 1}`, `the "Standard:" line of evidence "standard-${j + 1}"`),
|
|
102
|
+
),
|
|
103
|
+
]
|
|
104
|
+
|
|
105
|
+
const evidenceOf = (c: Candidate): string =>
|
|
106
|
+
[
|
|
107
|
+
`Note: ${c.decision.anchor}`,
|
|
108
|
+
`Reviewer's text: ${c.decision.body}`,
|
|
109
|
+
`Human's answer: ${c.decision.choice === "wontfix" ? "won't fix" : c.decision.text}`,
|
|
110
|
+
`Standard: ${c.text}`,
|
|
111
|
+
].join("\n")
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* One judge rest over every candidate. Only a confident `yes` on uncut
|
|
115
|
+
* evidence makes a standard; a contradiction the judge does not confidently
|
|
116
|
+
* rule out goes to the human instead of overwriting what they decided before.
|
|
117
|
+
* A cut kept standard leaves the round unlearned: raise `judgeBudgetBytes`.
|
|
118
|
+
*/
|
|
119
|
+
const judged = async (
|
|
120
|
+
candidates: readonly Candidate[],
|
|
121
|
+
decided: readonly string[],
|
|
122
|
+
minP: number,
|
|
123
|
+
): Promise<{ added: string[]; raised: Conflict[] }> => {
|
|
124
|
+
const { answers, truncated } = await judge("review.standards.judge", {
|
|
125
|
+
questions: candidates.flatMap((c, i) => questionsFor(i + 1, c.decision.anchor, decided)),
|
|
126
|
+
evidence: Object.fromEntries([
|
|
127
|
+
...candidates.map((c, i) => [`standard-${i + 1}`, evidenceOf(c)]),
|
|
128
|
+
...decided.map((s, k) => [`kept-${k + 1}`, s]),
|
|
129
|
+
]),
|
|
130
|
+
message:
|
|
131
|
+
"Judging whether your answers are standards to keep: run `gtd judge run`, or land without a verdict, which keeps none.",
|
|
132
|
+
label: "Learning your standards",
|
|
133
|
+
})
|
|
134
|
+
const ruledOut = (i: number, id: string): boolean =>
|
|
135
|
+
answered(answers[`standard-${i}.contradicts-${id}`], "no", minP)
|
|
136
|
+
const added: { i: number; text: string }[] = []
|
|
137
|
+
const raised: Conflict[] = []
|
|
138
|
+
candidates.forEach((c, n) => {
|
|
139
|
+
const i = n + 1
|
|
140
|
+
if (truncated.includes(`standard-${i}`)) return
|
|
141
|
+
if (decided.some((_, k) => truncated.includes(`kept-${k + 1}`))) return
|
|
142
|
+
if (!answered(answers[`standard-${i}`], "yes", minP)) return
|
|
143
|
+
const olds = [
|
|
144
|
+
...decided.filter((_, k) => !ruledOut(i, `${k + 1}`)),
|
|
145
|
+
...added.filter((a) => !ruledOut(i, `new-${a.i}`)).map((a) => a.text),
|
|
146
|
+
]
|
|
147
|
+
if (olds.length === 0) added.push({ i, text: c.text })
|
|
148
|
+
else raised.push({ text: c.text, olds })
|
|
149
|
+
})
|
|
150
|
+
return { added: added.map((a) => a.text), raised }
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Settles every answer to a standard conflict, then learns standards from
|
|
155
|
+
* the won't-fix ticks and own answers: kept in `.gtd/STANDARDS.md`, a
|
|
156
|
+
* contradicting one as a new conflict note in `.gtd/REVIEW.md`.
|
|
157
|
+
*/
|
|
158
|
+
export const learnStandards = async (decisions: readonly ReviewDecision[]): Promise<void> => {
|
|
159
|
+
const before = parseStandards(read(STANDARDS) ?? "")
|
|
160
|
+
const rest = decisions.filter((d) => !isConflictTitle(d.anchor))
|
|
161
|
+
const settled = decisions.filter((d) => isConflictTitle(d.anchor)).reduce(resolve, before)
|
|
162
|
+
const known = new Set(
|
|
163
|
+
[...settled.decided, ...settled.undecided.map((c) => c.text)].map(standardKey),
|
|
164
|
+
)
|
|
165
|
+
const candidates = rest.flatMap(candidateOf).filter((c) => {
|
|
166
|
+
const key = standardKey(c.text)
|
|
167
|
+
if (known.has(key)) return false
|
|
168
|
+
known.add(key)
|
|
169
|
+
return true
|
|
170
|
+
})
|
|
171
|
+
// Blank keeps none, so there is nothing to ask.
|
|
172
|
+
const minP = numeric(vars.standardMinP, Infinity)
|
|
173
|
+
const { added, raised } =
|
|
174
|
+
candidates.length === 0 || !Number.isFinite(minP)
|
|
175
|
+
? { added: [], raised: [] }
|
|
176
|
+
: await judged(candidates, settled.decided, minP)
|
|
177
|
+
const after: Standards = {
|
|
178
|
+
decided: [...settled.decided, ...added],
|
|
179
|
+
undecided: [...settled.undecided, ...raised],
|
|
180
|
+
}
|
|
181
|
+
if (renderStandards(after) !== renderStandards(before)) {
|
|
182
|
+
await run("review.standards.recording", writeScript(STANDARDS, renderStandards(after)), {
|
|
183
|
+
label: "Recording your standards",
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
if (raised.length > 0) {
|
|
187
|
+
const chunks = raised.map((c) => `## ${conflictTitle(c)}\n\n${conflictBody(c)}`)
|
|
188
|
+
await run(
|
|
189
|
+
"review.standards.raising",
|
|
190
|
+
writeScript(REVIEW, `${(read(REVIEW) ?? "").trimEnd()}\n\n${chunks.join("\n\n")}\n`),
|
|
191
|
+
{ label: "Asking about a contradicting standard", file: REVIEW },
|
|
192
|
+
)
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export const settlesStandard = (d: ReviewDecision): boolean => isConflictTitle(d.anchor)
|
|
197
|
+
|
|
198
|
+
export const standardsPending = (): boolean =>
|
|
199
|
+
parseStandards(read(STANDARDS) ?? "").undecided.length > 0
|
|
200
|
+
|
|
201
|
+
export const standardsAddedSince = (base: string): readonly string[] => {
|
|
202
|
+
const change = changesSince(base).get(STANDARDS)
|
|
203
|
+
if (change === undefined) return []
|
|
204
|
+
const was = new Set(parseStandards(change.before ?? "").decided)
|
|
205
|
+
return parseStandards(change.after ?? "").decided.filter((s) => !was.has(s))
|
|
206
|
+
}
|
|
@@ -29,7 +29,11 @@ type FindingKind = "mechanical" | "noise" | "design"
|
|
|
29
29
|
/** What the triaged quality lap did with its findings, for the review to list. */
|
|
30
30
|
export interface LensReport {
|
|
31
31
|
/** Design findings, then the mechanical ones the fix cap left. */
|
|
32
|
-
readonly notes: readonly (Finding & {
|
|
32
|
+
readonly notes: readonly (Finding & {
|
|
33
|
+
readonly unresolved?: true
|
|
34
|
+
readonly unclear?: true
|
|
35
|
+
readonly answers?: readonly [string, string]
|
|
36
|
+
})[]
|
|
33
37
|
readonly fixed: readonly (Finding & { readonly commit: string })[]
|
|
34
38
|
readonly noise: readonly (Finding & { readonly p: number })[]
|
|
35
39
|
}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
scope,
|
|
10
10
|
vars,
|
|
11
11
|
} from "../../flows/index.js"
|
|
12
|
-
import { defaultFlagRegistry, defaults, envDefaults } from "./vars.js"
|
|
12
|
+
import { defaultFlagRegistry, defaults, envDefaults, testsDefaults } from "./vars.js"
|
|
13
13
|
|
|
14
14
|
const setting = Schema.optionalWith(Schema.String, { exact: true })
|
|
15
15
|
|
|
@@ -20,6 +20,7 @@ export const ShippedOptions = Schema.Struct({
|
|
|
20
20
|
reviewNoteActionable: setting,
|
|
21
21
|
reviewSeverityMinP: setting,
|
|
22
22
|
qualityTriageMinP: setting,
|
|
23
|
+
standardMinP: setting,
|
|
23
24
|
refactorFlagPattern: setting,
|
|
24
25
|
seamAllow: setting,
|
|
25
26
|
sliceMaxCriteria: setting,
|
|
@@ -106,3 +107,23 @@ export const flagShipped = (
|
|
|
106
107
|
}
|
|
107
108
|
},
|
|
108
109
|
)
|
|
110
|
+
|
|
111
|
+
export const TestsOptions = Schema.Struct({
|
|
112
|
+
...ShippedOptions.fields,
|
|
113
|
+
testFiles: setting,
|
|
114
|
+
skipPattern: setting,
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
export type TestsOptions = Schema.Schema.Encoded<typeof TestsOptions>
|
|
118
|
+
|
|
119
|
+
export const testsShipped = (flow: Flow): WorkflowFactory<TestsOptions> =>
|
|
120
|
+
defineWorkflow(
|
|
121
|
+
Schema.standardSchemaV1(TestsOptions, { onExcessProperty: "error", errors: "all" }),
|
|
122
|
+
({ contextLimit, ...options }) => ({
|
|
123
|
+
flow: limited(flow, contextLimit),
|
|
124
|
+
defaults: over({ ...defaults, ...testsDefaults }, options),
|
|
125
|
+
envDefaults: over(envDefaults, options),
|
|
126
|
+
doors: { tests: {} },
|
|
127
|
+
base: undefined,
|
|
128
|
+
}),
|
|
129
|
+
)
|
|
@@ -4,6 +4,8 @@ import {
|
|
|
4
4
|
head,
|
|
5
5
|
judge,
|
|
6
6
|
numeric,
|
|
7
|
+
read,
|
|
8
|
+
removeScript,
|
|
7
9
|
requireRevert,
|
|
8
10
|
restoreScript,
|
|
9
11
|
run,
|
|
@@ -21,6 +23,7 @@ import { NO_FINDINGS, type LensReport } from "./lenses.js"
|
|
|
21
23
|
import {
|
|
22
24
|
answerReviewQuestions,
|
|
23
25
|
awaitReview,
|
|
26
|
+
coder,
|
|
24
27
|
collecting,
|
|
25
28
|
fixCheck,
|
|
26
29
|
fixNits,
|
|
@@ -30,6 +33,8 @@ import {
|
|
|
30
33
|
REVIEW,
|
|
31
34
|
reviewMissing,
|
|
32
35
|
} from "./steps.js"
|
|
36
|
+
import { learnStandards, settlesStandard, standardsAddedSince, standardsPending } from "./learn.js"
|
|
37
|
+
import { parseStandards, STANDARDS } from "./standards.js"
|
|
33
38
|
import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
|
|
34
39
|
import * as t from "./text.js"
|
|
35
40
|
|
|
@@ -191,6 +196,10 @@ const composing = (
|
|
|
191
196
|
weakened: kit.removedAssertions(changesSince(base)),
|
|
192
197
|
nitFix,
|
|
193
198
|
answered,
|
|
199
|
+
standards: {
|
|
200
|
+
added: standardsAddedSince(base),
|
|
201
|
+
undecided: parseStandards(read(STANDARDS) ?? "").undecided,
|
|
202
|
+
},
|
|
194
203
|
}),
|
|
195
204
|
[QUALITY],
|
|
196
205
|
)
|
|
@@ -211,7 +220,7 @@ export const humanReview = (
|
|
|
211
220
|
escalations: EscalationCount = { rounds: 0 },
|
|
212
221
|
options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
|
|
213
222
|
): Promise<kit.ReviewOutcome> => {
|
|
214
|
-
const { guard, guardRests, fixAccess, rules } = options
|
|
223
|
+
const { guard, guardRests, fixAccess, rules, awaitMessage } = options
|
|
215
224
|
const feedback: FeedbackTarget = options.feedback ?? {
|
|
216
225
|
file: REQUIREMENTS,
|
|
217
226
|
collecting,
|
|
@@ -234,11 +243,14 @@ export const humanReview = (
|
|
|
234
243
|
},
|
|
235
244
|
fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
|
|
236
245
|
keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
|
|
237
|
-
awaitReview: (b) => awaitReview(b),
|
|
246
|
+
awaitReview: (b) => awaitReview(b, awaitMessage?.(b)),
|
|
238
247
|
reviewMissing,
|
|
239
248
|
collecting: feedback.collecting,
|
|
240
249
|
triage: triage(severities),
|
|
241
250
|
decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
|
|
251
|
+
settles: settlesStandard,
|
|
252
|
+
learn: learnStandards,
|
|
253
|
+
pending: standardsPending,
|
|
242
254
|
answerQuestions: answerReviewQuestions,
|
|
243
255
|
fixNits: async (notes) => {
|
|
244
256
|
await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
|
|
@@ -293,6 +305,8 @@ export interface BuildTailOptions extends TailOptions {
|
|
|
293
305
|
readonly lap?: t.ReviewRange | undefined
|
|
294
306
|
/** What the triaged quality lap left for the review; absent: nothing. */
|
|
295
307
|
readonly report?: LensReport | undefined
|
|
308
|
+
/** The review gate's message for the review base; absent: the quality lap's. */
|
|
309
|
+
readonly awaitMessage?: ((base: string) => string) | undefined
|
|
296
310
|
}
|
|
297
311
|
|
|
298
312
|
/** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
|
|
@@ -358,6 +372,19 @@ const callForm = (
|
|
|
358
372
|
return undefined
|
|
359
373
|
}
|
|
360
374
|
|
|
375
|
+
/** Build the concerns a review round collected into `.gtd/REQUIREMENTS.md`, then remove it. */
|
|
376
|
+
export const fixFeedback = async (): Promise<void> => {
|
|
377
|
+
await t.agentWithSkills("feedback.fixing", t.reviewFeedbackFixPrompt(), {
|
|
378
|
+
label: "Fixing your review feedback",
|
|
379
|
+
file: REQUIREMENTS,
|
|
380
|
+
model: coder(),
|
|
381
|
+
system: t.finisherSystem(),
|
|
382
|
+
// An earlier nit fix may already have built the concern.
|
|
383
|
+
allowEmpty: true,
|
|
384
|
+
})
|
|
385
|
+
await run("feedback.closing", removeScript([REQUIREMENTS]), { label: "Closing the feedback" })
|
|
386
|
+
}
|
|
387
|
+
|
|
361
388
|
/**
|
|
362
389
|
* Undo the human's review-round code edits. A path changed since the human
|
|
363
390
|
* left it is not overwritten; requireRevert names it instead.
|
|
@@ -24,6 +24,9 @@ export const skills = (
|
|
|
24
24
|
notes: ["incremental-implementation", "code-simplification"],
|
|
25
25
|
// `review`'s turn building what a review round collected.
|
|
26
26
|
feedback: ["test-driven-development", "incremental-implementation"],
|
|
27
|
+
// The `tests` workflow's sweep and rewrites.
|
|
28
|
+
tests: ["test-audit", "test-driven-development"],
|
|
29
|
+
"tests.health": ["debugging-and-error-recovery"],
|
|
27
30
|
// The refactor's own turns load `ponytail`; `refactor.build.*` carry the `build.*` lists.
|
|
28
31
|
...build,
|
|
29
32
|
...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest"
|
|
2
|
+
import {
|
|
3
|
+
conflictFor,
|
|
4
|
+
conflictTitle,
|
|
5
|
+
parseStandards,
|
|
6
|
+
renderStandards,
|
|
7
|
+
standardsBlock,
|
|
8
|
+
} from "./standards.js"
|
|
9
|
+
|
|
10
|
+
const conflict = { text: "Calculators are functions.", olds: ["Calculators are classes."] }
|
|
11
|
+
|
|
12
|
+
describe("standards file", () => {
|
|
13
|
+
it("round-trips decided and undecided standards", () => {
|
|
14
|
+
const standards = { decided: ["Prefer early returns."], undecided: [conflict] }
|
|
15
|
+
expect(parseStandards(renderStandards(standards))).toEqual(standards)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it("reads a hand-edited file: any heading groups decided ones, wrapped lines join", () => {
|
|
19
|
+
const text = [
|
|
20
|
+
"# Standards",
|
|
21
|
+
"",
|
|
22
|
+
"Some prose.",
|
|
23
|
+
"",
|
|
24
|
+
"## Testing",
|
|
25
|
+
"",
|
|
26
|
+
"1. Every bug fix",
|
|
27
|
+
" gets a test.",
|
|
28
|
+
"",
|
|
29
|
+
"## Undecided",
|
|
30
|
+
"",
|
|
31
|
+
"- New",
|
|
32
|
+
" - contradicts: Old",
|
|
33
|
+
].join("\n")
|
|
34
|
+
expect(parseStandards(text)).toEqual({
|
|
35
|
+
decided: ["Every bug fix gets a test."],
|
|
36
|
+
undecided: [{ text: "New", olds: ["Old"] }],
|
|
37
|
+
})
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe("a formatter-wrapped file", () => {
|
|
42
|
+
it("keeps a wrapped contradicts line with its old standard", () => {
|
|
43
|
+
const text = [
|
|
44
|
+
"## Undecided",
|
|
45
|
+
"",
|
|
46
|
+
"- Calculators are plain functions. (re: subtract has no class)",
|
|
47
|
+
" - contradicts: Won't fix: subtract has no class — Calculators read better as",
|
|
48
|
+
" classes.",
|
|
49
|
+
].join("\n")
|
|
50
|
+
expect(parseStandards(text).undecided).toEqual([
|
|
51
|
+
{
|
|
52
|
+
text: "Calculators are plain functions. (re: subtract has no class)",
|
|
53
|
+
olds: ["Won't fix: subtract has no class — Calculators read better as classes."],
|
|
54
|
+
},
|
|
55
|
+
])
|
|
56
|
+
})
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
describe("conflict notes", () => {
|
|
60
|
+
it("keep their title when a formatter rewrites the emphasis", () => {
|
|
61
|
+
const standards = { decided: [], undecided: [{ text: "Use _named_ exports.", olds: [] }] }
|
|
62
|
+
expect(
|
|
63
|
+
conflictFor(standards, conflictTitle({ text: "Use *named* exports.", olds: [] })),
|
|
64
|
+
).toBeDefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it("are one per standard, however often the file lists it", () => {
|
|
68
|
+
const text = "## Undecided\n\n- New\n - contradicts: Old\n- New\n - contradicts: Old\n"
|
|
69
|
+
expect(parseStandards(text).undecided).toHaveLength(1)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it("title back to the conflict it asks about", () => {
|
|
73
|
+
const standards = { decided: [], undecided: [conflict] }
|
|
74
|
+
expect(conflictFor(standards, conflictTitle(conflict))).toBe(conflict)
|
|
75
|
+
expect(conflictFor(standards, "Standard conflict 00000000")).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe("standardsBlock", () => {
|
|
80
|
+
it("is empty without decided standards", () => {
|
|
81
|
+
expect(standardsBlock(undefined)).toBe("")
|
|
82
|
+
expect(standardsBlock(renderStandards({ decided: [], undecided: [conflict] }))).toBe("")
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it("lists only the decided ones", () => {
|
|
86
|
+
const block = standardsBlock(renderStandards({ decided: ["A."], undecided: [conflict] }))
|
|
87
|
+
expect(block).toContain("- A.")
|
|
88
|
+
expect(block).not.toContain(conflict.text)
|
|
89
|
+
})
|
|
90
|
+
})
|