@pmelab/gtd 43.0.0 → 45.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 (46) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +33 -13
  3. package/dist/chunks/{reserved-COEMFEpy.mjs → reserved-ty1mtlnS.mjs} +155 -3
  4. package/dist/chunks/review-BmoH-9im.mjs +408 -0
  5. package/dist/chunks/{tail-C_xn8U7U.mjs → tail-CM3sVoAb.mjs} +3 -3
  6. package/dist/workflow-kit.mjs +22 -10
  7. package/dist/workflows/cleanup.mjs +2 -2
  8. package/dist/workflows/feature.mjs +6 -278
  9. package/dist/workflows/fix.mjs +2 -2
  10. package/dist/workflows/review.mjs +27 -7
  11. package/dist/workflows/rollout.mjs +1 -1
  12. package/dist/workflows.mjs +379 -66
  13. package/package.json +1 -1
  14. package/src/steering/FORMATS.md +4 -4
  15. package/src/workflow-kit/review.test.ts +80 -2
  16. package/src/workflow-kit/review.ts +45 -11
  17. package/src/workflows/feature/coverage.ts +1 -1
  18. package/src/workflows/feature/feature.test.ts +8 -0
  19. package/src/workflows/feature/feature.ts +2 -2
  20. package/src/workflows/feature/index.ts +1 -1
  21. package/src/workflows/feature/refactor.ts +1 -1
  22. package/src/workflows/feature/review-answers.feature +12 -3
  23. package/src/workflows/feature/seam.ts +1 -1
  24. package/src/workflows/feature/standards.feature +286 -0
  25. package/src/workflows/feature/steps.test.ts +2 -64
  26. package/src/workflows/feature/steps.ts +5 -41
  27. package/src/workflows/fix/fix.feature +20 -21
  28. package/src/workflows/index.ts +1 -1
  29. package/src/workflows/review/review-door.feature +158 -0
  30. package/src/workflows/review/review.test.ts +1 -73
  31. package/src/workflows/review/review.ts +34 -6
  32. package/src/workflows/shared/learn.test.ts +154 -0
  33. package/src/workflows/shared/learn.ts +206 -0
  34. package/src/workflows/shared/options.ts +1 -0
  35. package/src/workflows/{feature → shared}/review.test.ts +4 -8
  36. package/src/workflows/{feature → shared}/review.ts +23 -10
  37. package/src/workflows/shared/skills.test.ts +2 -0
  38. package/src/workflows/shared/skills.ts +2 -0
  39. package/src/workflows/shared/standards.test.ts +90 -0
  40. package/src/workflows/shared/standards.ts +156 -0
  41. package/src/workflows/shared/steps.test.ts +64 -2
  42. package/src/workflows/shared/steps.ts +40 -1
  43. package/src/workflows/shared/tail.ts +2 -2
  44. package/src/workflows/shared/text.test.ts +58 -1
  45. package/src/workflows/shared/text.ts +39 -1
  46. package/src/workflows/shared/vars.ts +2 -0
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from "node:fs"
2
2
  import { describe, expect, it } from "vitest"
3
- import { installContext, type ScopeOptions, type StepRequest } from "../../flows/index.js"
4
- import { captureStep, fixtureContext } from "../shared/text.fixture.js"
3
+ import type { StepRequest } from "../../flows/index.js"
4
+ import { captureStep } from "../shared/text.fixture.js"
5
5
  import * as steps from "./steps.js"
6
6
 
7
7
  const capture = (
@@ -49,68 +49,6 @@ describe("the feature workflow's own steps declare skills — a step's rendered
49
49
  expect(closed).toContain("already closed")
50
50
  expect(closed).not.toContain("exactly one `- A:` reply")
51
51
  })
