@pmelab/gtd 32.0.0 → 33.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 (40) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +28 -22
  3. package/dist/chunks/{reserved-CHTJTzpA.mjs → reserved-BU5Zi-nX.mjs} +74 -13
  4. package/dist/chunks/{tail-CCsDBbZe.mjs → tail-DuXanjIY.mjs} +6 -3
  5. package/dist/testkit.mjs +2 -2
  6. package/dist/workflow-kit.mjs +15 -3
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +158 -34
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +2 -2
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +246 -46
  13. package/package.json +1 -1
  14. package/src/workflow-kit/quality.test.ts +25 -1
  15. package/src/workflow-kit/quality.ts +22 -4
  16. package/src/workflows/feature/acceptance-criteria.feature +8 -0
  17. package/src/workflows/feature/fast-path.feature +8 -2
  18. package/src/workflows/feature/feature.test.ts +19 -9
  19. package/src/workflows/feature/lens-models.feature +60 -0
  20. package/src/workflows/feature/naive-acceptance.feature +140 -0
  21. package/src/workflows/feature/one-step-land.feature +8 -2
  22. package/src/workflows/feature/refactor-absorbs.feature +6 -1
  23. package/src/workflows/feature/refactor-first.feature +9 -3
  24. package/src/workflows/feature/refactor-lens-models.feature +67 -0
  25. package/src/workflows/feature/refactor-review.feature +50 -5
  26. package/src/workflows/feature/refactor.ts +6 -5
  27. package/src/workflows/feature/risk-tier.feature +10 -0
  28. package/src/workflows/feature/seam-rejection.feature +19 -6
  29. package/src/workflows/feature/seam.feature +70 -16
  30. package/src/workflows/feature/seam.test.ts +189 -3
  31. package/src/workflows/feature/seam.ts +195 -30
  32. package/src/workflows/feature/steps.test.ts +13 -0
  33. package/src/workflows/index.ts +1 -1
  34. package/src/workflows/shared/skills.test.ts +7 -0
  35. package/src/workflows/shared/steps.test.ts +45 -0
  36. package/src/workflows/shared/steps.ts +4 -2
  37. package/src/workflows/shared/tail.test.ts +31 -1
  38. package/src/workflows/shared/tail.ts +6 -3
  39. package/src/workflows/shared/text.test.ts +40 -0
  40. package/src/workflows/shared/text.ts +97 -16
@@ -124,3 +124,16 @@ describe("the feature workflow's own steps declare skills — a step's rendered
124
124
  expect(request.options).toMatchObject({ file: ".gtd/TODO.md" })
125
125
  })
126
126
  })
127
+
128
+ describe("the feature workflow's planning steps run on the planner model", () => {
129
+ it("design triage defaults to smart and follows plannerModel", async () => {
130
+ const plain = await captureStep(() => steps.triage("base"), { scope: "design" })
131
+ const set = await captureStep(() => steps.triage("base"), {
132
+ scope: "design",
133
+ env: { plannerModel: "opus" },
134
+ })
135
+ if (plain.kind !== "agent" || set.kind !== "agent") throw new Error("unreachable")
136
+ expect(plain.options.model).toBe("smart")
137
+ expect(set.options.model).toBe("opus")
138
+ })
139
+ })
@@ -19,7 +19,7 @@ export {
19
19
  tail,
20
20
  type TailOptions,
21
21
  } from "./shared/tail.js"
22
- export { lensesOf } from "../workflow-kit/index.js"
22
+ export { lensEntriesOf, lensesOf, lensOf } from "../workflow-kit/index.js"
23
23
  export * from "./feature/steps.js"
24
24
  export * from "./feature/criteria.js"
25
25
  export * from "./feature/planning.js"
@@ -107,4 +107,11 @@ describe("the bundled workflow's skills export", () => {
107
107
  .sort(),
108
108
  ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
109
109
  })
110
+
111
+ it("keys a hinted entry by its lens alone, with the lens's own skills", () => {
112
+ const custom = skills({ ...defaults, qualityReviews: "my-lens=smart, correctness = base" })
113
+ expect(custom["build.quality.my-lens"]).toEqual(["my-lens"])
114
+ expect(custom["build.quality.correctness"]).toEqual(["code-review-and-quality"])
115
+ expect(Object.keys(custom).some((k) => k.includes("="))).toBe(false)
116
+ })
110
117
  })
