@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
@@ -0,0 +1,97 @@
1
+ - A deliverable, not a chat reply — size follows the work; cut padding
2
+ - Lead with the answer; never circle back to restate it
3
+ - Flat, commanding sentences — commit to the claim, never hedge
4
+ - Everyday words; define an unavoidable term in five words or fewer, on
5
+ first use
6
+ - Bold carries the load: bold the claim, not the sentence around it —
7
+ the bold text alone must yield the full point and every risk
8
+ - **Never trim a risk, a number, a threshold, or a scoped condition to
9
+ save space — this outranks every other rule here**
10
+ - Ship the artifact bare — no lead-in, no sign-off
11
+ - Compressing is not dropping: three load-bearing parts ship as three,
12
+ each shorter, never as two
13
+ - One idea per block; break when the idea shifts
14
+ - Flag risk in one blunt line, never hedged prose; never narrate — do it
15
+
16
+ - Machine-read: its format contract outranks every style rule above —
17
+ keep every `##`/`###` heading, checkbox row, and marker line exactly
18
+ as specified. Never renumber or rename a heading; a parser reads
19
+ these literally and a violation refuses the turn
20
+ - Voice rules above govern only the prose between these elements
21
+
22
+ You are judging and classifying a round of review feedback.
23
+
24
+ - You are an autonomous coding agent
25
+ - This workflow's own state files are its private scratchpad, never
26
+ project code or documentation
27
+ - A footnote (`[^name]` plus its `[^name]:` definition) is a comment on
28
+ its exact anchor — fold it in as a mandatory concern described
29
+ against that anchor's hunk or paragraph, never flattened into a
30
+ whole-file remark
31
+ - A one-shot footnote needs no reply: fold it in and DELETE it in this
32
+ same turn — marker and definition together. Never re-read a footnote
33
+ already acted on. A one-shot footnote that asks a question becomes a
34
+ thread instead: rewrite its definition as `- H: <its body>` then
35
+ `- A: <your reply>`, keeping the marker
36
+ - A thread is a definition whose body is a list of `- H:`/`- A:`
37
+ entries. Handle each by its last entry:
38
+ - last is `- H:` and asks something — reply with exactly one
39
+ `- A: <reply>` entry and change nothing else for it
40
+ - last concludes or advises — fold it in like a one-shot footnote,
41
+ then delete marker and definition
42
+ - last is `- A:` — it waits on the human; leave it alone
43
+ - Never start a thread yourself, and never leave a thread whose last
44
+ entry is `- H:` — a turn that does is refused
45
+ - This turn writes `.gtd/REQUIREMENTS.md` (the folded concerns) and
46
+ replies inside `.gtd/REVIEW.md` (thread replies only; the file stays
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
53
+ - When the capture names an answering commit, carry each answered
54
+ question into `.gtd/REQUIREMENTS.md`'s `## Answered Questions` as
55
+ `### <the note>` plus its answer — the lap replaces `.gtd/REVIEW.md`
56
+ - Keep capture order, and carry each note's `(tier: reason)`
57
+ label into the concern folded from it
58
+ - Nit notes were already fixed: never re-raise a fixed nit as a concern
59
+ - Finish by running `gtd check review .gtd/REVIEW.md` and fix
60
+ what it reports
61
+
62
+ The raw review material is:
63
+
64
+ capture-text
65
+ It names a commit. Work from what you already reviewed if you
66
+ wrote today's review earlier this conversation; otherwise read
67
+ that commit's diff yourself first.
68
+
69
+ The round is actionable if any of these hold:
70
+
71
+ - The human left a note on `.gtd/REVIEW.md`. A note is a mandatory
72
+ concern below
73
+ - The human added a code comment this round. A comment run whose
74
+ first line starts `H:` is a THREAD: when its last entry is an
75
+ `H:` question, write exactly one `A:` comment line, same token
76
+ and indentation, directly below it — no fold-in; a concluded
77
+ thread is folded into the requirements and its comment lines
78
+ deleted. Any other comment, even a plain-prose one, is a one-shot
79
+ concern — describe it, and note the comment line itself is
80
+ transient: it must not survive the lap that satisfies it
81
+ - The human hand-edited non-comment code this round — no longer
82
+ a committed intent to build on, but a sketch like the entry
83
+ commit's own diff. Describe what it was reaching for; expect
84
+ the next lap to re-derive it from scratch, never call it final
85
+
86
+ Not actionable only when none of the above holds — nothing but an
87
+ approving remark, no code edit, no substantive note. Never invent
88
+ actionability, and never dismiss a real note or edit as approval.
89
+
90
+ - If actionable: write `.gtd/REQUIREMENTS.md` with an ordered
91
+ list of concerns, each PRODUCT or TECHNICAL — the shape
92
+ `design.triage` builds, one `## <heading>` per concern in
93
+ build order. Fold every note, comment, and hand-edit in under
94
+ its concern. Raise no open questions here — `design.triage`
95
+ owns that later. Then finish
96
+ - If not: finish without writing anything — changing nothing is
97
+ the sign-off
@@ -22,9 +22,21 @@ describe("the bundled workflow's access export", () => {
22
22
  "build.quality.test-audit": { write: [] },
23
23
  "build.quality.conventions": { write: [] },
24
24
  "build.quality.spec-challenge": { write: [] },
25
+ "refactor.build.review": { write: [".gtd/REVIEW.md"] },
26
+ "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
27
+ "refactor.build.quality.correctness": { write: [] },
28
+ "refactor.build.quality.ponytail-review": { write: [] },
29
+ "refactor.build.quality.conventions": { write: [] },
25
30
  })
26
31
  })