52
-
53
- it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
54
- const request = await capture(
55
- () =>
56
- steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
57
- "build",
58
- )
59
- if (request.kind !== "agent") throw new Error("unreachable")
60
- expect(request.name).toBe("review.answer-review-questions")
61
- expect(request.options).toMatchObject({
62
- file: ".gtd/REVIEW.md",
63
- mode: "review",
64
- })
65
- expect(request.prompt).toContain("code-review-and-quality")
66
- expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
67
- expect(request.prompt).toContain("`A: ` line")
68
- expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
69
- })
70
-
71
- it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
72
- const request = await capture(
73
- () =>
74
- steps.fixNits([
75
- { id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
76
- { id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
77
- ]),
78
- "build",
79
- )
80
- if (request.kind !== "agent") throw new Error("unreachable")
81
- expect(request.name).toBe("review.fix.nits.fixing")
82
- expect(request.prompt).toContain("incremental-implementation, code-simplification")
83
- expect(request.prompt).toContain("typo")
84
- expect(request.prompt).toContain("semicolon")
85
- expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
86
- })
87
-
88
- it("fixNits with access runs in a review.fix.nits scope, the step named fixing inside it", async () => {
89
- const scopes: ScopeOptions[] = []
90
- const steps_: StepRequest[] = []
91
- const access = { write: ["src/a.ts", ".gtd/**"] }
92
- installContext(
93
- fixtureContext(
94
- { scope: "refactor.build" },
95
- {
96
- step: (request) => {
97
- steps_.push(request)
98
- return Promise.resolve()
99
- },
100
- pushScope: (s) => void scopes.push(s),
101
- popScope: () => undefined,
102
- changesSince: () => [],
103
- },
104
- ),
105
- )
106
- try {
107
- await steps.fixNits([], access)
108
- } finally {
109
- installContext(undefined)
110
- }
111
- expect(scopes).toEqual([{ name: "review.fix.nits", access }])
112
- expect(steps_.map((r) => ("name" in r ? r.name : ""))).toEqual(["fixing"])
113
- })
114
52
  })
115
53
 
116
54
  describe("the feature workflow's planning steps run on the planner model", () => {
@@ -1,8 +1,11 @@
1
- import { human, scope, type ScopeAccess } from "../../flows/index.js"
1
+ import { human } from "../../flows/index.js"
2
2
  import type { CollectRound } from "../../workflow-kit/index.js"
3
3
  import * as t from "../shared/text.js"
4
4
  import { riskTierOn } from "./tier.js"
5
- import { planner, REFACTOR, REQUIREMENTS, REVIEW } from "../shared/steps.js"
5
+ import { planner, REFACTOR, REQUIREMENTS } from "../shared/steps.js"
6
+
7
+ // Kept on the published feature entry.
8
+ export { answerReviewQuestions, collecting, fixNits } from "../shared/steps.js"
6
9
 
7
10
  /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
8
11
  export const triage = (base: string): Promise<void> =>
@@ -23,45 +26,6 @@ export const answerProductQuestions = (): Promise<void> =>
23
26
  acceptClean: true,
24
27
  })
25
28
 
26
- /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
27
- export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
28
- t.agentWithSkills("review.answer-review-questions", t.buildReviewAnswerQuestionsPrompt(notes), {
29
- label: "Answering your questions",
30
- file: REVIEW,
31
- mode: "review",
32
- model: planner(),
33
- system: t.reviewerSystem(),
34
- })
35
-
36
- /** Fix every `nit` note in one turn. */
37
- export const fixNits = (
38
- notes: readonly t.NoteInput[],
39
- access?: ScopeAccess,
40
- rules?: string,
41
- ): Promise<void> => {
42
- const fixing = (name: string): Promise<void> =>
43
- t.agentWithSkills(name, t.buildReviewFixNitsPrompt(notes, rules), {
44
- label: "Fixing your nits",
45
- file: REVIEW,
46
- model: planner(),
47
- system: t.reviewerSystem(),
48
- })
49
- return access === undefined
50
- ? fixing("review.fix.nits.fixing")
51
- : scope({ name: "review.fix.nits", access }, () => fixing("fixing"))
52
- }
53
-
54
- /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
55
- export const collecting = (capture: string, round?: CollectRound): Promise<void> =>
56
- t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture, undefined, round), {
57
- label: "Collecting your feedback",
58
- file: REQUIREMENTS,
59
- mode: "qa",
60
- model: planner(),
61
- system: t.reviewerSystem(),
62
- allowEmpty: true,
63
- })
64
-
65
29
  /** Turn the refactor review round `capture` describes into `.gtd/REFACTOR.md`, or change nothing. */