@@ -107,3 +107,48 @@ describe("the bundled workflow's steps declare skills — a bundled step's rende
107
107
  expect(carried.prompt).toContain("Carry-over: commit `abc1234`")
108
108
  })
109
109
  })
110
+
111
+ describe("the bundled workflow's model hints", () => {
112
+ const modelOf = async (
113
+ fn: () => Promise<void>,
114
+ context: Parameters<typeof captureStep>[1] = {},
115
+ ): Promise<string | undefined> => {
116
+ const request = await captureStep(fn, { scope: "build", ...context })
117
+ if (request.kind !== "agent") throw new Error(`expected an agent step, got ${request.kind}`)
118
+ return request.options.model
119
+ }
120
+
121
+ it.each([
122
+ ["correctness", "smart"],
123
+ ["owasp-security", "smart"],
124
+ ["spec-challenge", "smart"],
125
+ ["conventions", "base"],
126
+ ["ponytail-review", "base"],
127
+ ["test-audit", "base"],
128
+ ["my-lens", "smart"],
129
+ ])("lens %s defaults to %s", async (lens, model) => {
130
+ expect(await modelOf(() => steps.reviewQuality(lens))).toBe(model)
131
+ })
132
+
133
+ it("a lens's default follows the planner or coder setting it stands for", async () => {
134
+ const env = { plannerModel: "opus", coderModel: "sonnet" }
135
+ expect(await modelOf(() => steps.reviewQuality("correctness"), { env })).toBe("opus")
136
+ expect(await modelOf(() => steps.reviewQuality("conventions"), { env })).toBe("sonnet")
137
+ })
138
+
139
+ it("a model passed in wins over the lens's default; a blank one is none", async () => {
140
+ expect(await modelOf(() => steps.reviewQuality("correctness", undefined, "base"))).toBe("base")
141
+ expect(await modelOf(() => steps.reviewQuality("conventions", undefined, "opus"))).toBe("opus")
142
+ expect(await modelOf(() => steps.reviewQuality("conventions", undefined, ""))).toBe("base")
143
+ })
144
+
145
+ it("a lens named like an Object member still gets its default", async () => {
146
+ expect(await modelOf(() => steps.reviewQuality("constructor"))).toBe("smart")
147
+ })
148
+
149
+ it("the fix loop runs on the coder model, base by default", async () => {
150
+ expect(await modelOf(() => steps.fixCheck())).toBe("base")
151
+ expect(await modelOf(() => steps.fixQuality())).toBe("base")
152
+ expect(await modelOf(() => steps.fixCheck(), { env: { coderModel: "sonnet" } })).toBe("sonnet")
153
+ })
154
+ })
@@ -66,15 +66,17 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
66
66
  "spec-challenge": { skills: [], brief: t.specChallengeBrief },
67
67
  }
68
68
 
69
+ const coderLenses: ReadonlySet<string> = new Set(["conventions", "ponytail-review", "test-audit"])
70
+
69
71
  /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
