@pmelab/gtd 43.0.0 → 45.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 +33 -13
- package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
- package/dist/chunks/review-BmoH-9im.mjs +408 -0
- package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
- package/dist/workflow-kit.mjs +22 -10
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +6 -278
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +27 -7
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +379 -66
- package/package.json +1 -1
- package/src/steering/FORMATS.md +4 -4
- package/src/workflow-kit/review.test.ts +80 -2
- package/src/workflow-kit/review.ts +45 -11
- 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/refactor.ts +1 -1
- package/src/workflows/feature/review-answers.feature +12 -3
- package/src/workflows/feature/seam.ts +1 -1
- package/src/workflows/feature/standards.feature +286 -0
- package/src/workflows/feature/steps.test.ts +2 -64
- package/src/workflows/feature/steps.ts +5 -41
- package/src/workflows/fix/fix.feature +20 -21
- package/src/workflows/index.ts +1 -1
- package/src/workflows/review/review-door.feature +158 -0
- package/src/workflows/review/review.test.ts +1 -73
- package/src/workflows/review/review.ts +34 -6
- package/src/workflows/shared/learn.test.ts +154 -0
- package/src/workflows/shared/learn.ts +206 -0
- package/src/workflows/shared/options.ts +1 -0
- package/src/workflows/{feature → shared}/review.test.ts +4 -8
- package/src/workflows/{feature → shared}/review.ts +23 -10
- package/src/workflows/shared/skills.test.ts +2 -0
- package/src/workflows/shared/skills.ts +2 -0
- package/src/workflows/shared/standards.test.ts +90 -0
- package/src/workflows/shared/standards.ts +156 -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 +58 -1
- package/src/workflows/shared/text.ts +39 -1
- package/src/workflows/shared/vars.ts +2 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import type
|
|
3
|
-
import { captureStep } from "./text.fixture.js"
|
|
2
|
+
import { installContext, type ScopeOptions, type StepRequest } from "../../flows/index.js"
|
|
3
|
+
import { captureStep, fixtureContext } from "./text.fixture.js"
|
|
4
4
|
import * as steps from "./steps.js"
|
|
5
5
|
|
|
6
6
|
const capture = (
|
|
@@ -151,4 +151,66 @@ describe("the bundled workflow's model hints", () => {
|
|
|
151
151
|
expect(await modelOf(() => steps.fixQuality())).toBe("base")
|
|
152
152
|
expect(await modelOf(() => steps.fixCheck(), { env: { coderModel: "sonnet" } })).toBe("sonnet")
|
|
153
153
|
})
|
|
154
|
+
|
|
155
|
+
it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
|
|
156
|
+
const request = await capture(
|
|
157
|
+
() =>
|
|
158
|
+
steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
|
|
159
|
+
"build",
|
|
160
|
+
)
|
|
161
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
162
|
+
expect(request.name).toBe("review.answer-review-questions")
|
|
163
|
+
expect(request.options).toMatchObject({
|
|
164
|
+
file: ".gtd/REVIEW.md",
|
|
165
|
+
mode: "review",
|
|
166
|
+
})
|
|
167
|
+
expect(request.prompt).toContain("code-review-and-quality")
|
|
168
|
+
expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
|
|
169
|
+
expect(request.prompt).toContain("`A: ` line")
|
|
170
|
+
expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
|
|
174
|
+
const request = await capture(
|
|
175
|
+
() =>
|
|
176
|
+
steps.fixNits([
|
|
177
|
+
{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
|
|
178
|
+
{ id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
|
|
179
|
+
]),
|
|
180
|
+
"build",
|
|
181
|
+
)
|
|
182
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
183
|
+
expect(request.name).toBe("review.fix.nits.fixing")
|
|
184
|
+
expect(request.prompt).toContain("incremental-implementation, code-simplification")
|
|
185
|
+
expect(request.prompt).toContain("typo")
|
|
186
|
+
expect(request.prompt).toContain("semicolon")
|
|
187
|
+
expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
|
|
191
|
+
const scopes: ScopeOptions[] = []
|
|
192
|
+
const steps_: StepRequest[] = []
|
|
193
|
+
const access = { write: ["src/a.ts", ".gtd/**"] }
|
|
194
|
+
installContext(
|
|
195
|
+
fixtureContext(
|
|
196
|
+
{ scope: "refactor.build" },
|
|
197
|
+
{
|
|
198
|
+
step: (request) => {
|
|
199
|
+
steps_.push(request)
|
|
200
|
+
return Promise.resolve()
|
|
201
|
+
},
|
|
202
|
+
pushScope: (s) => void scopes.push(s),
|
|
203
|
+
popScope: () => undefined,
|
|
204
|
+
changesSince: () => [],
|
|
205
|
+
},
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
try {
|
|
209
|
+
await steps.fixNits([], access)
|
|
210
|
+
} finally {
|
|
211
|
+
installContext(undefined)
|
|
212
|
+
}
|
|
213
|
+
expect(scopes).toEqual([{ name: "review.fix.nits", access }])
|
|
214
|
+
expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
|
|
215
|
+
})
|
|
154
216
|
})
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { changesSince, env, human, scope, type ScopeAccess } from "../../flows/index.js"
|
|
2
|
-
import { removedAssertions } from "../../workflow-kit/index.js"
|
|
2
|
+
import { removedAssertions, type CollectRound } from "../../workflow-kit/index.js"
|
|
3
3
|
import * as t from "./text.js"
|
|
4
4
|
|
|
5
5
|
// The bundled workflow's single steps. A step's name is relative to the
|
|
@@ -145,6 +145,45 @@ export const awaitReview = (
|
|
|
145
145
|
acceptClean: true,
|
|
146
146
|
})
|
|
147
147
|
|
|
148
|
+
/** Answer every `question` note inline in `.gtd/REVIEW.md`. */
|
|
149
|
+
export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
|
|
150
|
+
t.agentWithSkills("review.answer-review-questions", t.buildReviewAnswerQuestionsPrompt(notes), {
|
|
151
|
+
label: "Answering your questions",
|
|
152
|
+
file: REVIEW,
|
|
153
|
+
mode: "review",
|
|
154
|
+
model: planner(),
|
|
155
|
+
system: t.reviewerSystem(),
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
/** Fix every `nit` note in one turn. */
|
|
159
|
+
export const fixNits = (
|
|
160
|
+
notes: readonly t.NoteInput[],
|
|
161
|
+
access?: ScopeAccess,
|
|
162
|
+
rules?: string,
|
|
163
|
+
): Promise<void> => {
|
|
164
|
+
const fixing = (name: string): Promise<void> =>
|
|
165
|
+
t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
|
|
166
|
+
label: "Fixing your nits",
|
|
167
|
+
file: REVIEW,
|
|
168
|
+
model: planner(),
|
|
169
|
+
system: t.reviewerSystem(),
|
|
170
|
+
})
|
|
171
|
+
return access === undefined
|
|
172
|
+
? fixing("review.fix.nits.fixing")
|
|
173
|
+
: scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
|
|
177
|
+
export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
|
|
178
|
+
t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
|
|
179
|
+
label: "Collecting your feedback",
|
|
180
|
+
file: REQUIREMENTS,
|
|
181
|
+
mode: "qa",
|
|
182
|
+
model: planner(),
|
|
183
|
+
system: t.reviewerSystem(),
|
|
184
|
+
allowEmpty: true,
|
|
185
|
+
})
|
|
186
|
+
|
|
148
187
|
/** The round at commit `round` left no `.gtd/REVIEW.md`. */
|
|
149
188
|
export const reviewMissing = (round: string): Promise<void> =>
|
|
150
189
|
human("review.review-missing", {
|
|
@@ -190,7 +190,7 @@ const runTail = <T>(finish: Finish<T>, options: TailOptions): Promise<T> =>
|
|
|
190
190
|
})
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* The
|
|
193
|
+
* The fix workflow's human review since `base`: a sign-off closes
|
|
194
194
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
195
195
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
196
196
|
*/
|
|
@@ -220,6 +220,6 @@ export const handOffReview = (
|
|
|
220
220
|
"gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note to hand to fix.",
|
|
221
221
|
})
|
|
222
222
|
|
|
223
|
-
/** The tail the
|
|
223
|
+
/** The tail the fix workflow runs: ends at the hand-off review since `base`. */
|
|
224
224
|
export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
|
|
225
225
|
tail((escalations) => handOffReview(base, escalations, guard), { fixFirst, guard })
|
|
@@ -31,7 +31,7 @@ import {
|
|
|
31
31
|
withSkills,
|
|
32
32
|
composedReview,
|
|
33
33
|
} from "./text.js"
|
|
34
|
-
import { steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
34
|
+
import { reviewDecisions, steeringFormatFor, unansweredNotes } from "../../steering/index.js"
|
|
35
35
|
import { captureStep, renderText } from "./text.fixture.js"
|
|
36
36
|
|
|
37
37
|
describe("withSkills", () => {
|
|
@@ -68,6 +68,28 @@ describe("agentWithSkills", () => {
|
|
|
68
68
|
expect(request.options).not.toHaveProperty("skills")
|
|
69
69
|
})
|
|
70
70
|
|
|
71
|
+
it("ends every prompt with the kept standards, never the undecided ones", async () => {
|
|
72
|
+
const standards = [
|
|
73
|
+
"# Standards",
|
|
74
|
+
"",
|
|
75
|
+
"- Prefer early returns.",
|
|
76
|
+
"",
|
|
77
|
+
"## Undecided",
|
|
78
|
+
"",
|
|
79
|
+
"- Use classes.",
|
|
80
|
+
" - contradicts: Prefer early returns.",
|
|
81
|
+
].join("\n")
|
|
82
|
+
const request = await captureStep(() => agentWithSkills("name", "do-the-work"), {
|
|
83
|
+
scope: "a",
|
|
84
|
+
skills: { a: [] },
|
|
85
|
+
read: (path) => (path === ".gtd/STANDARDS.md" ? standards : undefined),
|
|
86
|
+
})
|
|
87
|
+
if (request.kind !== "agent") throw new Error("unreachable")
|
|
88
|
+
expect(request.prompt.startsWith("do-the-work\n\n## Standards")).toBe(true)
|
|
89
|
+
expect(request.prompt.endsWith("- Prefer early returns.")).toBe(true)
|
|
90
|
+
expect(request.prompt).not.toContain("Use classes.")
|
|
91
|
+
})
|
|
92
|
+
|
|
71
93
|
it("leaves the prompt bare when the scope resolves to an empty list", async () => {
|
|
72
94
|
const request = await capture(() => agentWithSkills("name", "do-the-work"), { a: [] })
|
|
73
95
|
if (request.kind !== "agent") throw new Error("unreachable")
|
|
@@ -1244,6 +1266,41 @@ describe("buildReviewCollectingPrompt answer boxes", () => {
|
|
|
1244
1266
|
describe("composedReview", () => {
|
|
1245
1267
|
const BASE = "0123456789abcdef0123456789abcdef01234567"
|
|
1246
1268
|
|
|
1269
|
+
describe("standards", () => {
|
|
1270
|
+
const conflict = { text: "Calculators are functions.", olds: ["Calculators are classes."] }
|
|
1271
|
+
const withConflict = composedReview(BASE, {
|
|
1272
|
+
notes: [],
|
|
1273
|
+
fixed: [],
|
|
1274
|
+
noise: [],
|
|
1275
|
+
standards: { added: [], undecided: [conflict] },
|
|
1276
|
+
})
|
|
1277
|
+
|
|
1278
|
+
it("asks about each undecided standard in a note that blocks sign-off", () => {
|
|
1279
|
+
expect(withConflict).toMatch(/^## Standard conflict [0-9a-f]{8}$/m)
|
|
1280
|
+
expect(withConflict).toContain("- [ ] keep the new one (drop the old)")
|
|
1281
|
+
expect(unansweredNotes(withConflict)).toHaveLength(1)
|
|
1282
|
+
})
|
|
1283
|
+
|
|
1284
|
+
it("reads its boxes by position, like a note's", () => {
|
|
1285
|
+
const ticked = withConflict.replace("- [ ] keep the old one", "- [x] keep the old one")
|
|
1286
|
+
expect(unansweredNotes(ticked)).toEqual([])
|
|
1287
|
+
expect(reviewDecisions(withConflict, ticked)).toEqual([
|
|
1288
|
+
expect.objectContaining({ choice: "wontfix" }),
|
|
1289
|
+
])
|
|
1290
|
+
})
|
|
1291
|
+
|
|
1292
|
+
it("lists the standards added", () => {
|
|
1293
|
+
const doc = composedReview(BASE, {
|
|
1294
|
+
notes: [],
|
|
1295
|
+
fixed: [],
|
|
1296
|
+
noise: [],
|
|
1297
|
+
standards: { added: ["Prefer early returns."], undecided: [] },
|
|
1298
|
+
})
|
|
1299
|
+
expect(doc).toContain("## Standards added")
|
|
1300
|
+
expect(doc).toContain("- Prefer early returns.")
|
|
1301
|
+
})
|
|
1302
|
+
})
|
|
1303
|
+
|
|
1247
1304
|
it("is a valid review with unique chunk titles, so every note matches its own chunk", () => {
|
|
1248
1305
|
const doc = composedReview(BASE, {
|
|
1249
1306
|
notes: [
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import {
|
|
2
2
|
agent,
|
|
3
3
|
codeThreads,
|
|
4
|
+
read,
|
|
4
5
|
displayPath,
|
|
5
6
|
env,
|
|
6
7
|
head,
|
|
@@ -19,6 +20,13 @@ import {
|
|
|
19
20
|
FREE_TEXT_PLACEHOLDER,
|
|
20
21
|
} from "../../steering/index.js"
|
|
21
22
|
import { escapeRegExp } from "./registry.js"
|
|
23
|
+
import {
|
|
24
|
+
conflictBody,
|
|
25
|
+
conflictTitle,
|
|
26
|
+
standardsBlock,
|
|
27
|
+
STANDARDS,
|
|
28
|
+
type Conflict,
|
|
29
|
+
} from "./standards.js"
|
|
22
30
|
import { defaultFlagRegistry } from "./vars.js"
|
|
23
31
|
import type { CollectRound, RemovedAssertions, Requested } from "../../workflow-kit/index.js"
|
|
24
32
|
import {
|
|
@@ -57,7 +65,8 @@ export const agentWithSkills = (
|
|
|
57
65
|
options: AgentOptions = {},
|
|
58
66
|
): Promise<void> => {
|
|
59
67
|
const access = accessPreamble(accessFor(name, options.file))
|
|
60
|
-
const
|
|
68
|
+
const standards = standardsBlock(read(STANDARDS))
|
|
69
|
+
const body = [access, prompt, standards].filter((part) => part !== "").join("\n\n")
|
|
61
70
|
return agent(name, withSkills(skillsFor(name).join(", "), body), options)
|
|
62
71
|
}
|
|
63
72
|
|
|
@@ -124,12 +133,17 @@ export interface ComposedReview {
|
|
|
124
133
|
readonly nitFix?: string | undefined
|
|
125
134
|
/** Titles of notes the human already answered, left out. */
|
|
126
135
|
readonly answered?: ReadonlySet<string> | undefined
|
|
136
|
+
/** Standards added since the review base, and the conflicts still waiting on the human. */
|
|
137
|
+
readonly standards?:
|
|
138
|
+
| { readonly added: readonly string[]; readonly undecided: readonly Conflict[] }
|
|
139
|
+
| undefined
|
|
127
140
|
}
|
|
128
141
|
|
|
129
142
|
const SUMMARY_TITLES = [
|
|
130
143
|
"Weakened scenarios",
|
|
131
144
|
"Fixed from your notes",
|
|
132
145
|
"Fixed automatically",
|
|
146
|
+
"Standards added",
|
|
133
147
|
"Dropped as noise",
|
|
134
148
|
"Nothing to note",
|
|
135
149
|
]
|
|
@@ -157,6 +171,10 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
157
171
|
const chunk = (title: string, body: string): string => `## ${title}\n\n${body.trim()}`
|
|
158
172
|
// The lead-in line keeps a body that ends in a list from absorbing the boxes.
|
|
159
173
|
const answerBoxes = `${ANSWER_LEAD}\n\n- [ ] ${ANSWER_FIX}\n- [ ] ${ANSWER_WONT_FIX}\n- [ ] ${FREE_TEXT_PLACEHOLDER}`
|
|
174
|
+
const conflicts = (report.standards?.undecided ?? []).map((c) =>
|
|
175
|
+
chunk(unique(conflictTitle(c)), conflictBody(c)),
|
|
176
|
+
)
|
|
177
|
+
const added = report.standards?.added ?? []
|
|
160
178
|
const notes = report.notes
|
|
161
179
|
.map((n) => ({ ...n, title: unique(n.title) }))
|
|
162
180
|
.filter((n) => report.answered?.has(n.title) !== true)
|
|
@@ -180,6 +198,7 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
180
198
|
`These scenarios lost an assertion the review base had:\n\n${weakened.join("\n")}`,
|
|
181
199
|
),
|
|
182
200
|
]),
|
|
201
|
+
...conflicts,
|
|
183
202
|
...notes.map((n) =>
|
|
184
203
|
chunk(
|
|
185
204
|
n.title,
|
|
@@ -200,6 +219,14 @@ export const composedReview = (base: string, report: ComposedReview): string =>
|
|
|
200
219
|
report.fixed.map((f) => `- ${f.title} (${short(f.commit)})`).join("\n"),
|
|
201
220
|
),
|
|
202
221
|
]),
|
|
222
|
+
...(added.length === 0
|
|
223
|
+
? []
|
|
224
|
+
: [
|
|
225
|
+
chunk(
|
|
226
|
+
"Standards added",
|
|
227
|
+
`Learned from your answers and kept in \`${STANDARDS}\`:\n\n${added.map((s) => `- ${s}`).join("\n")}`,
|
|
228
|
+
),
|
|
229
|
+
]),
|
|
203
230
|
...(report.noise.length === 0
|
|
204
231
|
? []
|
|
205
232
|
: [chunk("Dropped as noise", report.noise.map((f) => `- ${f.title} (p=${f.p})`).join("\n"))]),
|
|
@@ -875,6 +902,17 @@ The reviewer's risks are:
|
|
|
875
902
|
|
|
876
903
|
${risks.length === 0 ? "(none)\n" : notesCapture(risks)}`
|
|
877
904
|
|
|
905
|
+
export const reviewFeedbackFixPrompt = (): string =>
|
|
906
|
+
`${stateFileRules}
|
|
907
|
+
- Read \`.gtd/REQUIREMENTS.md\` — the concerns the human's review asked for,
|
|
908
|
+
one \`## \` heading each, in build order
|
|
909
|
+
- Build every concern in this one turn; an answered question is context,
|
|
910
|
+
not a concern
|
|
911
|
+
- Where a change is behavioural, add a test that fails without it
|
|
912
|
+
- Leave \`.gtd/REQUIREMENTS.md\` untouched — the next step removes it
|
|
913
|
+
- Leave everything uncommitted and finish your turn
|
|
914
|
+
`
|
|
915
|
+
|
|
878
916
|
export const buildReviewReviewMissingMessage = (commit: string): string =>
|
|
879
917
|
`The review round committed no \`.gtd/REVIEW.md\` (at ${commit}), so there is
|
|
880
918
|
nothing to sign off on.
|
|
@@ -24,6 +24,8 @@ export const defaults: Readonly<Record<string, string>> = {
|
|
|
24
24
|
reviewNoteActionable: "0.7",
|
|
25
25
|
reviewSeverityMinP: "0.7",
|
|
26
26
|
qualityTriageMinP: "0.7",
|
|
27
|
+
// How sure the judge must be that an answer at the review gate is a reusable standard.
|
|
28
|
+
standardMinP: "0.8",
|
|
27
29
|
refactorFlagPattern: "\\bGTD_FLAG_\\w+",
|
|
28
30
|
seamAllow: [...testFileGlobs, ".gtd/**"].join(", "),
|
|
29
31
|
flagMode: "true",
|