66
30
  export const refactorCollecting = (
67
31
  capture: string,
@@ -1,8 +1,7 @@
1
- Feature: fix fixes the notes review hands it
1
+ Feature: fix fixes the notes a hand-off leaves it
2
2
 
3
- `review` and `fix` meet only in `.gtd/REVIEW.md`: a review the human leaves
4
- a note in ends with the reviewer's copy kept beside it, and `gtd door fix`
5
- picks both up.
3
+ A hand-off is `.gtd/REVIEW.md` with the human's notes beside the reviewer's
4
+ own `.gtd/REVIEW.orig.md`; `gtd door fix` picks both up.
6
5
 
7
6
  Background:
8
7
  Given the shipped workflows, as gtd install scaffolds them
@@ -13,10 +12,8 @@ Feature: fix fixes the notes review hands it
13
12
  """
14
13
  And the working tree is committed as "feat: add calculator"
15
14
 
16
- Scenario: a note left in review is fixed, and both review files are removed
17
- Given step "review-gate.check" changes nothing
18
- And step "build.health.check" changes nothing
19
- And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
15
+ Scenario: a handed-off note is fixed, and both review files are removed
16
+ Given a file ".gtd/REVIEW.orig.md" with:
20
17
  """
21
18
  # Review: abc1234
22
19
 
@@ -27,11 +24,7 @@ Feature: fix fixes the notes review hands it
27
24
  - [ ] ./src/calc.ts#1-1
28
25
  new add function
29
26
  """
30
- When gtd opens door "review" with "HEAD~1"
31
- And the process is driven
32
- Then the process rests at "build.review.await-review"
33
-
34
- Given a file ".gtd/REVIEW.md" with:
27
+ And a file ".gtd/REVIEW.md" with:
35
28
  """
36
29
  # Review: abc1234
37
30
 
@@ -42,21 +35,27 @@ Feature: fix fixes the notes review hands it
42
35
  - [ ] ./src/calc.ts#1-1
43
36
  new add function — rename it to sum
44
37
  """
45
- And step "build.review.handing-off" runs its script
46
- When gtd lands
47
- And the process is driven
48
- Then the process rests at "idle"
49
- And ".gtd/REVIEW.orig.md" contains "new add function"
38
+ And the working tree is committed as "chore: hand off a review"
39
+ And step "build.health.check" changes nothing
40
+ And step "build.review.reviewing" writes ".gtd/REVIEW.md" with:
41
+ """
42
+ # Review: abc1234
43
+
44
+ <!-- base: 0000000 -->
45
+
46
+ ## calc
50
47
 
51
- Given step "notes.fixing" writes "src/calc.ts" with:
48
+ - [ ] ./src/calc.ts#1-1
49
+ new add function
50
+ """
51
+ And step "notes.fixing" writes "src/calc.ts" with:
52
52
  """
53
53
  export const sum = (a: number, b: number) => a + b
54
54
  """
55
55
  And step "notes.closing" runs its script
56
56
  When gtd opens door "fix"
57
57
  Then the resting step's content contains "rename it to sum"
58
- # The fix goes to its own review, which the same scripted reviewer writes
59
- # afresh — the handed-off review itself is gone, as the reviewer's copy shows.
58
+ # The fix goes to its own review, which the scripted reviewer writes afresh.
60
59
  When the process is driven
61
60
  Then the process rests at "build.review.await-review"
62
61
  And the commit subjects end with:
@@ -32,7 +32,7 @@ export {
32
32
  type BuildTailOptions,
33
33
  type FeedbackTarget,
34
34
  type ReviewOutcome,
35
- } from "./feature/review.js"
35
+ } from "./shared/review.js"
36
36
  export * from "./feature/feature.js"
37
37
  export { base, review, reviewFlow } from "./review/review.js"
38
38
  export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
@@ -0,0 +1,158 @@
1
+ @inmem
2
+ Feature: the review door fixes its notes in the same process
3
+
4
+ `gtd door review` runs the feature's review over everything since its base:
5
+ the triaged quality lap writes `.gtd/REVIEW.md`, the human answers each note,
6
+ and what they ask for is fixed in place under the health loop before the
7
+ review comes back. A sign-off ends the process; nothing is left for `fix`.
8
+
9
+ Background:
10
+ Given the shipped workflows, as gtd install scaffolds them
11
+ And an environment variable "GTD_QUALITYREVIEWS" set to "correctness"
12
+ And a file "src/calc.ts" with:
13
+ """
14
+ export const add = (a: number, b: number) => a + b
15
+ """
16
+ And the working tree is committed as "feat: add calculator"
17
+ And step "review-gate.check" changes nothing
18
+ And step "build.health.check" changes nothing
19
+ And step "build.quality.correctness.reviewing" writes ".gtd/QUALITY.md" with:
20
+ """
21
+ # Quality
22
+
23
+ ## add has no test
24
+
25
+ Add a test for add.
26
+ """
27
+ And step "build.quality.sorting" runs its script
28
+ And judge step "build.quality.triage" answers:
29
+ """
30
+ [{"id":"Q1","answer":"design","p":0.9}]
31
+ """
32
+ And step "build.review.composing" runs its script
33
+ And step "build.review.closing" runs its script
34
+
35
+ Scenario: the review is composed from the lap's notes, with answer boxes
36
+ When gtd opens door "review" with "HEAD~1"
37
+ And the process is driven
38
+ Then the process rests at "build.review.await-review"
39
+ And the commit subjects end with:
40
+ """
41
+ gtd(human): review-gate.check
42
+ gtd(check): review-gate.check → build.quality.correctness.reviewing
43
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
44
+ gtd(judge): build.quality.triage → build.quality.sorting
45
+ gtd(check): build.quality.sorting → build.review.composing
46
+ gtd(check): build.review.composing → build.review.await-review
47
+ """
48
+ And ".gtd/REVIEW.md" contains:
49
+ """
50
+ ## add has no test
51
+
52
+ Add a test for add.
53
+
54
+ Tick exactly one:
55
+
56
+ - [ ] fix as suggested
57
+ - [ ] won't fix
58
+ - [ ] _your answer_
59
+ """
60
+
61
+ Scenario: a won't-fix signs off, and no review is left for fix
62
+ Given judge step "build.review.standards.judge" answers:
63
+ """
64
+ [{"id":"standard-1","answer":"no","p":0.9}]
65
+ """
66
+ When gtd opens door "review" with "HEAD~1"
67
+ And the process is driven
68
+ And the text "- [ ] won't fix" in ".gtd/REVIEW.md" is replaced with "- [x] won't fix"
69
+ And gtd lands
70
+ And the process is driven
71
+ Then the process rests at "idle"
72
+ And ".gtd/REVIEW.md" does not exist
73
+ And ".gtd/REVIEW.orig.md" does not exist
74
+
75
+ Scenario: a note ticked fix as suggested is fixed in place, then reviewed again
76
+ Given step "build.review.fix.nits.fixing" writes "src/calc.test.ts" with:
77
+ """
78
+ it("adds", () => {})
79
+ """
80
+ When gtd opens door "review" with "HEAD~1"
81
+ And the process is driven
82
+ And the text "- [ ] fix as suggested" in ".gtd/REVIEW.md" is replaced with "- [x] fix as suggested"
83
+ And gtd lands
84
+ And the process is driven
85
+ Then the process rests at "build.review.await-review"
86
+ And the commit subjects end with:
87
+ """
88
+ gtd(human): build.review.await-review → build.review.fix.nits.fixing
89
+ gtd(agent): build.review.fix.nits.fixing → build.health.check
90
+ gtd(check): build.health.check → build.review.closing
91
+ gtd(check): build.review.closing → build.review.composing
92
+ gtd(check): build.review.composing → build.review.await-review
93
+ """
94
+ And ".gtd/REVIEW.md" contains "## Fixed from your notes"
95
+ And "src/calc.test.ts" contains "adds"
96
+ When gtd lands
97
+ And the process is driven
98
+ Then the process rests at "idle"
99
+ And ".gtd/REVIEW.md" does not exist
100
+ And ".gtd/REVIEW.orig.md" does not exist
101
+
102
+ Scenario: an own answer asking for a change is fixed in place, then reviewed again
103
+ Given judge step "build.review.standards.judge" answers:
104
+ """
105
+ [{"id":"standard-1","answer":"no","p":0.9}]
106
+ """
107
+ And judge step "build.review.triage" answers:
108
+ """
109
+ [{"id":"answer-1","answer":"edit","p":0.9}]
110
+ """
111
+ And step "build.review.collecting" writes ".gtd/REQUIREMENTS.md" with:
112
+ """
113
+ # Requirements
114
+
115
+ ## Test add with negatives
116
+
117
+ add(-1, -2) returns -3.
118
+ """
119
+ And step "feedback.fixing" is prompted with ".gtd/REQUIREMENTS.md"
120
+ And step "feedback.fixing" writes "src/calc.test.ts" with:
121
+ """
122
+ it("adds negatives", () => {})
123
+ """
124
+ And step "re-unwind" runs its script
125
+ And step "feedback.closing" runs its script
126
+ When gtd opens door "review" with "HEAD~1"
127
+ And the process is driven
128
+ And the text "- [ ] _your answer_" in ".gtd/REVIEW.md" is replaced with "- [x] test negatives too"
129
+ And gtd lands
130
+ And the process is driven
131
+ Then the process rests at "build.review.await-review"
132
+ And the commit subjects end with:
133
+ """
134
+ gtd(human): build.review.await-review → build.review.standards.judge
135
+ gtd(judge): build.review.standards.judge → build.review.triage
136
+ gtd(judge): build.review.triage → build.review.closing
137
+ gtd(check): build.review.closing → build.review.collecting
138
+ gtd(agent): build.review.collecting → re-unwind
139
+ gtd(check): re-unwind → feedback.fixing
140
+ gtd(agent): feedback.fixing → feedback.closing
141
+ gtd(check): feedback.closing → build.health.check
142
+ gtd(check): build.health.check → build.quality.correctness.reviewing
143
+ gtd(agent): build.quality.correctness.reviewing → build.quality.triage
144
+ gtd(judge): build.quality.triage → build.quality.sorting
145
+ gtd(check): build.quality.sorting → build.review.composing
146
+ gtd(check): build.review.composing → build.review.await-review
147
+ """
148
+ And ".gtd/REQUIREMENTS.md" does not exist
149
+ And "src/calc.test.ts" contains "adds negatives"
150
+ And ".gtd/REVIEW.md" does not contain "test negatives too"
151
+
152
+ Scenario: a leftover requirements file is refused before any step
153
+ Given a file ".gtd/REQUIREMENTS.md" with:
154
+ """
155
+ # Requirements
156
+ """
157
+ And the working tree is committed as "chore: leftover requirements"
158
+ Then opening door "review" is refused with ".gtd/REQUIREMENTS.md is left over"
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest"
2
- import { runsItsScript, testRepo, type TestRepo } from "../../testkit/index.js"
2
+ import { testRepo } from "../../testkit/index.js"
3
3
  import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
4
  import { reviewDoc } from "../shared/config.fixture.js"
5
5
  import { base } from "./review.js"
@@ -7,79 +7,7 @@ import { base } from "./review.js"
7
7
  const REVIEW = ".gtd/REVIEW.md"
8
8
  const ORIG = ".gtd/REVIEW.orig.md"
9
9
 
10
- /** A repository with one commit to review, the review run until the human holds `.gtd/REVIEW.md`. */
11
- const reviewed = async (): Promise<TestRepo> => {
12
- const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
13
- repo.write("src/calc.ts", "export const add = (a: number, b: number) => a + b\n")
14
- repo.commit("feat: add calculator")
15
- await repo.door("review", "HEAD~1")
16
- const rest = await repo.drive({
17
- "review-gate.check": () => undefined,
18
- "build.health.check": () => undefined,
19
- "build.review.reviewing": (beat, r) => {
20
- expect(beat.file).toBe(REVIEW)
21
- r.write(REVIEW, reviewDoc("new add function"))
22
- },
23
- })
24
- expect(rest).toMatchObject({ state: "build.review.await-review", actor: "human" })
25
- return repo
26
- }
27
-
28
10
  describe("the review workflow", () => {
29
- it("checks, reviews and hands the review to the human", async () => {
30
- const repo = await reviewed()
31
- expect(repo.subjects().slice(2)).toEqual([
32
- "gtd(human): review-gate.check",
33
- "gtd(check): review-gate.check → build.health.check",
34
- "gtd(check): build.health.check → build.review.reviewing",
35
- "gtd(agent): build.review.reviewing → build.review.await-review",
36
- ])
37
- })
38
-
39
- it("closes a review the human signs off", async () => {
40
- const repo = await reviewed()
41
- repo.write(REVIEW, reviewDoc("new add function").replace("- [ ]", "- [x]"))
42
- await repo.land()
43
- const rest = await repo.drive({ "build.review.closing": runsItsScript })
44
- expect(rest.state).toBe("idle")
45
- expect(repo.subjects().slice(-2)).toEqual([
46
- "gtd(human): build.review.await-review → build.review.closing",
47
- "gtd(check): build.review.closing → idle",
48
- ])
49
- expect(repo.read(REVIEW)).toBeUndefined()
50
- })
51
-
52
- it("never lands a review the review format rejects", async () => {
53
- const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
54
- repo.write("src/calc.ts", "export const add = (a: number, b: number) => a + b\n")
55
- repo.commit("feat: add calculator")
56
- await repo.door("review", "HEAD~1")
57
- await expect(
58
- repo.drive({
59
- "review-gate.check": () => undefined,
60
- "build.health.check": () => undefined,
61
- "build.review.reviewing": (_, r) => r.write(REVIEW, "not a review\n"),
62
- }),
63
- ).rejects.toThrow(/validating \.gtd\/REVIEW\.md/)
64
- expect(repo.subjects().at(-1)).toBe("gtd(check): build.health.check → build.review.reviewing")
65
- })
66
-
67
- it("ends with the review and the reviewer's copy kept when the human leaves a note", async () => {
68
- const repo = await reviewed()
69
- repo.write(REVIEW, reviewDoc("new add function — rename it to sum"))
70
- await repo.land()
71
- const rest = await repo.drive({
72
- "build.review.handing-off": runsItsScript,
73
- })
74
- expect(rest.state).toBe("idle")
75
- expect(repo.subjects().slice(-2)).toEqual([
76
- "gtd(human): build.review.await-review → build.review.handing-off",
77
- "gtd(check): build.review.handing-off → idle",
78
- ])
79
- expect(repo.read(REVIEW)).toContain("rename it to sum")
80
- expect(repo.read(ORIG)).toBe(reviewDoc("new add function"))
81
- })
82
-
83
11
  it("refuses a pending hand-off before requesting any step, the gate's check included", async () => {
84
12
  const repo = testRepo({
85
13
  workflow: CONFIG_SCAFFOLD,
@@ -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(
@@ -0,0 +1,154 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest"
2
+
3
+ const state = vi.hoisted(() => ({
4
+ files: {} as Record<string, string | undefined>,
5
+ vars: { standardMinP: "0.8" } as Record<string, string>,
6
+ answers: {} as Record<string, { answer: string; p: number } | undefined>,
7
+ judged: [] as string[][],
8
+ written: {} as Record<string, string>,
9
+ }))
10
+
11
+ vi.mock("../../flows/index.js", async (original) => ({
12
+ ...(await original<typeof import("../../flows/index.js")>()),
13
+ read: (path: string) => state.files[path],
14
+ vars: state.vars,
15
+ judge: async (_: string, spec: { questions: { id: string }[] }) => {
16
+ state.judged.push(spec.questions.map((q) => q.id))
17
+ return { answers: state.answers, truncated: [] }
18
+ },
19
+ run: async (_: string, script: string) => {
20
+ const path = /> '([^']+)'/.exec(script)?.[1] ?? ""
21
+ const content = /printf '%s' '((?:[^']|'\\'')*)'/.exec(script)?.[1] ?? ""
22
+ state.written[path] = content.replaceAll("'\\''", "'")
23
+ },
24
+ }))
25
+
26
+ import type { ReviewDecision } from "../../steering/index.js"
27
+ import { learnStandards } from "./learn.js"
28
+
29
+ const STANDARDS = ".gtd/STANDARDS.md"
30
+
31
+ const file = (...lines: string[]): string =>
32
+ [
33
+ "# Standards",
34
+ "",
35
+ "Kept from your review answers for every agent turn. One standard per bullet.",
36
+ "",
37
+ ...lines,
38
+ "",
39
+ ].join("\n")
40
+
41
+ const answer = (anchor: string, text: string): ReviewDecision => ({
42
+ anchor,
43
+ body: "",
44
+ choice: "answer",
45
+ text,
46
+ })
47
+
48
+ const yes = { answer: "yes", p: 0.9 }
49
+ const no = { answer: "no", p: 0.9 }
50
+ const kept = () => state.written[STANDARDS]
51
+
52
+ beforeEach(() => {
53
+ state.files = { ".gtd/REVIEW.md": "# Review\n" }
54
+ state.vars.standardMinP = "0.8"
55
+ state.answers = {}
56
+ state.judged = []
57
+ state.written = {}
58
+ })
59
+
60
+ describe("learnStandards", () => {
61
+ it("asks a later candidate about each earlier one, and holds a contradiction for the human", async () => {
62
+ state.answers = { "standard-1": yes, "standard-2": yes, "standard-2.contradicts-new-1": yes }
63
+ await learnStandards([answer("A", "Use classes."), answer("B", "Never use classes.")])
64
+ expect(state.judged[0]).toContain("standard-2.contradicts-new-1")
65
+ expect(kept()).toBe(
66
+ file(
67
+ "- Use classes. (re: A)",
68
+ "",
69
+ "## Undecided",
70
+ "",
71
+ "- Never use classes. (re: B)",
72
+ " - contradicts: Use classes. (re: A)",
73
+ ),
74
+ )
75
+ })
76
+
77
+ it("raises a contradiction ruled out below the threshold", async () => {
78
+ state.files[STANDARDS] = file("- Old.")
79
+ state.answers = { "standard-1": yes, "standard-1.contradicts-1": { answer: "no", p: 0.5 } }
80
+ await learnStandards([answer("A", "New.")])
81
+ expect(kept()).toBe(
82
+ file("- Old.", "", "## Undecided", "", "- New. (re: A)", " - contradicts: Old."),
83
+ )
84
+ })
85
+
86
+ it("keeps one the judge confidently calls compatible", async () => {
87
+ state.files[STANDARDS] = file("- Old.")
88
+ state.answers = { "standard-1": yes, "standard-1.contradicts-1": no }
89
+ await learnStandards([answer("A", "New.")])
90
+ expect(kept()).toBe(file("- Old.", "- New. (re: A)"))
91
+ })
92
+
93
+ it("asks no judge when standardMinP is blank", async () => {
94
+ state.vars.standardMinP = ""
95
+ await learnStandards([answer("A", "New.")])
96
+ expect(state.judged).toEqual([])
97
+ expect(state.written).toEqual({})
98
+ })
99
+
100
+ it("drops a replaced standard from another open conflict", async () => {
101
+ state.files[STANDARDS] = file(
102
+ "- O.",
103
+ "",
104
+ "## Undecided",
105
+ "",
106
+ "- A.",
107
+ " - contradicts: O.",
108
+ "- B.",
109
+ " - contradicts: O.",
110
+ )
111
+ // The conflict note's title hashes its new standard, "A.".
112
+ await learnStandards([
113
+ { anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
114
+ ])
115
+ expect(kept()).toBe(file("- A.", "", "## Undecided", "", "- B."))
116
+ })
117
+ })
118
+
119
+ describe("learnStandards, from a won't-fix", () => {
120
+ it("states the finding, not gtd's banner", async () => {
121
+ state.answers = { "standard-1": yes }
122
+ await learnStandards([
123
+ {
124
+ anchor: "Slow loop",
125
+ // As the review parser hands it over: paragraphs joined by a space.
126
+ body: "_The fixer left this unresolved._ The loop is quadratic.",
127
+ choice: "wontfix",
128
+ text: "",
129
+ },
130
+ ])
131
+ expect(kept()).toBe(file("- Won't fix: Slow loop — The loop is quadratic."))
132
+ })
133
+
134
+ it("replaces an old standard hand-edited in case only", async () => {
135
+ state.files[STANDARDS] = file(
136
+ "- use classes.",
137
+ "",
138
+ "## Undecided",
139
+ "",
140
+ "- A.",
141
+ " - contradicts: Use classes.",
142
+ )
143
+ await learnStandards([
144
+ { anchor: "Standard conflict 01248e26", body: "", choice: "fix", text: "" },
145
+ ])
146
+ expect(kept()).toBe(file("- A."))
147
+ })
148
+
149
+ it("does not learn again what a formatter rewrote", async () => {
150
+ state.files[STANDARDS] = file("- Prefer _named_ exports. (re: A)")
151
+ await learnStandards([answer("A", "Prefer *named* exports.")])
152
+ expect(state.judged).toEqual([])
153
+ })
154
+ })