27
32
 
33
+ it("leaves the refactor review's fix scopes to the refactor's own scope option", () => {
34
+ expect(bundled).not.toHaveProperty(["refactor.build.review.fix.nits"])
35
+ expect(bundled).not.toHaveProperty(["refactor.build.review.fix.risks"])
36
+ expect(bundled["refactor.build.review"]).toEqual({ write: [".gtd/REVIEW.md"] })
37
+ expect(bundled["refactor.build.review.defer"]).toEqual({ write: [".gtd/TODO.md"] })
38
+ })
39
+
28
40
  it("keys one lens scope per qualityReviews entry", () => {
29
41
  const custom = access({ ...defaults, qualityReviews: " my-lens ,, correctness" })
30
42
  expect(
@@ -34,9 +46,27 @@ describe("the bundled workflow's access export", () => {
34
46
  ).toEqual(["build.quality.correctness", "build.quality.my-lens"])
35
47
  })
36
48
 
49
+ it("keys one refactor lens scope per refactorQualityReviews entry beside the refactor review's own", () => {
50
+ const custom = access({ ...defaults, refactorQualityReviews: " my-lens ,, correctness" })
51
+ expect(
52
+ Object.keys(custom)
53
+ .filter((k) => k.startsWith("refactor."))
54
+ .sort(),
55
+ ).toEqual([
56
+ "refactor.build.quality.correctness",
57
+ "refactor.build.quality.my-lens",
58
+ "refactor.build.review",
59
+ "refactor.build.review.defer",
60
+ ])
61
+ })
62
+
37
63
  it("keys only turn or script scopes", () => {
38
64
  // `build.review.defer` is a script scope: no turn, so no skills entry.
39
- const known = [...Object.keys(workflow.skills(defaults)), "build.review.defer"]
65
+ const known = [
66
+ ...Object.keys(workflow.skills(defaults)),
67
+ "build.review.defer",
68
+ "refactor.build.review.defer",
69
+ ]
40
70
  for (const key of Object.keys(bundled)) expect(known).toContain(key)
41
71
  })
42
72
  })
@@ -6,7 +6,8 @@ import { lensesOf, lensScopes } from "../../workflow-kit/index.js"
6
6
  // are restricted, and only on writes; no scope restricts reads. `refactor`'s
7
7
  // write restriction lives in its own `scope()` option, not here. A step's own
8
8
  // steering file is folded in per step, so it is not listed here. Build and fix
