@pmelab/gtd 28.2.0 → 29.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 (51) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -4
  3. package/dist/chunks/{reserved-DeBS4Ymh.mjs → reserved-DoHUII2D.mjs} +206 -121
  4. package/dist/chunks/{tail-B2SER2Ih.mjs → tail-C6YBG5zh.mjs} +19 -12
  5. package/dist/testkit.mjs +2 -1
  6. package/dist/workflow-kit.mjs +51 -21
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +247 -96
  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 +528 -262
  13. package/package.json +1 -1
  14. package/src/testkit/TestRepo.ts +3 -0
  15. package/src/testkit/index.ts +1 -1
  16. package/src/testkit/steps.ts +44 -0
  17. package/src/workflow-kit/review.test.ts +366 -8
  18. package/src/workflow-kit/review.ts +109 -26
  19. package/src/workflows/feature/feature.test.ts +313 -1
  20. package/src/workflows/feature/feature.ts +48 -43
  21. package/src/workflows/feature/one-step-land.feature +4 -0
  22. package/src/workflows/feature/refactor-first.feature +54 -24
  23. package/src/workflows/feature/refactor-review.feature +1706 -0
  24. package/src/workflows/feature/refactor.test.ts +592 -2
  25. package/src/workflows/feature/refactor.ts +160 -42
  26. package/src/workflows/feature/review.test.ts +322 -5
  27. package/src/workflows/feature/review.ts +77 -10
  28. package/src/workflows/feature/seam-rejection.feature +4 -0
  29. package/src/workflows/feature/seam.feature +3 -4
  30. package/src/workflows/feature/seam.test.ts +196 -1
  31. package/src/workflows/feature/seam.ts +23 -9
  32. package/src/workflows/feature/steps.test.ts +54 -2
  33. package/src/workflows/feature/steps.ts +45 -9
  34. package/src/workflows/feature/tier.test.ts +4 -0
  35. package/src/workflows/feature/tier.ts +2 -20
  36. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +97 -0
  37. package/src/workflows/shared/access.test.ts +31 -1
  38. package/src/workflows/shared/access.ts +7 -1
  39. package/src/workflows/shared/fences.ts +19 -0
  40. package/src/workflows/shared/health.ts +32 -12
  41. package/src/workflows/shared/options.test.ts +18 -0
  42. package/src/workflows/shared/options.ts +1 -0
  43. package/src/workflows/shared/prose.ts +4 -2
  44. package/src/workflows/shared/skills.test.ts +20 -1
  45. package/src/workflows/shared/skills.ts +43 -32
  46. package/src/workflows/shared/steps.ts +33 -19
  47. package/src/workflows/shared/tail.test.ts +84 -1
  48. package/src/workflows/shared/tail.ts +30 -12
  49. package/src/workflows/shared/text.test.ts +266 -3
  50. package/src/workflows/shared/text.ts +183 -69
  51. package/src/workflows/shared/vars.ts +12 -2
@@ -3,13 +3,17 @@ import {
3
3
  head,
4
4
  judge,
5
5
  numeric,
6
+ requireRevert,
7
+ restoreScript,
8
+ run,
6
9
  vars,
7
10
  type JudgeQuestion,
8
11
  type Judgment,
12
+ type ScopeAccess,
9
13
  } from "../../flows/index.js"
10
14
  import type { ReviewNote } from "../../steering/index.js"
11
15
  import * as kit from "../../workflow-kit/index.js"
