@pmelab/gtd 40.0.0 → 42.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 (58) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +41 -30
  3. package/dist/chunks/{reserved-Qt5FM6Ia.mjs → reserved-D7MNY9QJ.mjs} +66 -18
  4. package/dist/chunks/{steering-DVILanVf.mjs → steering-oCDdQlpP.mjs} +1 -1
  5. package/dist/chunks/tail-D2o2h5cc.mjs +246 -0
  6. package/dist/flows.mjs +11 -1
  7. package/dist/gtd.bundle.mjs +12 -2
  8. package/dist/steering.mjs +1 -1
  9. package/dist/testkit.mjs +15 -3
  10. package/dist/web/main.js +1 -1
  11. package/dist/workflow-kit.mjs +30 -12
  12. package/dist/workflows/cleanup.mjs +2 -2
  13. package/dist/workflows/feature.mjs +44 -24
  14. package/dist/workflows/fix.mjs +3 -3
  15. package/dist/workflows/review.mjs +2 -2
  16. package/dist/workflows/rollout.mjs +1 -1
  17. package/dist/workflows.mjs +282 -51
  18. package/package.json +1 -1
  19. package/src/flows/scripts.ts +15 -0
  20. package/src/steering/review.test.ts +2 -20
  21. package/src/steering/review.ts +2 -1
  22. package/src/testkit/FlowScripts.test.ts +17 -0
  23. package/src/testkit/FlowScripts.ts +15 -0
  24. package/src/testkit/steps.ts +27 -0
  25. package/src/workflow-kit/health.ts +42 -13
  26. package/src/workflows/feature/coverage.feature +3 -3
  27. package/src/workflows/feature/fast-path.feature +2 -2
  28. package/src/workflows/feature/feature.test.ts +2 -37
  29. package/src/workflows/feature/feature.ts +12 -14
  30. package/src/workflows/feature/flag-mode.feature +2 -2
  31. package/src/workflows/feature/lens-review.feature +234 -0
  32. package/src/workflows/feature/one-step-land.feature +2 -2
  33. package/src/workflows/feature/quality-unresolved.feature +47 -32
  34. package/src/workflows/feature/refactor-absorbs.feature +3 -3
  35. package/src/workflows/feature/refactor-first.feature +5 -5
  36. package/src/workflows/feature/refactor-rebuild.feature +7 -7
  37. package/src/workflows/feature/refactor-review.feature +19 -100
  38. package/src/workflows/feature/refactor.test.ts +12 -27
  39. package/src/workflows/feature/refactor.ts +0 -1
  40. package/src/workflows/feature/review-gate.feature +6 -6
  41. package/src/workflows/feature/review.test.ts +29 -25
  42. package/src/workflows/feature/review.ts +53 -10
  43. package/src/workflows/feature/seam-rejection.feature +2 -2
  44. package/src/workflows/feature/seam.feature +2 -2
  45. package/src/workflows/feature/seam.test.ts +1 -1
  46. package/src/workflows/feature/slicing.feature +6 -6
  47. package/src/workflows/index.ts +1 -0
  48. package/src/workflows/shared/health.test.ts +30 -0
  49. package/src/workflows/shared/health.ts +12 -9
  50. package/src/workflows/shared/lenses.test.ts +39 -0
  51. package/src/workflows/shared/lenses.ts +176 -0
  52. package/src/workflows/shared/options.ts +1 -0
  53. package/src/workflows/shared/progress.feature +124 -0
  54. package/src/workflows/shared/tail.ts +19 -4
  55. package/src/workflows/shared/text.test.ts +68 -1
  56. package/src/workflows/shared/text.ts +126 -13
  57. package/src/workflows/shared/vars.ts +1 -0
  58. package/dist/chunks/tail-tAHx_AHe.mjs +0 -112
