@pmelab/gtd 42.0.0 → 44.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 (62) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +96 -48
  3. package/claude/hooks/sketch.ts +1 -1
  4. package/dist/chunks/{reserved-D7MNY9QJ.mjs → reserved-DJk_6-ju.mjs} +75 -46
  5. package/dist/chunks/review-X7Jfz0dY.mjs +249 -0
  6. package/dist/chunks/{steering-oCDdQlpP.mjs → steering-D4LUQ-av.mjs} +95 -12
  7. package/dist/chunks/{tail-D2o2h5cc.mjs → tail-5w5mxGTq.mjs} +3 -3
  8. package/dist/gtd.bundle.mjs +488 -30
  9. package/dist/steering.mjs +2 -2
  10. package/dist/testkit.mjs +447 -27
  11. package/dist/web/main.js +42 -13
  12. package/dist/workflow-kit.mjs +61 -25
  13. package/dist/workflows/cleanup.mjs +2 -2
  14. package/dist/workflows/feature.mjs +7 -290
  15. package/dist/workflows/fix.mjs +3 -3
  16. package/dist/workflows/review.mjs +27 -7
  17. package/dist/workflows/rollout.mjs +1 -1
  18. package/dist/workflows.mjs +287 -161
  19. package/package.json +1 -1
  20. package/src/cli/Cli.test.ts +33 -1
  21. package/src/cli/Cli.ts +70 -6
  22. package/src/cli/History.ts +15 -0
  23. package/src/cli/Judges.test.ts +373 -0
  24. package/src/cli/Judges.ts +624 -0
  25. package/src/cli/Metrics.ts +3 -16
  26. package/src/cli/Program.test.ts +3 -1
  27. package/src/cli/Program.ts +9 -0
  28. package/src/steering/FORMATS.md +4 -4
  29. package/src/steering/index.ts +12 -1
  30. package/src/steering/review.test.ts +107 -0
  31. package/src/steering/review.ts +159 -9
  32. package/src/testkit/EmittedScriptRecognizer.ts +0 -14
  33. package/src/testkit/steps.ts +4 -0
  34. package/src/workflow-kit/review.test.ts +170 -26
  35. package/src/workflow-kit/review.ts +95 -19
  36. package/src/workflows/feature/coverage.ts +1 -1
  37. package/src/workflows/feature/feature.test.ts +8 -0
  38. package/src/workflows/feature/feature.ts +2 -2
  39. package/src/workflows/feature/index.ts +1 -1
  40. package/src/workflows/feature/lens-review.feature +16 -4
  41. package/src/workflows/feature/refactor.ts +1 -1
  42. package/src/workflows/feature/review-answers.feature +179 -0
  43. package/src/workflows/feature/seam.ts +1 -1
  44. package/src/workflows/feature/steps.test.ts +2 -76
  45. package/src/workflows/feature/steps.ts +5 -48
  46. package/src/workflows/fix/fix.feature +20 -21
  47. package/src/workflows/index.ts +1 -1
  48. package/src/workflows/review/review-door.feature +149 -0
  49. package/src/workflows/review/review.test.ts +1 -73
  50. package/src/workflows/review/review.ts +34 -6
  51. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +6 -5
  52. package/src/workflows/shared/access.test.ts +2 -11
  53. package/src/workflows/shared/access.ts +0 -2
  54. package/src/workflows/{feature → shared}/review.test.ts +9 -61
  55. package/src/workflows/{feature → shared}/review.ts +40 -48
  56. package/src/workflows/shared/skills.test.ts +2 -0
  57. package/src/workflows/shared/skills.ts +2 -0
  58. package/src/workflows/shared/steps.test.ts +64 -2
  59. package/src/workflows/shared/steps.ts +40 -1
  60. package/src/workflows/shared/tail.ts +2 -2
  61. package/src/workflows/shared/text.test.ts +40 -89
  62. package/src/workflows/shared/text.ts +63 -54
@@ -1,18 +1,46 @@
1
- import { start, type WorkflowBase } from "../../flows/index.js"
1
+ import { read, refuse, removeScript, run, start, type WorkflowBase } from "../../flows/index.js"
2
+ import { buildTail, reUnwind } from "../shared/review.js"
2
3
  import { gate } from "../shared/health.js"
3
4
  import { shipped } from "../shared/options.js"