12
- import { healthy, type EscalationCount } from "../shared/health.js"
16
+ import { guarded, healthy, type EscalationCount, type Guard } from "../shared/health.js"
13
17
  import {
14
18
  awaitReview,
15
19
  fixCheck,
@@ -165,21 +169,29 @@ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".
165
169
  export const review = (
166
170
  base: string,
167
171
  escalations: EscalationCount = { rounds: 0 },
172
+ options: BuildTailOptions = {},
168
173
  ): Promise<kit.ReviewOutcome> => {
174
+ const { guard, guardRests, fixAccess, rules } = options
175
+ const feedback: FeedbackTarget = options.feedback ?? {
176
+ file: REQUIREMENTS,
177
+ collecting,
178
+ unmatched:
179
+ "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
180
+ }
169
181
  const severities = new Map<string, Severity>()
170
182
  // Armed by a defer-only round to pick the next gate's message; a fresh
171
183
  // review (`reviewing`) disarms it.
172
184
  let deferred: readonly t.NoteInput[] = []
173
185
  return kit.review(base, {
174
186
  file: REVIEW,
175
- feedback: REQUIREMENTS,
187
+ feedback: feedback.file,
176
188
  isCode,
177
189
  reviewing: (b, carry) => {
178
190
  deferred = []
179
- return reviewing(b, carry)
191
+ return reviewing(b, carry, options.lap)
180
192
  },
181
- fixRisks,
182
- keepGreen: () => healthy(fixCheck, { escalations }),
193
+ fixRisks: guarded((notes) => fixRisks(notes, fixAccess, rules), guard),
194
+ keepGreen: () => healthy(() => fixCheck(rules), { escalations, guard, guardRests, rules }),
183
195
  awaitReview: (b) => {
184
196
  const notes = deferred
185
197
  deferred = []
@@ -188,7 +200,7 @@ export const review = (
188
200
  : awaitReview(b)
189
201
  },
190
202
  reviewMissing,
191
- collecting,
203
+ collecting: feedback.collecting,
192
204
  triage: triage(severities),
193
205
  defer: (notes) => {
194
206
  deferred = notes.map((n) => ({
@@ -200,7 +212,7 @@ export const review = (
200
212
  return deferNotes(deferred, head())
201
213
  },
202
214
  answerQuestions: answerReviewQuestions,
203
- fixNits,
215
+ fixNits: guarded((notes) => fixNits(notes, fixAccess, rules), guard),
204
216
  close: closeReview,
205
217
  captures: {
206
218
  edits: t.reviewEditsCapture,
@@ -229,15 +241,70 @@ export const review = (
229
241
  refusals: {
230
242
  deleted:
231
243
  "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
232
- unmatched:
233
- "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
244
+ unmatched: feedback.unmatched,
245
+ ...(feedback.dropped === undefined ? {} : { dropped: feedback.dropped }),
234
246
  },
235
247
  })
236
248
  }
237
249
 
250
+ export interface FeedbackTarget {
251
+ readonly file: string
252
+ readonly collecting: (capture: string, round: kit.CollectRound) => Promise<void>
253
+ /** Refusal when the collecting turn changed something but not `file`. */
254
+ readonly unmatched: string
255
+ readonly dropped?: (requested: kit.Requested) => string
256
+ }
257
+
258
+ export interface BuildTailOptions {
259
+ /** Runs after every code-changing turn: fix-check, quality fix, risk fix, nit fix, escalation describe. */
260
+ readonly guard?: Guard
261
+ /** `false`: `guard` skips the escalation's human rests. */
262
+ readonly guardRests?: boolean
263
+ /** Quality-lap lenses; default `lensesOf(vars.qualityReviews)`. */
264
+ readonly lenses?: readonly string[]
265
+ readonly feedback?: FeedbackTarget
266
+ /** Write access for the nit and risk fix turns; absent: the bundled `access` export decides. */
267
+ readonly fixAccess?: ScopeAccess
268
+ /** The commit the quality lap reviews from; absent: `start()`. */
269
+ readonly lensBase?: string
270
+ /** Appended to every fix and describe prompt of the tail. */
271
+ readonly rules?: string
272
+ /** Absent: "first". */
273
+ readonly lap?: t.ReviewRange
274
+ }
275
+
238
276
  /** The build tail: fix (when entered red), keep green unless `green`, the quality lap, then human review since `base`. */
239
277
  export const buildTail = (
240
278
  fixFirst: boolean,
241
279
  base: string,
242
280
  green = false,
243
- ): Promise<kit.ReviewOutcome> => tail(fixFirst, (escalations) => review(base, escalations), green)
281
+ options: BuildTailOptions = {},
282
+ ): Promise<kit.ReviewOutcome> =>
283
+ tail(
284
+ fixFirst,
285
+ (escalations) => review(base, escalations, options),
286
+ green,
287
+ options.guard,
288
+ options.lenses,
289
+ options.guardRests,
290
+ options.lensBase,
291
+ options.rules,
292
+ )
293
+
294
+ /**
295
+ * Undo the human's review-round code edits. A path changed since the human
296
+ * left it is not overwritten; requireRevert names it instead.
297
+ */
298
+ export const reUnwind = async (
299
+ feedback: Extract<kit.ReviewOutcome, { verdict: "feedback" }>,
300
+ ): Promise<void> => {
301
+ const { base, restoreFrom, edited } = feedback
302
+ const restore = edited.filter((c) => c.status !== "added").map((c) => c.path)
303
+ const remove = edited.filter((c) => c.status === "added").map((c) => c.path)
304
+ await run("re-unwind", restoreScript(base, { restore, remove }, restoreFrom), {
305
+ label: "Re-unwinding your review edit",
306
+ file: REVIEW,
307
+ base,
308
+ })
309
+ requireRevert(edited, base)
310
+ }
@@ -143,4 +143,8 @@ Feature: rejected naive passes walk one fixed path through the steer gate to idl
143
143
  And the commit "gtd(judge): seam.judge → seam.steer" has a body containing "\"answer\":\"reject\""
144
144
  And the commit "gtd(judge): seam.judge → implement.building" has a body containing "\"answer\":\"land\""
145
145
  And no commit body contains "Gtd-Approved:"
146
+ And the output lines of gtd "slices" start with:
147
+ """
148
+ feature
149
+ """
146
150
  And no commit body contains "Gtd-Workflow:"
@@ -222,7 +222,7 @@ Feature: the naive pass and seam judge replace architecture behind GTD_FLAG_NAIV
222
222
  gtd(human): seam.steer → implement.building
223
223
  """
224
224
 
225
- Scenario: review feedback re-runs design and the whole seam phase
225
+ Scenario: review feedback after land re-runs design and implement, never the seam phase
226
226
  Given step "seam.naive" writes "src/calc.ts" with:
227
227
  """
228
228
  export const add = (a: number, b: number) => b + a
@@ -311,10 +311,9 @@ Feature: the naive pass and seam judge replace architecture behind GTD_FLAG_NAIV
311
311
  Then the process rests at "implement.building"
312
312
  And the commit subjects end with:
313
313
  """
314
+ gtd(check): build.review.closing → re-unwind
314
315
  gtd(check): re-unwind → design.triage
315
316
  gtd(agent): design.triage → design.gate.answer
316
- gtd(human): design.gate.answer → seam.naive
317
- gtd(agent): seam.naive → seam.judge
318
- gtd(judge): seam.judge → implement.building
317
+ gtd(human): design.gate.answer → implement.building
319
318
  gtd(agent): implement.building
320
319
  """
@@ -8,7 +8,7 @@ import {
8
8
  } from "../../flows/index.js"
9
9
  import { globMatches } from "../../wire/index.js"
10
10
  import { fixtureContext } from "../shared/text.fixture.js"
11
- import { judgeSeam, naivePass } from "./seam.js"
11
+ import { implement, judgeSeam, naivePass, seamBuild, type SeamOutcome } from "./seam.js"
12
12
  import { captureStep } from "../shared/text.fixture.js"
13
13
 
14
14
  afterEach(() => installContext(undefined))
@@ -86,3 +86,198 @@ describe("naivePass", () => {
86
86
  expect(request.prompt).toContain("0.8")
87
87
  })
88
88
  })
89
+
90
+ const REVIEW = ".gtd/REVIEW.md"
91
+ const REFACTOR = ".gtd/REFACTOR.md"
92
+ const reviewDoc = [
93
+ "# Review: abc1234",
94
+ "",
95
+ "<!-- base: 0000000000000000000000000000000000000000 -->",
96
+ "",
97
+ "## calc",
98
+ "- [ ] ./src/a.ts#1-1",
99
+ "add",
100
+ "",
101
+ ].join("\n")
102
+
103
+ interface Seamed {
104
+ readonly names: string[]
105
+ readonly slices: string[]
106
+ /** Slice names entered, with the steps seen while inside each. */
107
+ readonly inside: Record<string, string[]>
108
+ readonly outcome: SeamOutcome | Error
109
+ }
110
+
111
+ /** Runs `seamBuild` against a scripted context: the judge answers `answer`, every review signs off. */
112
+ const driveSeam = async (
113
+ answer: string,
114
+ stray: Change = change("src/a.ts", "modified"),
115
+ vars: Record<string, string> = {},
116
+ ): Promise<Seamed> => {
117
+ const names: string[] = []
118
+ const slices: string[] = []
119
+ const inside: Record<string, string[]> = {}
120
+ const stack: string[] = []
121
+ const open: string[] = []
122
+ const files = new Map<string, string>([["src/a.ts", "a"]])
123
+ let current = "c0"
124
+ const effects: Record<string, () => void> = {
125
+ deriving: () => void files.set(REFACTOR, "package"),
126
+ closing: () => void files.delete(REFACTOR),
127
+ "review.reviewing": () => void files.set(REVIEW, reviewDoc),
128
+ "review.closing": () => {
129
+ files.delete(REVIEW)
130
+ current = "after-refactor"
131
+ },
132
+ }
133
+ installContext({
134
+ ...fixtureContext({ vars: { refactorQualityReviews: "", ...vars } }),
135
+ matches: globMatches,
136
+ refuse: (message: string) => {
137
+ throw new Error(message)
138
+ },
139
+ pushScope: (scope: { name?: string }) => void stack.push(scope.name ?? ""),
140
+ popScope: () => void stack.pop(),
141
+ enterSlice: (name: string) => {
142
+ slices.push(name)
143
+ open.push(name)
144
+ inside[name] = []
145
+ },
146
+ leaveSlice: () => void open.pop(),
147
+ read: (path: string) => files.get(path),
148
+ head: () => current,
149
+ kept: () => tip(stray),
150
+ changes: () => [],
151
+ changesSince: () => [],
152
+ step: (request: StepRequest) => {
153
+ if (request.kind === "restart") return Promise.resolve()
154
+ const full = [...stack, request.name].join(".")
155
+ names.push(full)
156
+ for (const name of open) inside[name]!.push(full)
157
+ effects[request.name]?.()
158
+ if (request.kind === "judge")
159
+ return Promise.resolve({ answers: { shape: { answer, p: 0.9 } }, truncated: [] })
160
+ return Promise.resolve(undefined)
161
+ },
162
+ } as unknown as FlowContext)
163
+ let outcome: SeamOutcome | Error
164
+ try {
165
+ outcome = await seamBuild("c-base")
166
+ } catch (error) {
167
+ outcome = error as Error
168
+ }
169
+ return { names, slices, inside, outcome }
170
+ }
171
+
172
+ describe("seamBuild", () => {
173
+ it("land resolves the kept tip with the caller's base and enters no slice", async () => {
174
+ const h = await driveSeam("land")
175
+ expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
176
+ expect(h.slices).toEqual([])
177
+ expect(h.names).toEqual(["seam.naive", "seam.judge"])
178
+ })
179
+
180
+ it("refactor-first runs the reviewed refactor in slice refactor and resolves HEAD after it", async () => {
181
+ const h = await driveSeam("refactor-first")
182
+ expect(h.outcome).toEqual({ reference: "abc", base: "after-refactor" })
183
+ expect(h.slices).toEqual(["refactor"])
184
+ expect(h.inside.refactor![0]).toBe("refactor.deriving")
185
+ expect(h.inside.refactor).toContain("refactor.build.review.closing")
186
+ expect(h.names.some((n) => n.includes("approve"))).toBe(false)
187
+ expect(h.names.some((n) => n.startsWith("implement"))).toBe(false)
188
+ })
189
+
190
+ it("refactor-first with only test-file strays resolves the caller's base with no slice and no by-hand", async () => {
191
+ const h = await driveSeam("refactor-first", change("src/a.test.ts", "modified"), {
192
+ seamAllow: ".gtd/**",
193
+ })
194
+ expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
195
+ expect(h.slices).toEqual([])
196
+ expect(h.names).not.toContain("seam.by-hand")
197
+ expect(h.names.some((n) => n.startsWith("refactor"))).toBe(false)
198
+ })
199
+
200
+ it("rests an unreadable test-file stray at seam.by-hand", async () => {
201
+ const h = await driveSeam(
202
+ "refactor-first",
203
+ { path: "src/a.test.ts", status: "modified", before: undefined, after: "b" },
204
+ { seamAllow: ".gtd/**" },
205
+ )
206
+ expect(h.slices).toEqual([])
207
+ expect(h.names).toContain("seam.by-hand")
208
+ })
209
+
210
+ it("rests unreadable strays at seam.by-hand and enters no refactor slice", async () => {
211
+ const h = await driveSeam("refactor-first", {
212
+ path: "src/a.ts",
213
+ status: "modified",
214
+ before: undefined,
215
+ after: "b",
216
+ })
217
+ expect(h.slices).toEqual([])
218
+ expect(h.names).toContain("seam.by-hand")
219
+ expect(h.outcome).toEqual({ reference: "abc", base: "c-base" })
220
+ })
221
+ })
222
+
223
+ describe("implement", () => {
224
+ const building = async (
225
+ reference: string | undefined,
226
+ tier?: "low" | "high",
227
+ amend?: boolean,
228
+ ) => {
229
+ const requests: StepRequest[] = []
230
+ installContext(
231
+ fixtureContext(
232
+ {},
233
+ {
234
+ step: (request: StepRequest) => {
235
+ requests.push(request)
236
+ return Promise.resolve()
237
+ },
238
+ pushScope: () => undefined,
239
+ popScope: () => undefined,
240
+ changesSince: () => [],
241
+ },
242
+ ),
243
+ )
244
+ await implement(reference, tier, amend)
245
+ const request = requests.find((r) => r.kind === "agent" && r.name === "building")
246
+ if (request?.kind !== "agent") throw new Error("no building turn")
247
+ return request.prompt
248
+ }
249
+
250
+ it("names the reference with a git show line", async () => {
251
+ expect(await building("abc")).toContain("git show abc")
252
+ })
253
+
254
+ it("carries no git show line without a reference", async () => {
255
+ expect(await building(undefined)).not.toContain("git show")
256
+ })
257
+
258
+ it("first lap renders the reference line and the fresh-write wording", async () => {
259
+ const prompt = await building("abc")
260
+ expect(prompt).toContain("git show abc")
261
+ expect(prompt).toContain("Implement them properly")
262
+ expect(prompt).toContain("write\n the change fresh")
263
+ expect(prompt).toContain("new release flag")
264
+ })
265
+
266
+ it("amend says the change exists, keeps the earlier flag and asks for no new one", async () => {
267
+ const prompt = await building(undefined, undefined, true)
268
+ expect(prompt).toContain("already exists")
269
+ expect(prompt).toContain("amend")
270
+ expect(prompt).toContain("Keep the release flag")
271
+ expect(prompt).toContain("if it added one")
272
+ expect(prompt).not.toContain("implement them properly")
273
+ expect(prompt).not.toContain("Implement them properly")
274
+ expect(prompt).not.toContain("write the change fresh")
275
+ expect(prompt).toContain("otherwise put")
276
+ })
277
+
278
+ it("amend on tier low says to add no release flag", async () => {
279
+ const prompt = await building(undefined, "low", true)
280
+ expect(prompt).toContain("Add no release flag")
281
+ expect(prompt).toContain("amend")
282
+ })
283
+ })
@@ -1,12 +1,13 @@
1
1
  import {
2
2
  changes,
3
+ head,
3
4
  human,
4
5
  judgeDiffShape,
5
6
  kept,
6
7
  removeScript,
7
8
  run,
8
9
  scope,
9
- seamGate,
10
+ slice,
10
11
  vars,
11
12
  type DiffShapeResult,
12
13
  type KeptTip,
@@ -15,7 +16,7 @@ import { healthy } from "../shared/health.js"
15
16
  import { coder, FEEDBACK, REQUIREMENTS } from "../shared/steps.js"
16
17
  import * as t from "../shared/text.js"
17
18
  import { defaults } from "../shared/vars.js"
18
- import { refactorFirst } from "./refactor.js"
19
+ import { refactorStrays, reviewedRefactor } from "./refactor.js"
19
20
  import type { RiskTier } from "./tier.js"
20
21
 
21
22
  /** Naive-pass retries after a `reject`; the next `reject` rests at `seam.steer`. */
@@ -40,9 +41,13 @@ export const naivePass = (rejection?: t.SeamRejection): Promise<void> =>
40
41
  system: t.builderSystem(),
41
42
  })
42
43
 
43
- export const implement = (reference: string, tier?: RiskTier): Promise<void> =>
44
+ export const implement = (
45
+ reference: string | undefined,
46
+ tier?: RiskTier,
47
+ amend?: boolean,
48
+ ): Promise<void> =>
44
49
  scope("implement", async () => {
45
- await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier), {
50
+ await t.agentWithSkills("building", t.implementBuildingPrompt(reference, tier, amend), {
46
51
  label: "Implementing",
47
52
  model: coder(),
48
53
  system: t.builderSystem(),
@@ -74,7 +79,6 @@ const decide = (): Promise<Decision> =>
74
79
  const shape = await judgeSeam(tip)
75
80
  if (shape.choice === "land") return { reference: tip.hash }
76
81
  if (shape.choice === "refactor-first") {
77
- await seamGate("approve", shape)
78
82
  return { reference: tip.hash, shape }
79
83
  }
80
84
  rejection = { strays: shape.strays, p: shape.judgment?.answers.shape?.p ?? 0 }
@@ -95,10 +99,17 @@ const decide = (): Promise<Decision> =>
95
99
  }
96
100
  })
97
101
 
98
- export const seamBuild = async (tier?: RiskTier): Promise<void> => {
102
+ export interface SeamOutcome {
103
+ /** The naive pass's kept tip: the first implement turn's reading material. */
104
+ readonly reference: string
105
+ /** The feature review's base: HEAD after the refactor on refactor-first, else the caller's base. */
106
+ readonly base: string
107
+ }
108
+
109
+ export const seamBuild = async (base: string): Promise<SeamOutcome> => {
99
110
  const { reference, shape } = await decide()
100
- if (shape !== undefined && !(await refactorFirst(shape, { reference }))) {
101
- // Unreadable strays were never judged, and no refactor runs: a person reads the diff.
111
+ if (shape === undefined) return { reference, base }
112
+ if (shape.unreadable.length > 0) {
102
113
  await scope("seam", () =>
103
114
  human("by-hand", {
104
115
  message: t.seamByHandMessage(shape.strays, shape.unreadable, reference),
@@ -106,6 +117,9 @@ export const seamBuild = async (tier?: RiskTier): Promise<void> => {
106
117
  acceptClean: true,
107
118
  }),
108
119
  )
120
+ return { reference, base }
109
121
  }
110
- await implement(reference, tier)
122
+ if (refactorStrays(shape).length === 0) return { reference, base }
123
+ await slice("refactor", () => reviewedRefactor(shape, { reference }))
124
+ return { reference, base: head() }
111
125
  }
@@ -1,6 +1,7 @@
1
+ import { readFileSync } from "node:fs"
1
2
  import { describe, expect, it } from "vitest"
2
- import type { StepRequest } from "../../flows/index.js"
3
- import { captureStep } from "../shared/text.fixture.js"
3
+ import { installContext, type ScopeOptions, type StepRequest } from "../../flows/index.js"
4
+ import { captureStep, fixtureContext } from "../shared/text.fixture.js"
4
5
  import * as steps from "./steps.js"
5
6
 
6
7
  const capture = (
@@ -45,6 +46,30 @@ describe("the feature workflow's own steps declare skills — a step's rendered
45
46
  expect(prompt).toContain("code-review-and-quality")
46
47
  })
47
48
 
49
+ it("collecting's prompt is the pinned feature fixture", async () => {
50
+ const prompt = agentPrompt(await capture(() => steps.collecting("capture-text"), "build"))
51
+ expect(prompt).toContain(
52
+ readFileSync(
53
+ new URL("../shared/__fixtures__/feature-collecting-prompt.txt", import.meta.url),
54
+ "utf8",
55
+ ),
56
+ )
57
+ })
58
+
59
+ it("refactorCollecting renders the open variant for the reply round, the closed one otherwise, whatever .gtd/REVIEW.md holds", async () => {
60
+ const at = (round: "reply" | "edits", present: boolean): Promise<StepRequest> =>
61
+ captureStep(() => steps.refactorCollecting("capture", round, ["src/a.ts"]), {
62
+ scope: "refactor.build",
63
+ read: (path) => (present && path === ".gtd/REVIEW.md" ? "# Review" : undefined),
64
+ })
65
+ const open = agentPrompt(await at("reply", false))
66
+ const closed = agentPrompt(await at("edits", true))
67
+ expect(open).toContain("exactly one `- A:` reply")
68
+ expect(open).not.toContain("already closed")
69
+ expect(closed).toContain("already closed")
70
+ expect(closed).not.toContain("exactly one `- A:` reply")
71
+ })
72
+
48
73
  it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
49
74
  const request = await capture(
50
75
  () =>
@@ -80,6 +105,33 @@ describe("the feature workflow's own steps declare skills — a step's rendered
80
105
  expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
81
106
  })
82
107
 
108
+ it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
109
+ const scopes: ScopeOptions[] = []
110
+ const steps_: StepRequest[] = []
111
+ const access = { write: ["src/a.ts", ".gtd/**"] }
112
+ installContext(
113
+ fixtureContext(
114
+ { scope: "refactor.build" },
115
+ {
116
+ step: (request) => {
117
+ steps_.push(request)
118
+ return Promise.resolve()
119
+ },
120
+ pushScope: (s) => void scopes.push(s),
121
+ popScope: () => undefined,
122
+ changesSince: () => [],
123
+ },
124
+ ),
125
+ )
126
+ try {
127
+ await steps.fixNits([], access)
128
+ } finally {
129
+ installContext(undefined)
130
+ }
131
+ expect(scopes).toEqual([{ name: "review.fix.nits", access }])
132
+ expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
133
+ })
134
+
83
135
  it("deferNotes rests at build.review.defer as a script writing .gtd/TODO.md", async () => {
84
136
  const request = await capture(
85
137
  () =>
@@ -1,7 +1,16 @@
1
- import { human, run } from "../../flows/index.js"
1
+ import { human, run, scope, type ScopeAccess } from "../../flows/index.js"
2
+ import type { CollectRound } from "../../workflow-kit/index.js"
2
3
  import * as t from "../shared/text.js"
3
4
  import { riskTierOn } from "./tier.js"
4
- import { ARCHITECTURE, coder, FEEDBACK, planner, REQUIREMENTS, REVIEW } from "../shared/steps.js"
5
+ import {
6
+ ARCHITECTURE,
7
+ coder,
8
+ FEEDBACK,
9
+ planner,
10
+ REFACTOR,
11
+ REQUIREMENTS,
12
+ REVIEW,
13
+ } from "../shared/steps.js"
5
14
 
6
15
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
7
16
  export const triage = (base: string): Promise<void> =>
@@ -73,13 +82,22 @@ export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<vo
73
82
  })
74
83
 
75
84
  /** Fix every `nit` note in one turn. */
76
- export const fixNits = (notes: readonly t.NoteInput[]): Promise<void> =>
77
- t.agentWithSkills("review.fix.nits.fixing", t.buildReviewFixNitsPrompt(notes), {
78
- label: "Fixing your nits",
79
- file: REVIEW,
80
- model: planner(),
81
- system: t.reviewerSystem(),
82
- })
85
+ export const fixNits = (
86
+ notes: readonly t.NoteInput[],
87
+ access?: ScopeAccess,
88
+ rules?: string,
89
+ ): Promise<void> => {
90
+ const fixing = (name: string): Promise<void> =>
91
+ t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
92
+ label: "Fixing your nits",
93
+ file: REVIEW,
94
+ model: planner(),
95
+ system: t.reviewerSystem(),
96
+ })
97
+ return access === undefined
98
+ ? fixing("review.fix.nits.fixing")
99
+ : scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
100
+ }
83
101
 
84
102
  /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
85
103
  export const collecting = (capture: string): Promise<void> =>
@@ -92,6 +110,24 @@ export const collecting = (capture: string): Promise<void> =>
92
110
  allowEmpty: true,
93
111
  })
94
112
 
113
+ /** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
114
+ export const refactorCollecting = (
115
+ capture: string,
116
+ round: CollectRound,
117
+ strays: readonly string[],
118
+ ): Promise<void> =>
119
+ t.agentWithSkills(
120
+ "review.collecting",
121
+ t.buildReviewCollectingPrompt(capture, { strays, round }),
122
+ {
123
+ label: "Collecting your refactor feedback",
124
+ file: REFACTOR,
125
+ model: planner(),
126
+ system: t.reviewerSystem(),
127
+ allowEmpty: true,
128
+ },
129
+ )
130
+
95
131
  /** `build.review.defer`: append `notes` to `.gtd/TODO.md` as one section; the step's landing is the commit. */
96
132
  export const deferNotes = (notes: readonly t.NoteInput[], round: string): Promise<void> =>
97
133
  run("review.defer", t.deferNotesScript(notes, round), {
@@ -77,6 +77,10 @@ describe("readRiskTier fences", () => {
77
77
  const text = "````\n```\n## Risk Tier\n\nlow\n````\n"
78
78
  expect(readRiskTier(text)).toBe("high")
79
79
  })
80
+
81
+ it("does not open a fence on a backtick line indented 4 spaces", () => {
82
+ expect(readRiskTier(" ```\n## Risk Tier\n\nlow\n")).toBe("low")
83
+ })
80
84
  })
81
85
 
82
86
  describe("readRiskTier fence closers", () => {
@@ -1,4 +1,5 @@
1
1
  import { mark, read, vars } from "../../flows/index.js"
2
+ import { markFenced } from "../shared/fences.js"
2
3
  import { REQUIREMENTS } from "../shared/steps.js"
3
4
 
4
5
  export type RiskTier = "low" | "high"
@@ -6,32 +7,13 @@ export type RiskTier = "low" | "high"
6
7
  /** GTD_FLAG_NAIVE_PASS, GTD_FLAG_RISK_TIER: both pinned at process start, so a mid-process flip never moves a process. */
7
8
  export const riskTierOn = (): boolean => vars.flag_naive_pass === "1" && vars.flag_risk_tier === "1"
8
9
 
9
- // An unclosed fence runs to end of file, so the tier reads `high` (fail-closed). A backtick
10
- // opener with a backtick in its info string still counts as a fence: deviates from CommonMark,
11
- // worst case is `high`.
12
- const markFenced = (text: string): readonly { line: string; fenced: boolean }[] => {
13
- let fence: string | undefined // the opener run, e.g. "````"
14
- return text.split(/\r?\n/).map((line) => {
15
- const run = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
16
- const fenced = fence !== undefined || run !== undefined
17
- if (fence === undefined) fence = run
18
- else if (
19
- run !== undefined &&
20
- run[0] === fence[0] &&
21
- run.length >= fence.length &&
22
- /^ {0,3}(`+|~+)[ \t]*$/.test(line)
23
- )
24
- fence = undefined
25
- return { line, fenced }
26
- })
27
- }
28
-
29
10
  const sectionLines = (
30
11
  text: string | undefined,
31
12
  title: string,
32
13
  ): readonly { line: string; fenced: boolean }[] => {
33
14
  if (text === undefined) return []
34
15
  const heading = new RegExp(`^##[ \\t]+${title}[ \\t]*$`, "i")
16
+ // An unclosed fence hides the rest, so the tier reads `high` (fail-closed).
35
17
  const lines = markFenced(text)
36
18
  const start = lines.findIndex((l) => !l.fenced && heading.test(l.line))
37
19
  if (start < 0) return []