9
- // scopes carry no entry: no project layout is assumed.
9
+ // scopes carry no entry: no project layout is assumed. `refactor.build.review`'s
10
+ // fix scopes carry none either: `reviewedRefactor` sets their write limit at run time.
10
11
  export const access = (
11
12
  vars: Readonly<Record<string, string>>,
12
13
  ): Readonly<Record<string, ScopeAccess>> => ({
@@ -18,5 +19,10 @@ export const access = (
18
19
  // `{}` reopens what `build.review` restricted: the fixes edit code.
19
20
  "build.review.fix.nits": {},
20
21
  "build.review.fix.risks": {},
22
+ "refactor.build.review": { write: [".gtd/REVIEW.md"] },
23
+ "refactor.build.review.defer": { write: [".gtd/TODO.md"] },
21
24
  ...lensScopes("build.quality", lensesOf(vars.qualityReviews ?? ""), () => ({ write: [] })),
25
+ ...lensScopes("refactor.build.quality", lensesOf(vars.refactorQualityReviews ?? ""), () => ({
26
+ write: [],
27
+ })),
22
28
  })
@@ -0,0 +1,19 @@
1
+ // An unclosed fence runs to end of file. A backtick
2
+ // opener with a backtick in its info string still counts as a fence: deviates from CommonMark,
3
+ // worst case is `high`.
4
+ export const markFenced = (text: string): readonly { line: string; fenced: boolean }[] => {
5
+ let fence: string | undefined // the opener run, e.g. "````"
6
+ return text.split(/\r?\n/).map((line) => {
7
+ const run = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1]
8
+ const fenced = fence !== undefined || run !== undefined
9
+ if (fence === undefined) fence = run
10
+ else if (
11
+ run !== undefined &&
12
+ run[0] === fence[0] &&
13
+ run.length >= fence.length &&
14
+ /^ {0,3}(`+|~+)[ \t]*$/.test(line)
15
+ )
16
+ fence = undefined
17
+ return { line, fenced }
18
+ })
19
+ }
@@ -21,12 +21,12 @@ export const baseline = (name: string, label = "Checking the baseline"): Promise
21
21
 
22
22
  export type { EscalationCount } from "../../workflow-kit/index.js"
23
23
 
24
- const escalationSteps: kit.EscalationSteps = {
24
+ const escalationSteps = (rules?: string): kit.EscalationSteps => ({
25
25
  limit: 2,
26
- describe: describeEscalation,
26
+ describe: () => describeEscalation(rules),
27
27
  stop: escalate,
28
28
  exhausted: escalationExhausted,
29
- }
29
+ })
30
30
 
31
31
  /** Runs after an agent or human turn; refuses it by throwing (`refuse`). */
32
32
  export type Guard = () => void
@@ -38,20 +38,32 @@ export const guarded =
38
38
  guard?.()
39
39
  }
40
40
 
41
- const guardedSteps = (guard?: Guard, describeGuard?: Guard): kit.EscalationSteps => ({
42
- limit: escalationSteps.limit,
43
- describe: guarded(guarded(escalationSteps.describe, guard), describeGuard),
44
- stop: guarded(escalationSteps.stop, guard),
45
- exhausted: guarded(escalationSteps.exhausted, guard),
46
- })
41
+ const guardedSteps = (
42
+ guard?: Guard,
43
+ describeGuard?: Guard,
44
+ rests = true,
45
+ rules?: string,
46
+ ): kit.EscalationSteps => {
47
+ const steps = escalationSteps(rules)
48
+ return {
49
+ limit: steps.limit,
50
+ describe: guarded(guarded(steps.describe, guard), describeGuard),
51
+ stop: guarded(steps.stop, rests ? guard : undefined),
52
+ exhausted: guarded(steps.exhausted, rests ? guard : undefined),
53
+ }
54
+ }
47
55
 
48
56
  /**
49
57
  * Hand a red suite that repeated attempts could not fix to a person: an
50
58
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
51
59
  * on, the human is told the budget is spent instead.
52
60
  */
53
- export const escalation = (count: kit.EscalationCount, guard?: Guard): Promise<void> =>
54
- kit.escalation(count, guardedSteps(guard))
61
+ export const escalation = (
62
+ count: kit.EscalationCount,
63
+ guard?: Guard,
64
+ guardRests = true,
65
+ rules?: string,
66
+ ): Promise<void> => kit.escalation(count, guardedSteps(guard, undefined, guardRests, rules))
55
67
 
56
68
  /** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
57
69
  export const retryQuestion = (comparable: boolean): JudgeQuestion =>
@@ -100,6 +112,9 @@ export interface HealthOptions {
100
112
  readonly guard?: Guard | undefined
101
113
  /** Runs after the escalation's agent turn only; the human rests stay unguarded. */
102
114
  readonly describeGuard?: Guard | undefined
115
+ /** `false`: `guard` skips the human rests `health.stop` and `health.exhausted`. */
116
+ readonly guardRests?: boolean | undefined
117
+ readonly rules?: string | undefined
103
118
  }
104
119
 
105
120
  const runSuite = (options: HealthOptions): Promise<boolean> =>
@@ -123,7 +138,12 @@ export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}):
123
138
  fixesSoFar: options.fixesSoFar ?? 0,
124
139
  sameFailure,
125
140
  escalations: options.escalations ?? { rounds: 0 },
126
- escalation: guardedSteps(options.guard, options.describeGuard),
141
+ escalation: guardedSteps(
142
+ options.guard,
143
+ options.describeGuard,
144
+ options.guardRests,
145
+ options.rules,
146
+ ),
127
147
  })
128
148
 
129
149
  /**
@@ -56,6 +56,24 @@ describe("the shipped workflow factories", () => {
56
56
  })
57
57
  })
58
58
 
59
+ it("build seamAllow's default from the test-file globs, byte-equal to the shipped string", () => {
60
+ expect(defaults.seamAllow).toBe(
61
+ "**/*.test.*, **/*.spec.*, **/*.feature, **/tests/**, **/test/**, **/__tests__/**, .gtd/**",
62
+ )
63
+ })
64
+
65
+ it("default refactorQualityReviews to the three refactor lenses and let a factory option win", () => {
66
+ expect(defaults.refactorQualityReviews).toBe("correctness, ponytail-review, conventions")
67
+ expect(configure(feature({ refactorQualityReviews: "correctness" }))).toMatchObject({
68
+ ok: true,
69
+ build: { defaults: { refactorQualityReviews: "correctness" } },
70
+ })
71
+ expect(configure(workflows.rollout({ refactorQualityReviews: "" }))).toMatchObject({
72
+ ok: true,
73
+ build: { defaults: { refactorQualityReviews: "" } },
74
+ })
75
+ })
76
+
59
77
  it("build the shipped settings unchanged with no options", () => {
60
78
  for (const factory of [feature, fix, review]) {
61
79
  expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
@@ -19,6 +19,7 @@ export const ShippedOptions = Schema.Struct({
19
19
  refactorFlagPattern: setting,
20
20
  seamAllow: setting,
21
21
  qualityReviews: setting,
22
+ refactorQualityReviews: setting,
22
23
  testCommand: setting,
23
24
  plannerModel: setting,
24
25
  coderModel: setting,
@@ -248,11 +248,13 @@ export const footnoteRules = `- Leave a footnote anywhere: mark the exact spot w
248
248
  file changed in this process (\`#\`, \`--\` or \`;\` by language); the phone UI does not show them,
249
249
  and \`gtd check --open-threads\` lists the open ones`
250
250
 
251
- export const footnoteFoldIn = `- A footnote (\`[^name]\` plus its \`[^name]:\` definition) is a comment on
251
+ export const footnoteAnchor = `- A footnote (\`[^name]\` plus its \`[^name]:\` definition) is a comment on
252
252
  its exact anchor — fold it in as a mandatory concern described
253
253
  against that anchor's hunk or paragraph, never flattened into a
254
254
  whole-file remark
255
- - A one-shot footnote needs no reply: fold it in and DELETE it in this
255
+ `
256
+
257
+ export const footnoteFoldIn = `${footnoteAnchor}- A one-shot footnote needs no reply: fold it in and DELETE it in this
256
258
  same turn — marker and definition together. Never re-read a footnote
257
259
  already acted on. A one-shot footnote that asks a question becomes a
258
260
  thread instead: rewrite its definition as \`- H: <its body>\` then
@@ -39,6 +39,15 @@ describe("the bundled workflow's skills export", () => {
39
39
  "build.quality.test-audit",
40
40
  "build.quality.conventions",
41
41
  "build.quality.spec-challenge",
42
+ "refactor.build",
43
+ "refactor.build.health",
44
+ "refactor.build.fix.quality",
45
+ "refactor.build.review",
46
+ "refactor.build.review.fix.nits",
47
+ "refactor.build.review.fix.risks",
48
+ "refactor.build.quality.correctness",
49
+ "refactor.build.quality.ponytail-review",
50
+ "refactor.build.quality.conventions",
42
51
  ].sort(),
43
52
  )
44
53
  })
@@ -51,7 +60,6 @@ describe("the bundled workflow's skills export", () => {
51
60
  "build.quality",
52
61
  "implement.fix",
53
62
  "seam.judge",
54
- "seam.approve",
55
63
  "seam.steer",
56
64
  "seam.by-hand",
57
65
  ]) {
@@ -84,6 +92,17 @@ describe("the bundled workflow's skills export", () => {
84
92
  })
85
93
  })
86
94
 
95
+ it("keys one refactor lens scope per refactorQualityReviews entry", () => {
96
+ const custom = skills({ ...defaults, refactorQualityReviews: " my-lens ,, correctness" })
97
+ expect(
98
+ Object.keys(custom)
99
+ .filter((k) => k.startsWith("refactor.build.quality."))
100
+ .sort(),
101
+ ).toEqual(["refactor.build.quality.correctness", "refactor.build.quality.my-lens"])
102
+ const none = skills({ ...defaults, refactorQualityReviews: "" })
103
+ expect(Object.keys(none).filter((k) => k.startsWith("refactor.build.quality."))).toEqual([])
104
+ })
105
+
87
106
  it("loads one lens skill per quality lens: correctness its review skill, conventions and spec-challenge none, any other itself", () => {
88
107
  expect(bundled["build.quality.correctness"]).toEqual(["code-review-and-quality"])
89
108
  expect(bundled["build.quality.conventions"]).toEqual([])
@@ -9,35 +9,46 @@ import { lensesOf, lensScopes } from "../../workflow-kit/index.js"
9
9
  // `qualityReviews` entry, so the export is a function of the vars.
10
10
  export const skills = (
11
11
  vars: Readonly<Record<string, string>>,
12
- ): Readonly<Record<string, readonly string[]>> => ({
13
- design: ["spec-driven-development", "planning-and-task-breakdown"],
14
- architecture: ["api-and-interface-design", "documentation-and-adrs", "ponytail"],
15
- "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
16
- "packages.item": ["test-driven-development", "incremental-implementation"],
17
- "packages.item.fix.suite": ["debugging-and-error-recovery"],
18
- // `health` is shared code reached from both `packages.item` and `build`.
19
- "packages.item.health": ["debugging-and-error-recovery"],
20
- build: ["debugging-and-error-recovery"],
21
- "build.health": ["debugging-and-error-recovery"],
22
- "build.fix.quality": ["incremental-implementation", "code-simplification"],
23
- "build.review": ["code-review-and-quality"],
24
- "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
25
- "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
26
- // `fix`'s turn over the notes a review handed on.
27
- notes: ["incremental-implementation", "code-simplification"],
28
- // Every refactor agent turn loads `ponytail`.
29
- refactor: ["ponytail"],
30
- "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
31
- "refactor.health": ["ponytail", "debugging-and-error-recovery"],
32
- // Declared whether or not `naive_pass` is on, so a config keyed on them keeps
33
- // loading when the flag flips off.
34
- seam: ["incremental-implementation"],
35
- implement: ["test-driven-development", "incremental-implementation"],
36
- "implement.fix.suite": ["debugging-and-error-recovery"],
37
- "implement.health": ["debugging-and-error-recovery"],
38
- ...lensScopes(
39
- "build.quality",
40
- lensesOf(vars.qualityReviews ?? ""),
41
- (lens) => builtInLenses[lens]?.skills ?? [lens],
42
- ),
43
- })
12
+ ): Readonly<Record<string, readonly string[]>> => {
13
+ const build: Record<string, readonly string[]> = {
14
+ build: ["debugging-and-error-recovery"],
15
+ "build.health": ["debugging-and-error-recovery"],
16
+ "build.fix.quality": ["incremental-implementation", "code-simplification"],
17
+ "build.review": ["code-review-and-quality"],
18
+ "build.review.fix.nits": ["incremental-implementation", "code-simplification"],
19
+ "build.review.fix.risks": ["debugging-and-error-recovery", "incremental-implementation"],
20
+ }
21
+ return {
22
+ design: ["spec-driven-development", "planning-and-task-breakdown"],
23
+ architecture: ["api-and-interface-design", "documentation-and-adrs", "ponytail"],
24
+ "architecture.decompose": ["incremental-implementation", "planning-and-task-breakdown"],
25
+ "packages.item": ["test-driven-development", "incremental-implementation"],
26
+ "packages.item.fix.suite": ["debugging-and-error-recovery"],
27
+ // `health` is shared code reached from both `packages.item` and `build`.
28
+ "packages.item.health": ["debugging-and-error-recovery"],
29
+ // `fix`'s turn over the notes a review handed on.
30
+ notes: ["incremental-implementation", "code-simplification"],
31
+ // The refactor's own turns load `ponytail`; `refactor.build.*` carry the `build.*` lists.
32
+ ...build,
33
+ ...Object.fromEntries(Object.entries(build).map(([name, list]) => [`refactor.${name}`, list])),
34
+ refactor: ["ponytail"],
35
+ "refactor.fix.suite": ["ponytail", "debugging-and-error-recovery"],
36
+ "refactor.health": ["ponytail", "debugging-and-error-recovery"],
37
+ // Declared whether or not `naive_pass` is on, so a config keyed on them keeps
38
+ // loading when the flag flips off.
39
+ seam: ["incremental-implementation"],
40
+ implement: ["test-driven-development", "incremental-implementation"],
41
+ "implement.fix.suite": ["debugging-and-error-recovery"],
42
+ "implement.health": ["debugging-and-error-recovery"],
43
+ ...lensScopes(
44
+ "build.quality",
45
+ lensesOf(vars.qualityReviews ?? ""),
46
+ (lens) => builtInLenses[lens]?.skills ?? [lens],
47
+ ),
48
+ ...lensScopes(
49
+ "refactor.build.quality",
50
+ lensesOf(vars.refactorQualityReviews ?? ""),
51
+ (lens) => builtInLenses[lens]?.skills ?? [lens],
52
+ ),
53
+ }
54
+ }
@@ -1,4 +1,4 @@
1
- import { changesSince, env, human } from "../../flows/index.js"
1
+ import { changesSince, env, human, scope, type ScopeAccess } from "../../flows/index.js"
2
2
  import { removedAssertions } from "../../workflow-kit/index.js"
3
3
  import * as t from "./text.js"
4
4
 
@@ -12,6 +12,7 @@ export const ESCALATION = ".gtd/ESCALATION.md"
12
12
  export const REQUIREMENTS = ".gtd/REQUIREMENTS.md"
13
13
  export const ARCHITECTURE = ".gtd/ARCHITECTURE.md"
14
14
  export const REVIEW = ".gtd/REVIEW.md"
15
+ export const REFACTOR = ".gtd/REFACTOR.md"
15
16
  export const QUALITY = ".gtd/QUALITY.md"
16
17
 
17
18
  export const planner = (): string => env.plannerModel ?? ""
@@ -19,16 +20,16 @@ export const coder = (): string => env.coderModel ?? ""
19
20
 
20
21
  // ── Keeping the suite green ─────────────────────────────────────────────────
21
22
 
22
- export const fixCheck = (): Promise<void> =>
23
- t.agentWithSkills("fix", t.buildFixPrompt(), {
23
+ export const fixCheck = (rules?: string): Promise<void> =>
24
+ t.agentWithSkills("fix", t.buildFixPrompt(rules), {
24
25
  label: "Fixing the check",
25
26
  file: FEEDBACK,
26
27
  model: coder(),
27
28
  system: t.finisherSystem(),
28
29
  })
29
30
 
30
- export const describeEscalation = (): Promise<void> =>
31
- t.agentWithSkills("health.describe", t.healthDescribePrompt(), {
31
+ export const describeEscalation = (rules?: string): Promise<void> =>
32
+ t.agentWithSkills("health.describe", t.healthDescribePrompt(rules), {
32
33
  label: "Describing the escalation",
33
34
  file: FEEDBACK,
34
35
  model: coder(),
@@ -67,10 +68,10 @@ export const builtInLenses: Readonly<Record<string, BuiltInLens>> = {
67
68
  }
68
69
 
69
70
  /** One quality review through `lens`; each lens is its own scope, so its own conversation and skills. */
70
- export const reviewQuality = (lens: string): Promise<void> =>
71
+ export const reviewQuality = (lens: string, base?: string): Promise<void> =>
71
72
  t.agentWithSkills(
72
73
  `quality.${lens}.reviewing`,
73
- t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief),
74
+ t.buildQualityReviewingPrompt(lens, builtInLenses[lens]?.brief, base),
74
75
  {
75
76
  label: "Reviewing (one quality lens)",
76
77
  file: QUALITY,
@@ -80,8 +81,8 @@ export const reviewQuality = (lens: string): Promise<void> =>
80
81
  },
81
82
  )
82
83
 
83
- export const fixQuality = (): Promise<void> =>
84
- t.agentWithSkills("fix.quality.fixing", t.buildFixQualityPrompt(), {
84
+ export const fixQuality = (rules?: string): Promise<void> =>
85
+ t.agentWithSkills("fix.quality.fixing", t.buildFixQualityPrompt(rules), {
85
86
  label: "Fixing quality findings",
86
87
  file: QUALITY,
87
88
  model: coder(),
@@ -90,10 +91,14 @@ export const fixQuality = (): Promise<void> =>
90
91
  })
91
92
 
92
93
  /** Write `.gtd/REVIEW.md` over everything since `base`, carrying the answers of commit `carry` when given. */
93
- export const reviewing = (base: string, carry?: string): Promise<void> =>
94
+ export const reviewing = (
95
+ base: string,
96
+ carry?: string,
97
+ lap: t.ReviewRange = "first",
98
+ ): Promise<void> =>
94
99
  t.agentWithSkills(
95
100
  "review.reviewing",
96
- t.buildReviewReviewingPrompt(base, carry, removedAssertions(changesSince(base))),
101
+ t.buildReviewReviewingPrompt(base, lap, carry, removedAssertions(changesSince(base))),
97
102
  {
98
103
  label: "Reviewing",
99
104
  file: REVIEW,
@@ -105,14 +110,23 @@ export const reviewing = (base: string, carry?: string): Promise<void> =>
105
110
  )
106
111
 
107
112
  /** Fix the `Risk:`-marked notes the reviewer named; an empty turn means the risk was judged false. */
108
- export const fixRisks = (notes: readonly t.NoteInput[]): Promise<void> =>
109
- t.agentWithSkills("review.fix.risks.fixing", t.buildReviewFixRisksPrompt(notes), {
110
- label: "Fixing the reviewer's risks",
111
- file: REVIEW,
112
- model: planner(),
113
- system: t.reviewerSystem(),
114
- allowEmpty: true,
115
- })
113
+ export const fixRisks = (
114
+ notes: readonly t.NoteInput[],
115
+ access?: ScopeAccess,
116
+ rules?: string,
117
+ ): Promise<void> => {
118
+ const fixing = (name: string): Promise<void> =>
119
+ t.agentWithSkills(name, t.buildReviewFixRisksPrompt(notes, rules), {
120
+ label: "Fixing the reviewer's risks",
121
+ file: REVIEW,
122
+ model: planner(),
123
+ system: t.reviewerSystem(),
124
+ allowEmpty: true,
125
+ })
126
+ return access === undefined
127
+ ? fixing("review.fix.risks.fixing")
128
+ : scope({ name: "review.fix.risks", access }, () => fixing("fixing"))
129
+ }
116
130
 
117
131
  export const awaitReview = (
118
132
  base: string,
@@ -6,7 +6,7 @@ import {
6
6
  type StepRequest,
7
7
  } from "../../flows/index.js"
8
8
  import type { Guard } from "./health.js"
9
- import { handOffReview, REVIEW_ORIG } from "./tail.js"
9
+ import { handOffReview, qualityLap, REVIEW_ORIG, tail } from "./tail.js"
10
10
  import { fixtureContext } from "./text.fixture.js"
11
11
 
12
12
  afterEach(() => installContext(undefined))
@@ -206,3 +206,86 @@ describe("the hand-off review", () => {
206
206
  })
207
207
  })
208
208
  })
209
+
210
+ const prompts: string[] = []
211
+
212
+ describe("the quality lap's lenses", () => {
213
+ const lap = async (run: () => Promise<unknown>, vars: Record<string, string> = {}) => {
214
+ const log: string[] = []
215
+ prompts.length = 0
216
+ installContext(
217
+ fixtureContext(
218
+ { vars },
219
+ {
220
+ step: (request: StepRequest) => {
221
+ if (request.kind !== "restart") log.push(request.name)
222
+ if (request.kind === "agent") prompts.push(request.prompt)
223
+ return Promise.resolve()
224
+ },
225
+ pushScope: () => undefined,
226
+ popScope: () => undefined,
227
+ start: () => "START-SHA",
228
+ },
229
+ ),
230
+ )
231
+ await run()
232
+ return log
233
+ }
234
+
235
+ it("default to the qualityReviews setting", async () => {
236
+ const log = await lap(() => qualityLap(), { qualityReviews: "correctness, conventions" })
237
+ expect(log).toEqual(["quality.correctness.reviewing", "quality.conventions.reviewing"])
238
+ })
239
+
240
+ it("lenses override qualityReviews", async () => {
241
+ const log = await lap(() => qualityLap(undefined, ["ponytail-review"]), {
242
+ qualityReviews: "correctness, conventions",
243
+ })
244
+ expect(log).toEqual(["quality.ponytail-review.reviewing"])
245
+ })
246
+
247
+ it("an empty list runs no lens turn", async () => {
248
+ const log = await lap(() => qualityLap(undefined, []), { qualityReviews: "correctness" })
249
+ expect(log).toEqual([])
250
+ })
251
+
252
+ it("lensBase reaches every lens turn's prompt; absent, they name start()", async () => {
253
+ await lap(() => qualityLap(undefined, ["conventions", "correctness"], "BASE-SHA"))
254
+ expect(prompts).toHaveLength(2)
255
+ for (const prompt of prompts) expect(prompt).toContain("from `BASE-SHA`")
256
+ await lap(() => qualityLap(undefined, ["conventions", "correctness"]))
257
+ expect(prompts).toHaveLength(2)
258
+ for (const prompt of prompts) expect(prompt).toContain("from `START-SHA`")
259
+ })
260
+
261
+ it("tail threads lensBase to every lens turn", async () => {
262
+ await lap(() =>
263
+ tail(
264
+ false,
265
+ () => Promise.resolve(),
266
+ true,
267
+ undefined,
268
+ ["conventions", "correctness"],
269
+ true,
270
+ "BASE-SHA",
271
+ ),
272
+ )
273
+ expect(prompts).toHaveLength(2)
274
+ for (const prompt of prompts) expect(prompt).toContain("from `BASE-SHA`")
275
+ })
276
+
277
+ it("tail passes lenses to its lap and a guard after each lens turn", async () => {
278
+ let guards = 0
279
+ const log = await lap(() =>
280
+ tail(
281
+ false,
282
+ () => Promise.resolve(),
283
+ true,
284
+ () => void guards++,
285
+ ["conventions"],
286
+ ),
287
+ )
288
+ expect(log).toEqual(["quality.conventions.reviewing"])
289
+ expect(guards).toBe(1)
290
+ })
291
+ })