4
- import { handOffTail, refusePendingHandOff } from "../shared/tail.js"
5
+ import { coder, REQUIREMENTS } from "../shared/steps.js"
6
+ import { refusePendingHandOff } from "../shared/tail.js"
5
7
  import * as t from "../shared/text.js"
6
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
+
7
22
  /**
8
- * Pure review of everything since `reviewBase`: a green gate, the quality
9
- * lap, then the human review. A note ends the process with `.gtd/REVIEW.md`
10
- * left for `fix`; it never re-plans.
23
+ * The feature's review of everything since `reviewBase` — a green gate, the
24
+ * triaged quality lap, then the human review — with no design phase: a
25
+ * review that asks for changes is fixed in place and reviewed again, until
26
+ * sign-off.
11
27
  */
12
28
  export const reviewFlow = async (): Promise<void> => {
13
29
  refusePendingHandOff()
30
+ // A review's feedback is whatever sits in this file, so a leftover one would be built, then deleted.
31
+ if (read(REQUIREMENTS) !== undefined) {
32
+ refuse(`gtd: ${REQUIREMENTS} is left over — finish or remove it before a review.`)
33
+ }
14
34
  await gate("review-gate", t.reviewGateBlockedMessage())
15
- await handOffTail(false, start())
35
+ const base = start()
36
+ // The gate just proved the tree green.
37
+ let outcome = await buildTail(base, { green: true })
38
+ while (outcome.verdict === "feedback") {
39
+ await reUnwind(outcome)
40
+ await fixFeedback()
41
+ // The human reviews the whole change again; the lenses only what the fix changed.
42
+ outcome = await buildTail(base, { lensBase: outcome.base })
43
+ }
16
44
  }
17
45
 
