@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.
Files changed (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +33 -13
  3. package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
  4. package/dist/chunks/review-BmoH-9im.mjs +408 -0
  5. package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
  6. package/dist/workflow-kit.mjs +22 -10
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +6 -278
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +27 -7
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +379 -66
  13. package/package.json +1 -1
  14. package/src/steering/FORMATS.md +4 -4
  15. package/src/workflow-kit/review.test.ts +80 -2
  16. package/src/workflow-kit/review.ts +45 -11
  17. package/src/workflows/feature/coverage.ts +1 -1
  18. package/src/workflows/feature/feature.test.ts +8 -0
  19. package/src/workflows/feature/feature.ts +2 -2
  20. package/src/workflows/feature/index.ts +1 -1
  21. package/src/workflows/feature/refactor.ts +1 -1
  22. package/src/workflows/feature/review-answers.feature +12 -3
  23. package/src/workflows/feature/seam.ts +1 -1
  24. package/src/workflows/feature/standards.feature +286 -0
  25. package/src/workflows/feature/steps.test.ts +2 -64
  26. package/src/workflows/feature/steps.ts +5 -41
  27. package/src/workflows/fix/fix.feature +20 -21
  28. package/src/workflows/index.ts +1 -1
  29. package/src/workflows/review/review-door.feature +158 -0
  30. package/src/workflows/review/review.test.ts +1 -73
  31. package/src/workflows/review/review.ts +34 -6
  32. package/src/workflows/shared/learn.test.ts +154 -0
  33. package/src/workflows/shared/learn.ts +206 -0
  34. package/src/workflows/shared/options.ts +1 -0
  35. package/src/workflows/{feature → shared}/review.test.ts +4 -8
  36. package/src/workflows/{feature → shared}/review.ts +23 -10
  37. package/src/workflows/shared/skills.test.ts +2 -0
  38. package/src/workflows/shared/skills.ts +2 -0
  39. package/src/workflows/shared/standards.test.ts +90 -0
  40. package/src/workflows/shared/standards.ts +156 -0
  41. package/src/workflows/shared/steps.test.ts +64 -2
  42. package/src/workflows/shared/steps.ts +40 -1
  43. package/src/workflows/shared/tail.ts +2 -2
  44. package/src/workflows/shared/text.test.ts +58 -1
  45. package/src/workflows/shared/text.ts +39 -1
  46. package/src/workflows/shared/vars.ts +2 -0
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import type { StepRequest } from "../../flows/index.js"
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 review and fix workflows' human review since `base`: a sign-off closes
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 review and fix workflows run: ends at the hand-off review since `base`. */
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 body = access === "" ? prompt : `${access}\n\n${prompt}`
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",