@@ -48,7 +48,7 @@ interface Drive {
48
48
  readonly vars?: Readonly<Record<string, string>>
49
49
  /** The REVIEW.md the human leaves; defaults to `notes` laid over the baseline. */
50
50
  readonly after?: string
51
- /** The REVIEW.md each `review.reviewing` turn writes, in order; also raises the review-turn stop to one past the last. */
51
+ /** The REVIEW.md each `review.composing` turn writes, in order; also raises the review-turn stop to one past the last. */
52
52
  readonly reviewDocs?: readonly (string | undefined)[]
53
53
  /** Let the run pass the second gate, stopping at the first range read after it. */
54
54
  readonly passSecondGate?: boolean
@@ -95,7 +95,7 @@ const drive = async (d: Drive): Promise<Run> => {
95
95
  let awaits = 0
96
96
  let reviews = 0
97
97
  const effects: Record<string, () => void> = {
98
- "review.reviewing": () => {
98
+ "review.composing": () => {
99
99
  const docs = d.reviewDocs
100
100
  if (++reviews > (docs?.length ?? 0) + (docs === undefined ? 1 : 0)) throw new Stop()
101
101
  const written = docs?.[reviews - 1]
@@ -116,6 +116,7 @@ const drive = async (d: Drive): Promise<Run> => {
116
116
  gateMessages.push(request.options.message ?? "")
117
117
  }
118
118
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
119
+ if (request.kind === "run") prompts.set(request.name, request.body)
119
120
  if (request.kind === "judge") asked.push(...request.questions)
120
121
  }
121
122
  const context = fixtureContext(
@@ -184,10 +185,10 @@ describe("the risk-fix pass", () => {
184
185
  it("a marked risk is fixed, kept green, re-reviewed, then rests at the gate", async () => {
185
186
  const run = await drive({ notes: [], reviewDocs: [risky, clean] })
186
187
  expect(run.log.slice(0, 5)).toEqual([
187
- "review.reviewing",
188
+ "review.composing",
188
189
  "review.fix.risks.fixing",
189
190
  "health.check",
190
- "review.reviewing",
191
+ "review.composing",
191
192
  "review.await-review",
192
193
  ])
193
194
  expect(run.prompts.get("review.fix.risks.fixing")).toContain("risk-1")
@@ -199,10 +200,10 @@ describe("the risk-fix pass", () => {
199
200
  const run = await drive({ notes: [], reviewDocs: [risky, risky] })
200
201
  expect(run.log.filter((n) => n === "review.fix.risks.fixing")).toHaveLength(1)
201
202
  expect(run.log.slice(0, 5)).toEqual([
202
- "review.reviewing",
203
+ "review.composing",
203
204
  "review.fix.risks.fixing",
204
205
  "health.check",
205
- "review.reviewing",
206
+ "review.composing",
206
207
  "review.await-review",
207
208
  ])
208
209
  })
@@ -232,7 +233,7 @@ describe("the risk-fix pass", () => {
232
233
  it("no marker means no fix-risks step", async () => {
233
234
  const run = await drive({ notes: [], reviewDocs: [clean] })
234
235
  expect(run.log).not.toContain("review.fix.risks.fixing")
235
- expect(run.log[0]).toBe("review.reviewing")
236
+ expect(run.log[0]).toBe("review.composing")
236
237
  expect(run.log[1]).toBe("review.await-review")
237
238
  })
238
239
 
@@ -243,16 +244,16 @@ describe("the risk-fix pass", () => {
243
244
  reviewDocs: [undefined, risky, clean],
244
245
  })
245
246
  expect(run.log).toEqual([
246
- "review.reviewing",
247
+ "review.composing",
247
248
  "review.await-review",
248
249
  "review.triage",
249
250
  "review.fix.nits.fixing",
250
251
  "health.check",
251
252
  "review.closing",
252
- "review.reviewing",
253
+ "review.composing",
253
254
  "review.fix.risks.fixing",
254
255
  "health.check",
255
- "review.reviewing",
256
+ "review.composing",
256
257
  "review.await-review",
257
258
  ])
258
259
  })
@@ -265,7 +266,7 @@ describe("the guard", () => {
265
266
  it("runs right after the risk-fix turn", async () => {
266
267
  const run = await drive({ notes: [], reviewDocs: [risky, clean], guarded: true })
267
268
  expect(run.log.slice(0, 4)).toEqual([
268
- "review.reviewing",
269
+ "review.composing",
269
270
  "review.fix.risks.fixing",
270
271
  "guard",
271
272
  "health.check",
@@ -443,7 +444,7 @@ describe("review verdict routing", () => {
443
444
  it("an unanswered note counts as edit", async () => {
444
445
  const run = await drive({ notes: ["add — typo", "sub", "mul"], answers: {} })
445
446
  expect(run.log).toEqual([
446
- "review.reviewing",
447
+ "review.composing",
447
448
  "review.await-review",
448
449
  "review.triage",
449
450
  "review.closing",
@@ -478,7 +479,7 @@ describe("review verdict routing", () => {
478
479
  it("only praise closes and signs off with no agent turn", async () => {
479
480
  const run = await drive({ notes, answers: { "note-1": verdict("praise") } })
480
481
  expect(run.log).toEqual([
481
- "review.reviewing",
482
+ "review.composing",
482
483
  "review.await-review",
483
484
  "review.triage",
484
485
  "review.closing",
@@ -492,7 +493,7 @@ describe("review verdict routing", () => {
492
493
  answers: { "note-1": verdict("question"), "note-2": verdict("question") },
493
494
  })
494
495
  expect(run.log).toEqual([
495
- "review.reviewing",
496
+ "review.composing",
496
497
  "review.await-review",
497
498
  "review.triage",
498
499
  "review.answer-review-questions",
@@ -508,17 +509,17 @@ describe("review verdict routing", () => {
508
509
  answers: { "note-1": verdict("question"), "note-2": verdict("nit") },
509
510
  })
510
511
  expect(run.log).toEqual([
511
- "review.reviewing",
512
+ "review.composing",
512
513
  "review.await-review",
513
514
  "review.triage",
514
515
  "review.answer-review-questions",
515
516
  "review.fix.nits.fixing",
516
517
  "health.check",
517
518
  "review.closing",
518
- "review.reviewing",
519
+ "review.composing",
519
520
  ])
520
521
  expect(run.prompts.get("review.fix.nits.fixing")).not.toContain("why?")
521
- expect(run.prompts.get("review.reviewing")).toContain("Carry-over: commit `c4`")
522
+ expect(run.prompts.get("review.composing")).toContain("git show 'c4:.gtd/REVIEW.md'")
522
523
  expect(run.result).toBe("stopped")
523
524
  })
524
525
 
@@ -527,7 +528,8 @@ describe("review verdict routing", () => {
527
528
  notes: ["add — typo", "sub", "mul"],
528
529
  answers: { "note-1": verdict("nit") },
529
530
  })
530
- expect(run.prompts.get("review.reviewing")).not.toContain("Carry-over")
531
+ expect(run.prompts.get("review.composing")).not.toContain("git show")
532
+ expect(run.prompts.get("review.composing")).toContain("## Fixed from your notes")
531
533
  })
532
534
 
533
535
  it("edits run after questions and nits, and the capture names only the edits and the answering commit", async () => {
@@ -536,7 +538,7 @@ describe("review verdict routing", () => {
536
538
  answers: { "note-2": verdict("question"), "note-3": verdict("nit") },
537
539
  })
538
540
  expect(run.log).toEqual([
539
- "review.reviewing",
541
+ "review.composing",
540
542
  "review.await-review",
541
543
  "review.triage",
542
544
  "review.answer-review-questions",
@@ -556,7 +558,7 @@ describe("review verdict routing", () => {
556
558
  it("a noted round with no extractable note collects as one edit, without judging", async () => {
557
559
  const run = await drive({ notes: [], after: BEFORE + "\nSome stray prose.\n" })
558
560
  expect(run.log).toEqual([
559
- "review.reviewing",
561
+ "review.composing",
560
562
  "review.await-review",
561
563
  "review.closing",
562
564
  "review.collecting",
@@ -679,7 +681,7 @@ describe("severity routing", () => {
679
681
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
680
682
  })
681
683
  expect(run.log).toEqual([
682
- "review.reviewing",
684
+ "review.composing",
683
685
  "review.await-review",
684
686
  "review.triage",
685
687
  "review.defer",
@@ -771,7 +773,7 @@ describe("severity routing", () => {
771
773
  passSecondGate: true,
772
774
  })
773
775
  expect(run.log).toEqual([
774
- "review.reviewing",
776
+ "review.composing",
775
777
  "review.await-review",
776
778
  "review.triage",
777
779
  "review.answer-review-questions",
@@ -807,7 +809,7 @@ describe("BuildTailOptions.lensBase", () => {
807
809
  if (request.kind === "restart") return Promise.resolve()
808
810
  if (request.kind === "agent" && request.name.startsWith("quality."))
809
811
  prompts.push(request.prompt)
810
- if (request.name === "review.reviewing") throw new Stop()
812
+ if (request.name === "review.composing") throw new Stop()
811
813
  return Promise.resolve()
812
814
  },
813
815
  pushScope: () => undefined,
@@ -1048,10 +1050,12 @@ describe("buildTail without rules", () => {
1048
1050
  if (request.kind === "agent") prompts.set(request.name, request.prompt)
1049
1051
  if (request.name === "health.check") checks++
1050
1052
  if (request.name === "fix.quality.fixing") files.delete(".gtd/QUALITY.md")
1051
- if (request.name === "review.reviewing" || request.name === "health.stop")
1053
+ if (request.name === "review.composing" || request.name === "health.stop")
1052
1054
  throw new Stop()
1053
1055
  return Promise.resolve(
1054
- request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
1056
+ request.kind === "judge"
1057
+ ? { answers: { Q1: { answer: "mechanical", p: 0.9 } }, truncated: [] }
1058
+ : undefined,
1055
1059
  )
1056
1060
  },
1057
1061
  pushScope: () => undefined,
@@ -1,12 +1,15 @@
1
1
  import {
2
2
  answered,
3
+ changesSince,
3
4
  head,
4
5
  judge,
5
6
  numeric,
6
7
  requireRevert,
7
8
  restoreScript,
8
9
  run,
10
+ showScript,
9
11
  vars,
12
+ writeScript,
10
13
  type JudgeQuestion,
11
14
  type Judgment,
12
15
  type ScopeAccess,
@@ -14,13 +17,14 @@ import {
14
17
  import type { ReviewNote } from "../../steering/index.js"
15
18
  import * as kit from "../../workflow-kit/index.js"
16
19
  import { guarded, healthy, type EscalationCount } from "../shared/health.js"
20
+ import { NO_FINDINGS, type LensReport } from "../shared/lenses.js"
17
21
  import {
18
22
  awaitReview,
19
23
  fixCheck,
20
24
  fixRisks,
25
+ QUALITY,
21
26
  REQUIREMENTS,
22
27
  REVIEW,
23
- reviewing,
24
28
  reviewMissing,
25
29
  } from "../shared/steps.js"
26
30
  import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
@@ -161,10 +165,37 @@ const triage =
161
165
  export const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
162
166
 
163
167
  /**
164
- * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
165
- * reviews and signs off or comments, and each note is judged: edits go to
166
- * another planning lap, questions are answered in place, nits fixed in one
167
- * batch, praise dropped.
168
+ * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
169
+ * turn — and removes what the fixer left in `.gtd/QUALITY.md`. A re-review
170
+ * after fixed nits restores the doc as committed at `carry`, answers kept.
171
+ */
172
+ const composing = (
173
+ base: string,
174
+ report: LensReport,
175
+ carry?: string,
176
+ nitFix?: string,
177
+ ): Promise<void> =>
178
+ run(
179
+ "review.composing",
180
+ carry === undefined
181
+ ? writeScript(
182
+ REVIEW,
183
+ t.composedReview(base, {
184
+ ...report,
185
+ weakened: kit.removedAssertions(changesSince(base)),
186
+ nitFix,
187
+ }),
188
+ [QUALITY],
189
+ )
190
+ : showScript(carry, REVIEW, REVIEW),
191
+ { label: "Composing the review", file: REVIEW, base },
192
+ )
193
+
194
+ /**
195
+ * The quality lap's notes reach a human over everything since `base`, who
196
+ * signs off or comments, and each note is judged: edits go to another
197
+ * planning lap, questions are answered in place, nits fixed in one batch,
198
+ * praise dropped.
168
199
  */
169
200
  export const review = (
170
201
  base: string,
@@ -182,13 +213,17 @@ export const review = (
182
213
  // Armed by a defer-only round to pick the next gate's message; a fresh
183
214
  // review (`reviewing`) disarms it.
184
215
  let deferred: readonly t.NoteInput[] = []
216
+ // Set by a nit fix, so the fresh review that follows names its commit.
217
+ let nitFix: string | undefined
185
218
  return kit.review(base, {
186
219
  file: REVIEW,
187
220
  feedback: feedback.file,
188
221
  isCode,
189
222
  reviewing: (b, carry) => {
190
223
  deferred = []
191
- return reviewing(b, carry, options.lap)
224
+ const fixedAt = nitFix
225
+ nitFix = undefined
226
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt)
192
227
  },
193
228
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
194
229
  keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
@@ -212,7 +247,10 @@ export const review = (
212
247
  return deferNotes(deferred, head())
213
248
  },
214
249
  answerQuestions: answerReviewQuestions,
215
- fixNits: guarded((notes) => fixNits(notes, fixAccess, rules), guard),
250
+ fixNits: async (notes) => {
251
+ await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
252
+ nitFix = head()
253
+ },
216
254
  close: closeReview,
217
255
  captures: {
218
256
  edits: t.reviewEditsCapture,
@@ -259,11 +297,13 @@ export interface BuildTailOptions extends TailOptions {
259
297
  readonly feedback?: FeedbackTarget | undefined
260
298
  /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
261
299
  readonly fixAccess?: ScopeAccess | undefined
262
- /** Absent: "first". */
300
+ /** @deprecated No reviewer turn reads it since the review is composed. */
263
301
  readonly lap?: t.ReviewRange | undefined
302
+ /** What the triaged quality lap left for the review; absent: nothing. */
303
+ readonly report?: LensReport | undefined
264
304
  }
265
305
 
266
- /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
306
+ /** The build tail: fix (when entered red), keep green unless `green`, the triaged quality lap, then human review since `base`. */
267
307
  export function buildTail(base: string, options?: BuildTailOptions): Promise<kit.ReviewOutcome>
268
308
  /** @deprecated Pass `buildTail(base, { fixFirst, green, ...options })`. */
269
309
  export function buildTail(
@@ -287,7 +327,10 @@ export function buildTail(
287
327
  )
288
328
  }
289
329
  const [base, form] = options
290
- return tail((escalations) => review(base, escalations, form), form)
330
+ return tail((escalations, report) => review(base, escalations, { ...form, report }), {
331
+ ...form,
332
+ triage: true,
333
+ })
291
334
  }
292
335
 
293
336
  const isOptionalBoolean = (value: unknown): value is boolean | null | undefined =>
@@ -100,7 +100,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
100
100
  And step "implement.applying" runs its script
101
101
  When gtd lands
102
102
  And the process is driven
103
- Then the process rests at "build.review.reviewing"
103
+ Then the process rests at "build.review.composing"
104
104
  And the commit subjects end with:
105
105
  """
106
106
  gtd(human): idle → unwind
@@ -124,7 +124,7 @@ Feature: rejected naive passes walk one fixed path through the steer gate to the
124
124
  gtd(check): implement.applying → implement.health.check
125
125
  gtd(check): implement.health.check → implement.coverage
126
126
  gtd(judge): implement.coverage → implement.closing
127
- gtd(check): implement.closing → build.review.reviewing
127
+ gtd(check): implement.closing → build.review.composing
128
128
  """
129
129
  And the commit "gtd(human): idle → unwind" has a body containing "Gtd-Var: flag_risk_tier=0"
130
130
  And the commit "gtd(agent): seam.naive → seam.check" has a body containing "Gtd-Kept:"
@@ -152,7 +152,7 @@ Feature: the naive pass and seam judge decide how a feature is built
152
152
  [{"id":"AC-1","answer":"yes","p":0.9}]
153
153
  """
154
154
  When the process is driven
155
- Then the process rests at "build.review.reviewing"
155
+ Then the process rests at "build.review.composing"
156
156
  And "src/calc.ts" contains "subtract"
157
157
  And the commit subjects end with:
158
158
  """
@@ -160,7 +160,7 @@ Feature: the naive pass and seam judge decide how a feature is built
160
160
  gtd(check): implement.applying → implement.health.check
161
161
  gtd(check): implement.health.check → implement.coverage
162
162
  gtd(judge): implement.coverage → implement.closing
163
- gtd(check): implement.closing → build.review.reviewing
163
+ gtd(check): implement.closing → build.review.composing
164
164
  """
165
165
  And ".gtd/REQUIREMENTS.md" does not exist
166
166
 
@@ -335,7 +335,7 @@ const driveSeam = async (
335
335
  note: () => void files.set(NOTE, "Seam: a shared op"),
336
336
  deriving: () => void files.set(REFACTOR, "package"),
337
337
  closing: () => void files.delete(REFACTOR),
338
- "review.reviewing": () => void files.set(REVIEW, reviewDoc),
338
+ "review.composing": () => void files.set(REVIEW, reviewDoc),
339
339
  "review.closing": () => {
340
340
  files.delete(REVIEW)
341
341
  current = "after-refactor"
@@ -51,7 +51,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
51
51
  [{"id":"AC-1","answer":"yes","p":0.9},{"id":"AC-2","answer":"yes","p":0.9}]
52
52
  """
53
53
  When the process is driven
54
- Then the process rests at "build.review.reviewing"
54
+ Then the process rests at "build.review.composing"
55
55
  And the commit subjects end with:
56
56
  """
57
57
  gtd(human): design.gate.answer → seam.naive
@@ -60,7 +60,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
60
60
  gtd(check): implement.applying → implement.health.check
61
61
  gtd(check): implement.health.check → implement.coverage
62
62
  gtd(judge): implement.coverage → implement.closing
63
- gtd(check): implement.closing → build.review.reviewing
63
+ gtd(check): implement.closing → build.review.composing
64
64
  """
65
65
  And the output lines of gtd "slices" start with:
66
66
  """
@@ -112,7 +112,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
112
112
  [{"id":"AC-2","answer":"yes","p":0.9}]
113
113
  """
114
114
  When the process is driven
115
- Then the process rests at "build.review.reviewing"
115
+ Then the process rests at "build.review.composing"
116
116
  And the commit subjects end with:
117
117
  """
118
118
  gtd(agent): design.triage → design.gate.answer
@@ -129,7 +129,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
129
129
  gtd(check): implement.applying → implement.health.check
130
130
  gtd(check): implement.health.check → implement.coverage
131
131
  gtd(judge): implement.coverage → implement.closing
132
- gtd(check): implement.closing → build.review.reviewing
132
+ gtd(check): implement.closing → build.review.composing
133
133
  """
134
134
  And the output lines of gtd "slices" start with:
135
135
  """
@@ -268,7 +268,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
268
268
  """
269
269
  When gtd lands
270
270
  And the process is driven
271
- Then the process rests at "build.review.reviewing"
271
+ Then the process rests at "build.review.composing"
272
272
  And the commit subjects end with:
273
273
  """
274
274
  gtd(human): seam.steer → seam.naive
@@ -277,7 +277,7 @@ Feature: a change with more criteria than the cap is built in coarse slices
277
277
  gtd(check): implement.applying → implement.health.check
278
278
  gtd(check): implement.health.check → implement.coverage
279
279
  gtd(judge): implement.coverage → implement.closing
280
- gtd(check): implement.closing → build.review.reviewing
280
+ gtd(check): implement.closing → build.review.composing
281
281
  """
282
282
  And ".gtd/REQUIREMENTS.md" does not exist
283
283
  And ".gtd/SLICES.md" does not exist
@@ -19,6 +19,7 @@ export {
19
19
  tail,
20
20
  type TailOptions,
21
21
  } from "./shared/tail.js"
22
+ export { type Finding, type LensReport } from "./shared/lenses.js"
22
23
  export { lensEntriesOf, lensesOf, lensOf } from "../workflow-kit/index.js"
23
24
  export * from "./feature/steps.js"
24
25
  export * from "./feature/criteria.js"
@@ -116,3 +116,33 @@ describe("healthy describeGuard", () => {
116
116
  ).rejects.toThrow("guarded: nope")
117
117
  })
118
118
  })
119
+
120
+ describe("healthy at the fix cap", () => {
121
+ // Blank reports are not comparable; at an 8-byte budget the cut trims both.
122
+ it.each([
123
+ ["could not compare", "", (n: number) => "\n".repeat(n)],
124
+ ["saw only cut", `, judgeBudgetBytes: "8"`, (n: number) => `a long failing report ${n}\n`],
125
+ ])("grants no extra turn on progress the judge %s", async (_, extra, report) => {
126
+ let runs = 0
127
+ const repo = testRepo({
128
+ workflow: [
129
+ `import { healthy } from "@pmelab/gtd/workflows"`,
130
+ `import { agent } from "@pmelab/gtd/flows"`,
131
+ `export const defaults = { judgeIdenticalMinP: "0.7"${extra} }`,
132
+ `export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go")) }`,
133
+ `export default held`,
134
+ ].join("\n"),
135
+ env: { GTD_TEST_COMMAND: "true" },
136
+ })
137
+ await repo.start("held")
138
+ const rest = await repo.drive({
139
+ first: (_, r) => r.write("start", "1\n"),
140
+ "health.check": (_, r) => r.write(".gtd/FEEDBACK.md", report(++runs)),
141
+ "health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
142
+ fix: (_, r) => r.write("f", `${runs}\n`),
143
+ "health.recheck": (_, r) => r.write(".gtd/FEEDBACK.md", "red again\n"),
144
+ })
145
+ expect(rest.state).toBe("health.describe")
146
+ expect(repo.subjects().filter((s) => s.startsWith("gtd(agent): fix")).length).toBe(3)
147
+ })
148
+ })
@@ -105,7 +105,10 @@ export const retryQuestion = (comparable: boolean): JudgeQuestion =>
105
105
  "new-failure: a materially different failure than before. progress: still red, but closer to green — the default if genuinely unsure.",
106
106
  }
107
107
 
108
- const sameFailure = async (previous: string, current: string): Promise<boolean> => {
108
+ const compare = async (
109
+ previous: string,
110
+ current: string,
111
+ ): Promise<kit.RetryVerdict | undefined> => {
109
112
  const comparable = current.trim() !== "" && previous.trim() !== ""
110
113
  const { answers, truncated } = await judge("health.judge", {
111
114
  questions: [retryQuestion(comparable)],
@@ -113,12 +116,11 @@ const sameFailure = async (previous: string, current: string): Promise<boolean>
113
116
  message: t.healthJudgeMessage(),
114
117
  label: "Judging the retry",
115
118
  })
116
- // Evidence the budget cut can make two different reports look alike: never sameness.
117
- return (
118
- comparable &&
119
- truncated.length === 0 &&
120
- answered(answers.verdict, "identical", numeric(vars.judgeIdenticalMinP, Infinity))
121
- )
119
+ // Evidence the budget cut can make two different reports look alike: neither sameness nor progress.
120
+ if (!comparable || truncated.length > 0) return undefined
121
+ const minP = numeric(vars.judgeIdenticalMinP, Infinity)
122
+ if (answered(answers.verdict, "identical", minP)) return "identical"
123
+ return answered(answers.verdict, "progress", minP) ? "progress" : undefined
122
124
  }
123
125
 
124
126
  export interface HealthOptions {
@@ -153,7 +155,8 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
153
155
  /**
154
156
  * Loop until the suite is green: check, and on red run `fix` — escalating
155
157
  * first once `FIX_CAP` fixes are spent, or a judge calls the failure
156
- * identical to the previous round's. An escalation re-runs the suite once
158
+ * identical to the previous round's. A judge calling it progress at the cap
159
+ * buys one extra fix per run of fixes. An escalation re-runs the suite once
157
160
  * first; a green re-run ends the loop.
158
161
  */
159
162
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
@@ -162,7 +165,7 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
162
165
  report: FEEDBACK,
163
166
  fixCap: FIX_CAP,
164
167
  fixesSoFar: options.fixesSoFar ?? 0,
165
- sameFailure,
168
+ compare,
166
169
  escalations: options.escalations ?? { rounds: 0 },
167
170
  recheck: () =>
168
171
  recheck("health.recheck", "health.check", { sweepOnGreen: healthSweep(options) }),
@@ -0,0 +1,39 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext } from "../../flows/index.js"
3
+ import { headingSectionBodies } from "../../steering/index.js"
4
+ import { findingsOf } from "./lenses.js"
5
+ import { fixtureContext } from "./text.fixture.js"
6
+
7
+ afterEach(() => installContext(undefined))
8
+
9
+ const parse = (text: string) => {
10
+ installContext(fixtureContext({}, { sectionBodies: (t) => headingSectionBodies(t) }))
11
+ return findingsOf(text)
12
+ }
13
+
14
+ describe("findingsOf", () => {
15
+ it("numbers one finding per ## chunk, the heading cut from its body", () => {
16
+ expect(parse("# Quality\n\n## a\n\nfix a\n\n## b\n\nfix b\n")).toEqual([
17
+ { id: "Q1", title: "a", body: "fix a" },
18
+ { id: "Q2", title: "b", body: "fix b" },
19
+ ])
20
+ })
21
+
22
+ it("keeps prose outside every chunk as a finding of its own", () => {
23
+ expect(parse("# Quality\n\n- stray\n\n## a\n\nfix a\n")).toEqual([
24
+ { id: "Q1", title: "Quality findings", body: "- stray" },
25
+ { id: "Q2", title: "a", body: "fix a" },
26
+ ])
27
+ })
28
+
29
+ it("keeps prose a later lens wrote under a # heading after the chunks", () => {
30
+ expect(parse("## a\n\nfix a\n\n# Conventions\n\n- naming off\n")).toEqual([
31
+ { id: "Q1", title: "Quality findings", body: "- naming off" },
32
+ { id: "Q2", title: "a", body: "fix a" },
33
+ ])
34
+ })
35
+
36
+ it("reads a file with only a title as no finding", () => {
37
+ expect(parse("# Quality\n")).toEqual([])
38
+ })
39
+ })