18
46
  export const review = shipped(
@@ -45,11 +45,12 @@ You are judging and classifying a round of review feedback.
45
45
  - This turn writes `.gtd/REQUIREMENTS.md` (the folded concerns) and
46
46
  replies inside `.gtd/REVIEW.md` (thread replies only; the file stays
47
47
  in the tree) — you classify, you do not build
48
- - Fold every concluded thread, ticked answer and hand-edit — and each
49
- `edit` note the capture lists, no other note — into
50
- `.gtd/REQUIREMENTS.md` and delete the folded threads from
51
- `.gtd/REVIEW.md`; append one `- A:` reply to each thread
52
- whose last entry is a `- H:` question
48
+ - Fold every concluded thread and hand-edit — and each `edit` note
49
+ the capture lists, no other note — into `.gtd/REQUIREMENTS.md` and
50
+ delete the folded threads from `.gtd/REVIEW.md`; append one `- A:`
51
+ reply to each thread whose last entry is a `- H:` question
52
+ - Never fold a note's ticked answer box the capture does not list: gtd
53
+ routes those itself
53
54
  - When the capture names an answering commit, carry each answered
54
55
  question into `.gtd/REQUIREMENTS.md`'s `## Answered Questions` as
55
56
  `### <the note>` plus its answer — the lap replaces `.gtd/REVIEW.md`
@@ -11,7 +11,6 @@ describe("the bundled workflow's access export", () => {
11
11
  expect(bundled).toEqual({
12
12
  design: { write: [] },
13
13
  "build.review": { write: [".gtd/REVIEW.md"] },
14
- "build.review.defer": { write: [".gtd/TODO.md"] },
15
14
  "build.review.fix.nits": {},
16
15
  "build.review.fix.risks": {},
17
16
  "build.quality.correctness": { write: [] },
@@ -21,7 +20,6 @@ describe("the bundled workflow's access export", () => {
21
20
  "build.quality.conventions": { write: [] },
22
21
  "build.quality.spec-challenge": { write: [] },
23
22
  "refactor.build.review": { write: [".gtd/REVIEW.md"] },
24
- "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
25
23
  "refactor.build.quality.correctness": { write: [] },
26
24
  "refactor.build.quality.ponytail-review": { write: [] },
27
25
  "refactor.build.quality.conventions": { write: [] },
@@ -32,7 +30,6 @@ describe("the bundled workflow's access export", () => {
32
30
  expect(bundled).not.toHaveProperty(["refactor.build.review.fix.nits"])
33
31
  expect(bundled).not.toHaveProperty(["refactor.build.review.fix.risks"])
34
32
  expect(bundled["refactor.build.review"]).toEqual({ write: [".gtd/REVIEW.md"] })
35
- expect(bundled["refactor.build.review.defer"]).toEqual({ write: [".gtd/TODO.md"] })
36
33
  })
37
34
 
38
35
  it("keys one lens scope per qualityReviews entry", () => {
@@ -54,17 +51,11 @@ describe("the bundled workflow's access export", () => {
54
51
  "refactor.build.quality.correctness",
55
52
  "refactor.build.quality.my-lens",
56
53
  "refactor.build.review",
57
- "refactor.build.review.defer",
58
54
  ])
59
55
  })
60
56
 
61
- it("keys only turn or script scopes", () => {
62
- // `build.review.defer` is a script scope: no turn, so no skills entry.
63
- const known = [
64
- ...Object.keys(workflow.skills(defaults)),
65
- "build.review.defer",
66
- "refactor.build.review.defer",
67
- ]
57
+ it("keys only turn scopes", () => {
58
+ const known = Object.keys(workflow.skills(defaults))
68
59
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
69
60
  })
70
61
  })
@@ -13,12 +13,10 @@ export const access = (
13
13
  ): Readonly<Record<string, ScopeAccess>> => ({
14
14
  design: { write: [] },
15
15
  "build.review": { write: [".gtd/REVIEW.md"] },
16
- "build.review.defer": { write: [".gtd/TODO.md"] },
17
16
  // `{}` reopens what `build.review` restricted: the fixes edit code.
18
17
  "build.review.fix.nits": {},
19
18
  "build.review.fix.risks": {},
20
19
  "refactor.build.review": { write: [".gtd/REVIEW.md"] },
21
- "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
22
20
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] })),
23
21
  ...lensScopes("refactor.build.quality", lensesOf(vars.refactorQualityReviews ?? ""), () => ({
24
22
  write: [],
@@ -9,7 +9,7 @@ import {
9
9
  } from "../../flows/index.js"
10
10
  import {
11
11
  buildTail,
12
- review,
12
+ humanReview,
13
13
  reUnwind,
14
14
  severityOf,
15
15
  type BuildTailOptions,
@@ -17,8 +17,7 @@ import {
17
17
  } from "./review.js"
18
18
  import type { ReviewNote } from "../../steering/index.js"
19
19
  import * as bundled from "../index.js"
20
- import type * as featureEntry from "./index.js"
21
- import { fixtureContext } from "../shared/text.fixture.js"
20
+ import { fixtureContext } from "./text.fixture.js"
22
21
  import { toRequest } from "../../judges/index.js"
23
22
 
24
23
  afterEach(() => installContext(undefined))
@@ -155,7 +154,7 @@ const drive = async (d: Drive): Promise<Run> => {
155
154
  installContext(context)
156
155
  let result: ReviewOutcome | "stopped"
157
156
  try {
158
- result = await review("base", undefined, {
157
+ result = await humanReview("base", undefined, {
159
158
  ...d.options,
160
159
  ...(d.guarded ? { guard: () => void log.push("guard") } : {}),
161
160
  })
@@ -675,7 +674,7 @@ describe("severity routing", () => {
675
674
  expect(run.prompts.get("review.collecting")).toContain("(critical: forced by `!`)")
676
675
  })
677
676
 
678
- it("a confident could edit defers and, alone, returns to the review gate", async () => {
677
+ it("a confident could edit is still re-planned, labelled could", async () => {
679
678
  const run = await drive({
680
679
  notes: ["add — quote", "sub", "mul"],
681
680
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
@@ -684,9 +683,10 @@ describe("severity routing", () => {
684
683
  "review.composing",
685
684
  "review.await-review",
686
685
  "review.triage",
687
- "review.defer",
688
- "review.await-review",
686
+ "review.closing",
687
+ "review.collecting",
689
688
  ])
689
+ expect(run.prompts.get("review.collecting")).toContain("(could)")
690
690
  })
691
691
 
692
692
  it("severity answers on a nit leave it a nit", async () => {
@@ -730,68 +730,19 @@ describe("severity routing", () => {
730
730
  expect(run.judged).toContain("note-1.security")
731
731
  })
732
732
 
733
- it("the gate after a deferral accepts a clean land and lists the deferred notes", async () => {
734
- const run = await drive({
735
- notes: ["add — quote", "sub", "mul"],
736
- answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
737
- })
738
- expect(run.gates).toEqual([true, true])
739
- expect(run.gateMessages[1]).toContain("NOT acted on")
740
- expect(run.gateMessages[1]).toContain("note-1")
741
- expect(run.gateMessages[0]).not.toContain("NOT acted on")
742
- })
743
-
744
733
  it("the first round's gate accepts a clean land", async () => {
745
734
  const run = await drive({ notes: ["add", "sub", "mul"] })
746
735
  expect(run.gates.length).toBeGreaterThan(0)
747
736
  expect(run.gates.every((g) => g)).toBe(true)
748
737
  })
749
738
 
750
- it("a later gate still accepts a clean land after a deferral round with a nit", async () => {
751
- const run = await drive({
752
- notes: ["add — quote", "sub — typo", "mul"],
753
- answers: {
754
- "note-1": verdict("edit"),
755
- ...sev("note-1", could),
756
- "note-2": verdict("nit"),
757
- },
758
- reviewDocs: [undefined, doc(["add", "sub"])],
759
- })
760
- expect(run.log).toContain("review.defer")
761
- expect(run.log.at(-1)).toBe("review.await-review")
762
- expect(run.gates).toEqual([true, true])
763
- })
764
-
765
- it("a question and a could note in one round: the gate's base is the answering commit", async () => {
766
- const run = await drive({
767
- notes: ["add — why?", "sub — quote", "mul"],
768
- answers: {
769
- "note-1": verdict("question"),
770
- "note-2": verdict("edit"),
771
- ...sev("note-2", could),
772
- },
773
- passSecondGate: true,
774
- })
775
- expect(run.log).toEqual([
776
- "review.composing",
777
- "review.await-review",
778
- "review.triage",
779
- "review.answer-review-questions",
780
- "review.defer",
781
- "review.await-review",
782
- ])
783
- // c4 is the answering commit, not the defer commit (c5) or the round (c2)
784
- expect(run.since.at(-1)).toBe("c4")
785
- })
786
-
787
739
  it("a blank reviewSeverityMinP makes every confident could critical", async () => {
788
740
  const run = await drive({
789
741
  notes: ["add — quote", "sub", "mul"],
790
742
  answers: { "note-1": verdict("edit"), ...sev("note-1", could) },
791
743
  vars: { reviewSeverityMinP: "" },
792
744
  })
793
- expect(run.log).toContain("review.collecting")
794
- expect(run.log).not.toContain("review.defer")
745
+ expect(run.prompts.get("review.collecting")).toContain("(critical: judge unsure)")
795
746
  })
796
747
  })
797
748
 
@@ -1023,11 +974,8 @@ describe("BuildTailOptions.lensBase", () => {
1023
974
  })
1024
975
  })
1025
976
 
1026
- // pins the public re-exports from the feature entry point and the published workflows entry
977
+ // pins the public re-exports from the published workflows entry
1027
978
  export type _PublicBuildTailOptions = [
1028
- featureEntry.BuildTailOptions,
1029
- featureEntry.FeedbackTarget,
1030
- featureEntry.ReviewRange,
1031
979
  bundled.BuildTailOptions,
1032
980
  bundled.FeedbackTarget,
1033
981
  bundled.ReviewRange,
@@ -16,20 +16,22 @@ import {
16
16
  } from "../../flows/index.js"
17
17
  import type { ReviewNote } from "../../steering/index.js"
18
18
  import * as kit from "../../workflow-kit/index.js"
19
- import { guarded, healthy, type EscalationCount } from "../shared/health.js"
20
- import { NO_FINDINGS, type LensReport } from "../shared/lenses.js"
19
+ import { guarded, healthy, type EscalationCount } from "./health.js"
20
+ import { NO_FINDINGS, type LensReport } from "./lenses.js"
21
21
  import {
22
+ answerReviewQuestions,
22
23
  awaitReview,
24
+ collecting,
23
25
  fixCheck,
26
+ fixNits,
24
27
  fixRisks,
25
28
  QUALITY,
26
29
  REQUIREMENTS,
27
30
  REVIEW,
28
31
  reviewMissing,
29
- } from "../shared/steps.js"
30
- import { closeReview, isOptionalObject, tail, type TailOptions } from "../shared/tail.js"
31
- import * as t from "../shared/text.js"
32
- import { answerReviewQuestions, collecting, deferNotes, fixNits } from "./steps.js"
32
+ } from "./steps.js"
33
+ import { closeReview, isOptionalObject, tail, type TailOptions } from "./tail.js"
34
+ import * as t from "./text.js"
33
35
 
34
36
  export type { ReviewOutcome } from "../../workflow-kit/index.js"
35
37
 
@@ -46,6 +48,12 @@ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
46
48
 
47
49
  type Tier = "critical" | "should" | "could"
48
50
 
51
+ const TIER_ORDER: Readonly<Record<Tier, number>> = { critical: 0, should: 1, could: 2 }
52
+
53
+ /** Capture order: critical, should, could; within a tier one-way doors, then new concepts. */
54
+ const captureKey = (s: Severity | undefined): number =>
55
+ s === undefined ? 2 : TIER_ORDER[s.tier] * 3 + s.rank
56
+
49
57
  const SEVERITY_ANSWERS: Readonly<Record<string, readonly string[]>> = {
50
58
  security: ["yes", "no"],
51
59
  reversibility: ["one-way", "two-way"],
@@ -60,10 +68,7 @@ interface Severity {
60
68
  readonly rank: 0 | 1 | 2
61
69
  }
62
70
 
63
- /**
64
- * Deferring is the risky direction: anything unanswered, unrecognised, cut or
65
- * below `minP` is `critical`, never `could`.
66
- */
71
+ /** Anything unanswered, unrecognised, cut or below `minP` is `critical`, never `could`. */
67
72
  export const severityOf = (note: ReviewNote, judgment: Judgment, minP: number): Severity => {
68
73
  const ans = (q: string) => judgment.answers[`${note.id}.${q}`]
69
74
  const a = (q: string): string | undefined => ans(q)?.answer
@@ -125,7 +130,7 @@ const severityQuestions = (note: ReviewNote): JudgeQuestion[] => [
125
130
  * nothing) plus four severity answers. Dismissing a note is the risky
126
131
  * direction, so only a confident non-`edit` verdict on uncut evidence counts;
127
132
  * a cut, unanswered or below-floor note is an `edit`. Severity answers on a
128
- * non-`edit` note are ignored. A confident `could` edit becomes `defer`.
133
+ * non-`edit` note are ignored; on an `edit` they order the collecting turn.
129
134
  */
130
135
  const triage =
131
136
  (severities: Map<string, Severity>) =>
@@ -155,9 +160,8 @@ const triage =
155
160
  ? undefined
156
161
  : VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
157
162
  if (verdict !== undefined && !truncated.includes(n.id)) return [n.id, verdict]
158
- const severity = severityOf(n, judgment, severityMinP)
159
- severities.set(n.id, severity)
160
- return [n.id, severity.tier === "could" ? "defer" : "edit"]
163
+ severities.set(n.id, severityOf(n, judgment, severityMinP))
164
+ return [n.id, "edit"]
161
165
  }),
162
166
  )
163
167
  }
@@ -167,13 +171,15 @@ export const isCode = (path: string): boolean => path !== ".gtd" && !path.starts
167
171
  /**
168
172
  * gtd writes `.gtd/REVIEW.md` from the quality lap's `report` — no reviewer
169
173
  * 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.
174
+ * after fixed nits restores the doc as committed at `carry`, answers kept;
175
+ * a fresh one leaves out the notes the human already answered.
171
176
  */
172
177
  const composing = (
173
178
  base: string,
174
179
  report: LensReport,
175
180
  carry?: string,
176
181
  nitFix?: string,
182
+ answered: ReadonlySet<string> = new Set(),
177
183
  ): Promise<void> =>
178
184
  run(
179
185
  "review.composing",
@@ -184,6 +190,7 @@ const composing = (
184
190
  ...report,
185
191
  weakened: kit.removedAssertions(changesSince(base)),
186
192
  nitFix,
193
+ answered,
187
194
  }),
188
195
  [QUALITY],
189
196
  )
@@ -193,11 +200,13 @@ const composing = (
193
200
 
194
201
  /**
195
202
  * 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,
203
+ * answers each one — fix as suggested, won't fix, or their own answer — and
204
+ * may comment. A fix joins the nit batch, a won't-fix is dropped, and an own
205
+ * answer or comment is judged: edits end the review as `feedback` for the
206
+ * caller to build, questions are answered in place, nits fixed in one batch,
198
207
  * praise dropped.
199
208
  */
200
- export const review = (
209
+ export const humanReview = (
201
210
  base: string,
202
211
  escalations: EscalationCount = { rounds: 0 },
203
212
  options: Omit<BuildTailOptions, "fixFirst" | "green"> = {},
@@ -210,42 +219,26 @@ export const review = (
210
219
  "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
211
220
  }
212
221
  const severities = new Map<string, Severity>()
213
- // Armed by a defer-only round to pick the next gate's message; a fresh
214
- // review (`reviewing`) disarms it.
215
- let deferred: readonly t.NoteInput[] = []
216
222
  // Set by a nit fix, so the fresh review that follows names its commit.
217
223
  let nitFix: string | undefined
224
+ // Every note the human answered in this review, left out of a fresh one.
225
+ const answered = new Set<string>()
218
226
  return kit.review(base, {
219
227
  file: REVIEW,
220
228
  feedback: feedback.file,
221
229
  isCode,
222
230
  reviewing: (b, carry) => {
223
- deferred = []
224
231
  const fixedAt = nitFix
225
232
  nitFix = undefined
226
- return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt)
233
+ return composing(b, options.report ?? NO_FINDINGS, carry, fixedAt, answered)
227
234
  },
228
235
  fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
229
236
  keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
230
- awaitReview: (b) => {
231
- const notes = deferred
232
- deferred = []
233
- return notes.length > 0
234
- ? awaitReview(b, t.buildReviewAwaitAfterDeferMessage(b, notes))
235
- : awaitReview(b)
236
- },
237
+ awaitReview: (b) => awaitReview(b),
237
238
  reviewMissing,
238
239
  collecting: feedback.collecting,
239
240
  triage: triage(severities),
240
- defer: (notes) => {
241
- deferred = notes.map((n) => ({
242
- id: n.id,
243
- anchor: n.anchor,
244
- text: n.text,
245
- label: severities.get(n.id)!.reason,
246
- }))
247
- return deferNotes(deferred, head())
248
- },
241
+ decided: (decisions) => decisions.forEach((d) => answered.add(d.anchor)),
249
242
  answerQuestions: answerReviewQuestions,
250
243
  fixNits: async (notes) => {
251
244
  await guarded((n: typeof notes) => fixNits(n, fixAccess, rules), guard)(notes)
@@ -259,14 +252,13 @@ export const review = (
259
252
  t.reviewEditNotesCapture(
260
253
  round,
261
254
  edits
262
- .map((n, i) => ({ n, i, s: severities.get(n.id) }))
263
- // critical before should, then one-way doors, then new concepts
264
- .sort(
265
- (x, y) =>
266
- Number(x.s?.tier === "should") - Number(y.s?.tier === "should") ||
267
- (x.s?.rank ?? 2) - (y.s?.rank ?? 2) ||
268
- x.i - y.i,
269
- )
255
+ .map((n, i) => ({
256
+ n,
257
+ i,
258
+ s: severities.get(n.id),
259
+ key: captureKey(severities.get(n.id)),
260
+ }))
261
+ .sort((x, y) => x.key - y.key || x.i - y.i)
270
262
  .map(({ n, s }) => ({
271
263
  id: n.id,
272
264
  anchor: n.anchor,
@@ -327,7 +319,7 @@ export function buildTail(
327
319
  )
328
320
  }
329
321
  const [base, form] = options
330
- return tail((escalations, report) => review(base, escalations, { ...form, report }), {
322
+ return tail((escalations, report) => humanReview(base, escalations, { ...form, report }), {
331
323
  ...form,
332
324
  triage: true,
333
325
  })
@@ -21,6 +21,7 @@ describe("the bundled workflow's skills export", () => {
21
21
  "build.review.fix.nits",
22
22
  "build.review.fix.risks",
23
23
  "notes",
24
+ "feedback",
24
25
  "refactor",
25
26
  "refactor.fix.suite",
26
27
  "refactor.health",
@@ -71,6 +72,7 @@ describe("the bundled workflow's skills export", () => {
71
72
  "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
72
73
  "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
73
74
  notes: ["incremental-implementation", "code-simplification"],
75
+ feedback: ["test-driven-development", "incremental-implementation"],
74
76
  refactor: ["ponytail"],
75
77
  "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
76
78
  "refactor.health": ["ponytail", "debugging-and-error-recovery"],
@@ -22,6 +22,8 @@ export const skills = (
22
22
  design: ["spec-driven-development", "planning-and-task-breakdown"],
23
23
  // `fix`'s turn over the notes a review handed on.
24
24
  notes: ["incremental-implementation", "code-simplification"],
25
+ // `review`'s turn building what a review round collected.
26
+ feedback: ["test-driven-development", "incremental-implementation"],
25
27
  // The refactor's own turns load `ponytail`; `refactor.build.*` carry the `build.*` lists.
26
28
  ...build,
27
29
  ...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
@@ -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 })