70
- export const reviewQuality = (lens: string, base?: string): Promise<void> =>
72
+ export const reviewQuality = (lens: string, base?: string, model?: string): Promise<void> =>
71
73
  t.agentWithSkills(
72
74
  `quality.${lens}.reviewing`,
73
75
  t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
74
76
  {
75
77
  label: "Reviewing (one quality lens)",
76
78
  file: QUALITY,
77
- model: planner(),
79
+ model: model || (coderLenses.has(lens) ? coder() : planner()),
78
80
  system: t.reviewerSystem(),
79
81
  allowEmpty: true,
80
82
  },
@@ -208,18 +208,23 @@ describe("the hand-off review", () => {
208
208
  })
209
209
 
210
210
  const prompts: string[] = []
211
+ const models: (string | undefined)[] = []
211
212
 
212
213
  describe("the quality lap's lenses", () => {
213
214
  const lap = async (run: () => Promise<unknown>, vars: Record<string, string> = {}) => {
214
215
  const log: string[] = []
215
216
  prompts.length = 0
217
+ models.length = 0
216
218
  installContext(
217
219
  fixtureContext(
218
220
  { vars },
219
221
  {
220
222
  step: (request: StepRequest) => {
221
223
  if (request.kind !== "restart") log.push(request.name)
222
- if (request.kind === "agent") prompts.push(request.prompt)
224
+ if (request.kind === "agent") {
225
+ prompts.push(request.prompt)
226
+ models.push(request.options.model)
227
+ }
223
228
  return Promise.resolve()
224
229
  },
225
230
  pushScope: () => undefined,
@@ -279,6 +284,31 @@ describe("the quality lap's lenses", () => {
279
284
  expect(prompts[0]).not.toContain("from `R`")
280
285
  })
281
286
 
287
+ it("each lens runs on its qualityReviews hint, else its default, under its plain name", async () => {
288
+ const log = await lap(() => qualityLap(), {
289
+ qualityReviews: "correctness=base, conventions, my-lens",
290
+ })
291
+ expect(log).toEqual([
292
+ "quality.correctness.reviewing",
293
+ "quality.conventions.reviewing",
294
+ "quality.my-lens.reviewing",
295
+ ])
296
+ expect(models).toEqual(["base", "base", "smart"])
297
+ })
298
+
299
+ it("lenses passed in carry their own hints, never qualityReviews'", async () => {
300
+ const log = await lap(
301
+ () =>
302
+ tail(() => Promise.resolve(), {
303
+ green: true,
304
+ lenses: ["correctness", "conventions=opus"],
305
+ }),
306
+ { qualityReviews: "correctness=base" },
307
+ )
308
+ expect(log).toEqual(["quality.correctness.reviewing", "quality.conventions.reviewing"])
309
+ expect(models).toEqual(["smart", "opus"])
310
+ })
311
+
282
312
  it("tail passes lenses to its lap and a guard after each lens turn", async () => {
283
313
  let guards = 0
284
314
  const log = await lap(() =>
@@ -35,8 +35,11 @@ export const qualityLap = (
35
35
  base?: string,
36
36
  ): Promise<"clean" | "findings"> =>
37
37
  kit.qualityLap(
38
- lenses ?? kit.lensesOf(vars.qualityReviews ?? ""),
39
- guarded((lens: string) => reviewQuality(lens, base), guard),
38
+ lenses ?? kit.lensEntriesOf(vars.qualityReviews ?? ""),
39
+ guarded((entry: string) => {
40
+ const { lens, model } = kit.lensOf(entry)
41
+ return reviewQuality(lens, base, model)
42
+ }, guard),
40
43
  QUALITY,
41
44
  )
42
45
 
@@ -77,7 +80,7 @@ export interface TailOptions {
77
80
  readonly green?: boolean | undefined
78
81
  /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
79
82
  readonly guard?: Guard | undefined
80
- /** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
83
+ /** Quality-lap lens entries, `<lens>` or `<lens>=<model>`; default `lensEntriesOf(vars.qualityReviews)`. */
81
84
  readonly lenses?: readonly string[] | undefined
82
85
  /** `false` leaves the escalation's human rests unguarded; default `true`. */
83
86
  readonly guardRests?: boolean | undefined
@@ -965,6 +965,22 @@ describe("the seam phase's texts", () => {
965
965
  expect(prompt).toContain("solved the task the wrong way")
966
966
  })
967
967
 
968
+ it("asks for a breadth-first pass with ID-tagged behaviour tests, green before it ends", () => {
969
+ const prompt = renderText(() => naivePassPrompt())
970
+ expect(prompt).toContain("breadth-first")
971
+ expect(prompt).toContain("`[AC-3] …`")
972
+ expect(prompt).toContain("never a file this pass adds")
973
+ expect(prompt).toContain("green")
974
+ })
975
+
976
+ it("has a red retry pick up the kept attempt instead of starting over", () => {
977
+ const prompt = renderText(() => naivePassPrompt({ red: "abc123" }))
978
+ expect(prompt).toContain("left the suite red")
979
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
980
+ expect(prompt).toContain(".gtd/FEEDBACK.md")
981
+ expect(prompt).not.toContain("rejected")
982
+ })
983
+
968
984
  it("cites the reference as reading material only and asks for a new release flag", () => {
969
985
  const prompt = renderText(() => implementBuildingPrompt("abc123"))
970
986
  expect(prompt).toContain("git show abc123")
@@ -1019,6 +1035,30 @@ describe("the seam phase's texts", () => {
1019
1035
  expect(message).toContain("src/flows/helpers.ts")
1020
1036
  })
1021
1037
 
1038
+ it("hands a faulty pass back with the fault and the kept attempt to pick up", () => {
1039
+ const fault = {
1040
+ fault: "acceptance-tests: no test of the naive pass names AC-2",
1041
+ hash: "abc123",
1042
+ }
1043
+ const prompt = renderText(() => naivePassPrompt(fault))
1044
+ expect(prompt).toContain("names AC-2")
1045
+ expect(prompt).toContain("git diff --binary abc123^ abc123 | git apply")
1046
+ const message = renderText(() => seamSteerMessage(fault))
1047
+ expect(message).toContain("names AC-2")
1048
+ expect(message).toContain("`seam.check` runs its suite")
1049
+ expect(message).toContain("Overrule")
1050
+ })
1051
+
1052
+ it("names the red suite, its report and what overruling it means", () => {
1053
+ const message = renderText(() => seamSteerMessage({ red: "abc123" }))
1054
+ expect(message).toContain("The last\nattempt left the suite red")
1055
+ expect(message).toContain(".gtd/FEEDBACK.md")
1056
+ expect(message).toContain("edit `.gtd/REQUIREMENTS.md`")
1057
+ expect(message).toContain("Overrule")
1058
+ expect(message).toContain("seam judge")
1059
+ expect(message).not.toContain("outside the expected set")
1060
+ })
1061
+
1022
1062
  it("names every unreadable path for a by-hand landing", () => {
1023
1063
  const message = renderText(() =>
1024
1064
  seamByHandMessage(["vendor/a.bin", "src/flows/helpers.ts"], ["vendor/a.bin"], "abc123"),
@@ -1134,31 +1134,72 @@ export interface SeamRejection {
1134
1134
  readonly p: number
1135
1135
  }
1136
1136
 
1137
+ /** A naive pass whose suite `seam.check` found red; `red` is its kept tip. */
1138
+ export interface NaiveRed {
1139
+ readonly red: string
1140
+ }
1141
+
1142
+ /** A naive pass whose tests break the acceptance-test rules; `hash` is its kept tip. */
1143
+ export interface NaiveFault {
1144
+ readonly fault: string
1145
+ readonly hash: string
1146
+ }
1147
+
1148
+ export type NaiveRetry = SeamRejection | NaiveRed | NaiveFault
1149
+
1150
+ const isRed = (retry: NaiveRetry): retry is NaiveRed => "red" in retry
1151
+ const isFault = (retry: NaiveRetry): retry is NaiveFault => "fault" in retry
1152
+
1153
+ const pickUp = (hash: string): string =>
1154
+ `Pick it up — \`git diff --binary ${hash}^ ${hash} | git apply\``
1155
+
1137
1156
  const criteriaBinding = (refused: boolean): string =>
1138
1157
  `- Its \`## Acceptance Criteria\` (\`AC-<n>\`) bind this turn: re-read them from
1139
1158
  the file, never from memory, and stop only when each one holds. They are
1140
1159
  settled — ${refused ? "a turn that changes them is refused" : "leave them as they are"}`
1141
1160
 
1142
- export const naivePassPrompt = (rejection?: SeamRejection): string =>
1161
+ const naiveRetry = (retry: NaiveRetry): string =>
1162
+ isFault(retry)
1163
+ ? `
1164
+ Your previous attempt's (\`${retry.hash}\`) tests fall short:
1165
+ ${retry.fault}
1166
+ ${pickUp(retry.hash)} — and fix its tests; do not start over.
1167
+ `
1168
+ : isRed(retry)
1169
+ ? `
1170
+ Your previous attempt (\`${retry.red}\`) left the suite red; \`.gtd/FEEDBACK.md\`
1171
+ holds the output. ${pickUp(retry.red)}
1172
+ — and fix it until the suite is green; do not start over.
1173
+ `
1174
+ : `
1175
+ Your previous attempt was rejected: the judge answered \`reject\` (p=${retry.p}),
1176
+ meaning the naive pass solved the task the wrong way — discard it and
1177
+ re-implement. It changed these files outside the expected set:
1178
+ ${listed(retry.strays)}
1179
+ Start over and choose a different approach; do not rebuild the same diff.
1180
+ `
1181
+
1182
+ export const naivePassPrompt = (retry?: NaiveRetry): string =>
1143
1183
  `${stateFileRules}
1144
1184
  - Read \`.gtd/REQUIREMENTS.md\`: the settled requirements for this change
1145
1185
  ${criteriaBinding(false)}
1146
1186
  - Implement them the quickest way that works — the naive version. Do not
1147
1187
  restructure, abstract or refactor around the change; a later step judges
1148
1188
  the shape of your diff and a reviewer makes it fit
1149
- - Add tests as you go, and leave \`.gtd/\` untouched
1189
+ - Work breadth-first: rough out every part of the change before finishing any
1190
+ one, so a run cut off midway still shows the whole shape
1191
+ - Write behaviour-level acceptance tests, at least one per criterion, each
1192
+ naming its ID in the test or scenario name (\`[AC-3] …\`). Test through code
1193
+ that existed before this pass — a public entry point, the CLI, an existing
1194
+ module's exports — never a file this pass adds: that is naive structure a
1195
+ later reset and refactor replaces. A pass that leaves a criterion's ID out
1196
+ of its tests, or whose test imports a file it added, is sent back
1197
+ - Run \`${env.testCommand}\` and finish only once it is green with every
1198
+ criterion's test passing: the next step runs it on your diff and sends a red
1199
+ pass back
1200
+ - Leave \`.gtd/\` untouched
1150
1201
  - Leave everything uncommitted and finish your turn — do not commit
1151
- ${
1152
- rejection === undefined
1153
- ? ""
1154
- : `
1155
- Your previous attempt was rejected: the judge answered \`reject\` (p=${rejection.p}),
1156
- meaning the naive pass solved the task the wrong way — discard it and
1157
- re-implement. It changed these files outside the expected set:
1158
- ${listed(rejection.strays)}
1159
- Start over and choose a different approach; do not rebuild the same diff.
1160
- `
1161
- }`
1202
+ ${retry === undefined ? "" : naiveRetry(retry)}`
1162
1203
 
1163
1204
  export const implementBuildingPrompt = (
1164
1205
  reference: string | undefined,
@@ -1216,9 +1257,49 @@ ${fixFeedbackPrompt}
1216
1257
  - Leave everything uncommitted and finish your turn — do not commit
1217
1258
  `
1218
1259
 
1219
- export const seamSteerMessage = (rejection: SeamRejection): string =>
1220
- `The naive pass was rejected three times and the retries are spent.
1221
- The last attempt changed these files outside the expected set:
1260
+ const steerChoices = (overrule: string): string => `Two ways forward (then run \`gtd land\`):
1261
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1262
+ pass runs again with a fresh retry budget
1263
+ - **Overrule** — ${overrule}
1264
+
1265
+ An edit together with an approval counts as steer. Only
1266
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1267
+ branch unjudged.
1268
+ `
1269
+
1270
+ export const seamSteerMessage = (retry: NaiveRetry): string =>
1271
+ isFault(retry)
1272
+ ? `The naive pass failed three times and the retries are spent. The last
1273
+ attempt's tests (\`git show ${retry.hash}\`) fall short:
1274
+ ${retry.fault}
1275
+
1276
+ ${steerChoices(`land a clean tree to go on with the last attempt as it is:
1277
+ \`seam.check\` runs its suite and the seam judge rules on its shape. Use it
1278
+ when the rule cannot fit this change (tests the globs do not see, or no
1279
+ entry point that existed before). Risk: a criterion may have no test that
1280
+ survives a later refactor — the choice is recorded in history.`)}`
1281
+ : isRed(retry)
1282
+ ? `The naive pass failed three times and the retries are spent. The last
1283
+ attempt left the suite red: \`.gtd/FEEDBACK.md\` holds the last run's output; \`git show ${retry.red}\` shows
1284
+ the last attempt.
1285
+
1286
+ Two ways forward (then run \`gtd land\`):
1287
+ - **Steer** — edit \`.gtd/REQUIREMENTS.md\` to redirect the change; the naive
1288
+ pass runs again with a fresh retry budget
1289
+ - **Overrule** — land a clean tree to go on with the last attempt as it is:
1290
+ the seam judge rules on its shape, and the implementation's health loop
1291
+ must turn the suite green. Risk: the criteria's tests never passed on the
1292
+ naive pass — the choice is recorded in history.
1293
+
1294
+ An edit together with an approval counts as steer. Only
1295
+ \`.gtd/REQUIREMENTS.md\` is meant to change here: any other edit lands on the
1296
+ branch unjudged.
1297
+ `
1298
+ : seamRejectedMessage(retry)
1299
+
1300
+ const seamRejectedMessage = (rejection: SeamRejection): string =>
1301
+ `The naive pass failed three times and the retries are spent. The judge
1302
+ rejected the last attempt: it changed these files outside the expected set:
1222
1303
  ${listed(rejection.strays)}
1223
1304
 
1224
1305
  Two ways forward (then run \`gtd land\`):