@pmelab/gtd 23.1.0 → 24.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 (50) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +13 -6
  3. package/dist/gtd.bundle.mjs +795 -1051
  4. package/dist/workflows.mjs +1952 -1725
  5. package/package.json +2 -1
  6. package/skills/authoring/SKILL.md +5 -4
  7. package/src/flows/factory.ts +5 -1
  8. package/src/flows/runtime.ts +3 -0
  9. package/src/workflow-kit/health.test.ts +30 -0
  10. package/src/workflow-kit/health.ts +86 -0
  11. package/src/workflow-kit/index.ts +7 -0
  12. package/src/workflow-kit/quality.test.ts +17 -0
  13. package/src/workflow-kit/quality.ts +45 -0
  14. package/src/workflow-kit/review.ts +324 -0
  15. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  16. package/src/workflows/feature/feature.test.ts +38 -0
  17. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  18. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  19. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  20. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  21. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  22. package/src/workflows/feature/review.ts +106 -0
  23. package/src/workflows/feature/steps.test.ts +82 -0
  24. package/src/workflows/feature/steps.ts +92 -0
  25. package/src/workflows/fix/fix.test.ts +94 -0
  26. package/src/workflows/fix/fix.ts +52 -0
  27. package/src/workflows/index.ts +44 -2
  28. package/src/workflows/review/review.test.ts +43 -0
  29. package/src/workflows/review/review.ts +31 -0
  30. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  31. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  32. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  33. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  34. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  35. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  36. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  37. package/src/workflows/shared/scenarios.ts +35 -0
  38. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  39. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  40. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  41. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  42. package/src/workflows/shared/tail.test.ts +205 -0
  43. package/src/workflows/shared/tail.ts +135 -0
  44. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  45. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  46. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  47. package/src/workflows/review.ts +0 -362
  48. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  49. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  50. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,38 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type StepRequest } from "../../flows/index.js"
3
+ import { fixtureContext } from "../shared/text.fixture.js"
4
+ import { ordinaryStart } from "./feature.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ const FILES: Readonly<Record<string, string>> = {
9
+ ".gtd/REVIEW.md": "# Review: abc1234\n",
10
+ ".gtd/REVIEW.orig.md": "# Review: abc1234\n",
11
+ }
12
+
13
+ describe("an ordinary feature start", () => {
14
+ it("refuses a pending hand-off right after idle, before unwinding", async () => {
15
+ const steps: StepRequest[] = []
16
+ installContext(
17
+ fixtureContext(
18
+ {},
19
+ {
20
+ step: (request: StepRequest) => {
21
+ steps.push(request)
22
+ return Promise.resolve()
23
+ },
24
+ refuse: (message: string): never => {
25
+ throw new Error(message)
26
+ },
27
+ pushScope: () => undefined,
28
+ popScope: () => undefined,
29
+ read: (path) => FILES[path],
30
+ head: () => "c1",
31
+ start: () => "c0",
32
+ },
33
+ ),
34
+ )
35
+ await expect(ordinaryStart()).rejects.toThrow(".gtd/REVIEW.orig.md is a pending hand-off")
36
+ expect(steps.map((s) => ("name" in s ? s.name : s.kind))).toEqual(["idle"])
37
+ })
38
+ })
@@ -7,43 +7,22 @@ import {
7
7
  revertScript,
8
8
  run,
9
9
  start,
10
- type Doors,
11
- type WorkflowBase,
12
- type Summary,
13
- } from "../flows/index.js"
14
- import { baseline, gate } from "./health.js"
10
+ } from "../../flows/index.js"
11
+ import { gate } from "../shared/health.js"
12
+ import { shipped } from "../shared/options.js"
13
+ import type { FrozenScenarios } from "../shared/scenarios.js"
14
+ import { FEEDBACK, REVIEW } from "../shared/steps.js"
15
+ import { refusePendingHandOff } from "../shared/tail.js"
16
+ import * as t from "../shared/text.js"
15
17
  import { packages } from "./packages.js"
16
18
  import { architecture, design } from "./planning.js"
17
- import type { FrozenScenarios } from "./scenarios.js"
18
19
  import { buildTail, type ReviewOutcome } from "./review.js"
19
- import { ARCHITECTURE, FEEDBACK, REQUIREMENTS, REVIEW } from "./steps.js"
20
- import { shipped } from "./options.js"
21
- import * as t from "./text.js"
22
20
 
23
- // gtd's bundled workflows. Any change to the tree starts a process:
24
- // `idle` → `unwind` reverts the sketch (its intent survives in history) → a
21
+ // The ordinary start. Any change to the tree starts a process: `idle` →
22
+ // `unwind` reverts the sketch (its intent survives in history) → a
25
23
  // green-baseline gate → design, architecture and one package per concern →
26
24
  // the quality lap → human review, which signs off (the episode ends back at
27
- // `idle`) or sends a full re-plan lap. `feature` is that ordinary start;
28
- // `gtd --workflow fix` and `gtd --workflow review --var reviewBase=<commitish>`
29
- // enter the same build tail further in. Each of the three is a factory over
30
- // the settings in `./vars.ts`; its flow is exported too, for composing.
31
- //
32
- // Every part is exported for other workflows to compose; see the modules
33
- // re-exported below.
34
-
35
- export { threads, type ThreadInfo } from "../flows/index.js"
36
- export { defaults, envDefaults } from "./vars.js"
37
- export { ShippedOptions } from "./options.js"
38
- export { skills } from "./skills.js"
39
- export { access } from "./access.js"
40
- export { agentWithSkills } from "./text.js"
41
- export * from "./steps.js"
42
- export * from "./health.js"
43
- export * from "./planning.js"
44
- export * from "./packages.js"
45
- export * from "./scenarios.js"
46
- export * from "./review.js"
25
+ // `idle`) or sends a full re-plan lap.
47
26
 
48
27
  /** Revert the sketch that started the process out of the working tree; its intent survives in history. */
49
28
  export const unwind = (): Promise<void> => {
@@ -97,6 +76,7 @@ export const afterTail = async (outcome: ReviewOutcome): Promise<void> => {
97
76
  /** An ordinary start: wait at `idle`, unwind the sketch, check the baseline, then plan and build. */
98
77
  export const ordinaryStart = async (): Promise<void> => {
99
78
  await human("idle", { message: t.idleMessage(), label: "Idle", file: ".gtd/TODO.md" })
79
+ refusePendingHandOff()
100
80
  await unwind()
101
81
  if (changes(FEEDBACK).some((c) => c.status !== "deleted")) {
102
82
  await human("unwind-failed", {
@@ -109,36 +89,4 @@ export const ordinaryStart = async (): Promise<void> => {
109
89
  await planAndBuild(start())
110
90
  }
111
91
 
112
- /** Repair a red baseline through the build tail, as its own reviewed commit. */
113
- export const fixFlow = async (): Promise<void> => {
114
- if (await baseline("fix-precheck")) return
115
- return afterTail(await buildTail(true, start()))
116
- }
117
-
118
- /** Pure review of everything since `reviewBase`. */
119
- export const reviewFlow = async (): Promise<void> => {
120
- await gate("review-gate", t.reviewGateBlockedMessage())
121
- return afterTail(await buildTail(false, start()))
122
- }
123
-
124
92
  export const feature = shipped(ordinaryStart)
125
- export const fix = shipped(fixFlow)
126
- export const review = shipped(reviewFlow)
127
-
128
- export default feature
129
-
130
- export const summary: Summary = t.summaryPrompt
131
-
132
- export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
133
-
134
- export const base: WorkflowBase = (workflow, vars) =>
135
- workflow === "review" ? (vars.reviewBase ?? "") : undefined
136
-
137
- export const doors: Doors = {
138
- fix: { workflow: "fix" },
139
- review: {
140
- workflow: "review",
141
- args: [{ name: "base", optional: true }],
142
- vars: ({ base }) => (base === undefined ? {} : { reviewBase: base }),
143
- },
144
- }
@@ -1,7 +1,7 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type StepRequest } from "../flows/index.js"
2
+ import { installContext, type Change, type StepRequest } from "../../flows/index.js"
3
3
  import { declaredTests, packages, SCENARIO_PACKAGE } from "./packages.js"
4
- import { fixtureContext } from "./text.fixture.js"
4
+ import { fixtureContext } from "../shared/text.fixture.js"
5
5
 
6
6
  afterEach(() => installContext(undefined))
7
7
 
@@ -8,9 +8,9 @@ import {
8
8
  removeScript,
9
9
  run,
10
10
  scope,
11
- } from "../flows/index.js"
12
- import { healthy } from "./health.js"
13
- import { freezeScenarios, guarded, type FrozenScenarios } from "./scenarios.js"
11
+ } from "../../flows/index.js"
12
+ import { healthy } from "../shared/health.js"
13
+ import { freezeScenarios, guarded, type FrozenScenarios } from "../shared/scenarios.js"
14
14
  import { build, fixSuite } from "./steps.js"
15
15
 
16
16
  /** Package 0: the e2e scenarios, written red. */
@@ -7,14 +7,13 @@ import {
7
7
  requireThreadsClosed,
8
8
  requireProgress,
9
9
  scope,
10
- } from "../flows/index.js"
10
+ } from "../../flows/index.js"
11
+ import { ARCHITECTURE, REQUIREMENTS } from "../shared/steps.js"
11
12
  import {
12
13
  answerProductQuestions,
13
14
  answerTechnicalQuestions,
14
- ARCHITECTURE,
15
15
  author,
16
16
  decompose,
17
- REQUIREMENTS,
18
17
  triage,
19
18
  } from "./steps.js"
20
19
 
@@ -1,8 +1,13 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type JudgeAnswer, type StepRequest } from "../flows/index.js"
3
- import { fixQualityFindings, review, type ReviewOutcome } from "./review.js"
4
- import * as bundled from "./index.js"
5
- import { fixtureContext } from "./text.fixture.js"
2
+ import {
3
+ installContext,
4
+ type Change,
5
+ type JudgeAnswer,
6
+ type StepRequest,
7
+ } from "../../flows/index.js"
8
+ import { review, type ReviewOutcome } from "./review.js"
9
+ import * as bundled from "../index.js"
10
+ import { fixtureContext } from "../shared/text.fixture.js"
6
11
 
7
12
  afterEach(() => installContext(undefined))
8
13
 
@@ -378,43 +383,3 @@ describe("scenario wording in review's fix turns", () => {
378
383
  expect(log.slice(-2)).toEqual(["fix", "scenario-wording"])
379
384
  })
380
385
  })
381
-
382
- describe("the quality fix loop", () => {
383
- it("a fix that resolves QUALITY.md and rewrites a scenario ends after the wording gate", async () => {
384
- const QUALITY = ".gtd/QUALITY.md"
385
- const log: string[] = []
386
- const files = new Map([
387
- [QUALITY, "- finding"],
388
- ["e2e/a.feature", "Given a"],
389
- ])
390
- let last: readonly Change[] = []
391
- installContext(
392
- fixtureContext(
393
- {},
394
- {
395
- step: (request: StepRequest) => {
396
- if (request.kind === "restart") return Promise.resolve()
397
- log.push(request.name)
398
- last = []
399
- if (request.name === "fix.quality.fixing") {
400
- files.delete(QUALITY)
401
- files.set("e2e/a.feature", "Given b")
402
- last = [{ path: QUALITY, status: "deleted", before: "- finding", after: undefined }]
403
- }
404
- return Promise.resolve()
405
- },
406
- pushScope: () => undefined,
407
- popScope: () => undefined,
408
- read: (path) => files.get(path),
409
- changes: () => last,
410
- matches: (path, pattern) => path === pattern,
411
- head: () => "h2",
412
- start: () => "h0",
413
- },
414
- ),
415
- )
416
- const frozen = { at: "h1", texts: { "e2e/a.feature": "Given a" } }
417
- expect(await fixQualityFindings({ rounds: 0 }, frozen)).toBe(true)
418
- expect(log).toEqual(["fix.quality.fixing", "scenario-wording"])
419
- })
420
- })
@@ -0,0 +1,106 @@
1
+ import { answered, judge, numeric, vars, type JudgeQuestion } from "../../flows/index.js"
2
+ import type { ReviewNote } from "../../steering/index.js"
3
+ import * as kit from "../../workflow-kit/index.js"
4
+ import { healthy, type EscalationCount } from "../shared/health.js"
5
+ import { guarded, type FrozenScenarios } from "../shared/scenarios.js"
6
+ import {
7
+ awaitReview,
8
+ fixCheck,
9
+ fixRisks,
10
+ REQUIREMENTS,
11
+ REVIEW,
12
+ reviewing,
13
+ reviewMissing,
14
+ } from "../shared/steps.js"
15
+ import { closeReview, tail } from "../shared/tail.js"
16
+ import * as t from "../shared/text.js"
17
+ import type { BuiltPlan } from "./packages.js"
18
+ import { answerReviewQuestions, collecting, fixNits } from "./steps.js"
19
+
20
+ export type { ReviewOutcome } from "../../workflow-kit/index.js"
21
+
22
+ const VERDICTS: readonly kit.Verdict[] = ["edit", "question", "nit", "praise"]
23
+
24
+ /** One four-way choice per note the human added to `.gtd/REVIEW.md`. */
25
+ const verdictQuestion = (note: ReviewNote): JudgeQuestion => ({
26
+ id: note.id,
27
+ primitive: "choice",
28
+ instructions: `Classify the human's note on review item "${note.anchor}" (in .gtd/REVIEW.md). Its evidence holds the anchor, the reviewer's text and the human's text.`,
29
+ criteria:
30
+ "edit: a request to change behaviour, design or scope — anything needing a plan, or any note you are unsure about. question: asks something and wants an answer, with no change requested. nit: a small, local, unambiguous fix (naming, typo, formatting, comment wording) needing no re-plan. praise: an approving remark with nothing to do.",
31
+ })
32
+
33
+ /**
34
+ * One judge rest, one verdict per note. Dismissing a note is the risky
35
+ * direction, so only a confident non-`edit` verdict on uncut evidence counts;
36
+ * a cut, unanswered or below-floor note is an `edit`, and a blank floor makes
37
+ * every note one.
38
+ */
39
+ const triage = async (notes: readonly ReviewNote[]): Promise<ReadonlyMap<string, kit.Verdict>> => {
40
+ const { answers, truncated } = await judge("review.triage", {
41
+ questions: notes.map(verdictQuestion),
42
+ evidence: Object.fromEntries(
43
+ notes.map((n) => [
44
+ n.id,
45
+ `Anchor: ${n.anchor}\nReviewer's text: ${n.before}\nHuman's text: ${n.text}`,
46
+ ]),
47
+ ),
48
+ message: t.buildReviewTriageMessage(),
49
+ label: "Judging each note",
50
+ })
51
+ const minP = numeric(vars.reviewNoteActionable, Infinity)
52
+ return new Map(
53
+ notes.map((n): [string, kit.Verdict] => {
54
+ const verdict = VERDICTS.find((v) => v !== "edit" && answered(answers[n.id], v, minP))
55
+ return [n.id, truncated.includes(n.id) || verdict === undefined ? "edit" : verdict]
56
+ }),
57
+ )
58
+ }
59
+
60
+ const isCode = (path: string): boolean => path !== ".gtd" && !path.startsWith(".gtd/")
61
+
62
+ /**
63
+ * A reviewer writes `.gtd/REVIEW.md` over everything since `base`, a human
64
+ * reviews and signs off or comments, and each note is judged: edits go to
65
+ * another planning lap, questions are answered in place, nits fixed in one
66
+ * batch, praise dropped.
67
+ */
68
+ export const review = (
69
+ base: string,
70
+ escalations: EscalationCount = { rounds: 0 },
71
+ frozen?: FrozenScenarios,
72
+ ): Promise<kit.ReviewOutcome> =>
73
+ kit.review(base, {
74
+ file: REVIEW,
75
+ feedback: REQUIREMENTS,
76
+ isCode,
77
+ reviewing,
78
+ fixRisks: (risks) => guarded(frozen, () => fixRisks(risks), "review")(),
79
+ keepGreen: () => healthy(guarded(frozen, fixCheck, "review"), { escalations }),
80
+ awaitReview,
81
+ reviewMissing,
82
+ collecting,
83
+ triage,
84
+ answerQuestions: answerReviewQuestions,
85
+ fixNits: (notes) => guarded(frozen, () => fixNits(notes), "review")(),
86
+ close: closeReview,
87
+ captures: {
88
+ edits: t.reviewEditsCapture,
89
+ notes: t.reviewNotesCapture,
90
+ editNotes: t.reviewEditNotesCapture,
91
+ },
92
+ refusals: {
93
+ deleted:
94
+ "gtd land: review-doc: .gtd/REVIEW.md was deleted — restore it, or leave a note (or edit code) to request changes.",
95
+ unmatched:
96
+ "gtd land: no declared pattern matches the pending changes — write .gtd/REQUIREMENTS.md from the feedback, or change nothing when it asks for nothing",
97
+ },
98
+ })
99
+
100
+ /** The build tail: fix (when entered red), keep green, the quality lap, then human review since `base`. */
101
+ export const buildTail = (
102
+ fixFirst: boolean,
103
+ base: string,
104
+ built?: BuiltPlan,
105
+ ): Promise<kit.ReviewOutcome> =>
106
+ tail(fixFirst, (escalations, frozen) => review(base, escalations, frozen), built)
@@ -0,0 +1,82 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import type { StepRequest } from "../../flows/index.js"
3
+ import { captureStep } from "../shared/text.fixture.js"
4
+ import * as steps from "./steps.js"
5
+
6
+ const capture = (
7
+ fn: () => Promise<void>,
8
+ scope: string,
9
+ skills?: Readonly<Record<string, readonly string[]>>,
10
+ ): Promise<StepRequest> => captureStep(fn, { scope, ...(skills !== undefined ? { skills } : {}) })
11
+
12
+ const agentPrompt = (request: StepRequest): string => {
13
+ if (request.kind !== "agent") throw new Error(`expected an agent step, got ${request.kind}`)
14
+ return request.prompt
15
+ }
16
+
17
+ describe("the feature workflow's own steps declare skills — a step's rendered prompt carries its bundled skills.ts preamble", () => {
18
+ it("triage carries design.triage's bundled skills", async () => {
19
+ const prompt = agentPrompt(await capture(() => steps.triage("base"), "design"))
20
+ expect(prompt).toContain("spec-driven-development, planning-and-task-breakdown")
21
+ })
22
+
23
+ it("author carries architecture.author's bundled skills", async () => {
24
+ const prompt = agentPrompt(await capture(() => steps.author(), "architecture"))
25
+ expect(prompt).toContain("api-and-interface-design, documentation-and-adrs, ponytail")
26
+ })
27
+
28
+ it("decompose carries architecture.decompose's bundled skills", async () => {
29
+ const prompt = agentPrompt(await capture(() => steps.decompose(), "architecture"))
30
+ expect(prompt).toContain("incremental-implementation, planning-and-task-breakdown")
31
+ })
32
+
33
+ it("build carries packages.item.building's bundled skills", async () => {
34
+ const prompt = agentPrompt(await capture(() => steps.build("pkg"), "packages.item"))
35
+ expect(prompt).toContain("test-driven-development, incremental-implementation")
36
+ })
37
+
38
+ it("fixSuite carries packages.item.fix.suite's bundled skills", async () => {
39
+ const prompt = agentPrompt(await capture(() => steps.fixSuite(), "packages.item"))
40
+ expect(prompt).toContain("debugging-and-error-recovery")
41
+ })
42
+
43
+ it("collecting carries build.review.collecting's bundled skills", async () => {
44
+ const prompt = agentPrompt(await capture(() => steps.collecting("capture"), "build"))
45
+ expect(prompt).toContain("code-review-and-quality")
46
+ })
47
+
48
+ it("answerReviewQuestions answers inline in .gtd/REVIEW.md as the reviewer, with its bundled skills", async () => {
49
+ const request = await capture(
50
+ () =>
51
+ steps.answerReviewQuestions([{ id: "note-1", anchor: "calc ./a.ts#1-1", text: "why?" }]),
52
+ "build",
53
+ )
54
+ if (request.kind !== "agent") throw new Error("unreachable")
55
+ expect(request.name).toBe("review.answer-review-questions")
56
+ expect(request.options).toMatchObject({
57
+ file: ".gtd/REVIEW.md",
58
+ mode: "review",
59
+ })
60
+ expect(request.prompt).toContain("code-review-and-quality")
61
+ expect(request.prompt).toContain("note-1 — calc ./a.ts#1-1")
62
+ expect(request.prompt).toContain("`A: ` line")
63
+ expect(request.prompt).toContain("gtd check review .gtd/REVIEW.md")
64
+ })
65
+
66
+ it("fixNits fixes every nit in one turn and leaves .gtd/REVIEW.md alone", async () => {
67
+ const request = await capture(
68
+ () =>
69
+ steps.fixNits([
70
+ { id: "note-1", anchor: "calc ./a.ts#1-1", text: "typo" },
71
+ { id: "note-2", anchor: "calc ./a.ts#2-2", text: "semicolon" },
72
+ ]),
73
+ "build",
74
+ )
75
+ if (request.kind !== "agent") throw new Error("unreachable")
76
+ expect(request.name).toBe("review.fix.nits.fixing")
77
+ expect(request.prompt).toContain("incremental-implementation, code-simplification")
78
+ expect(request.prompt).toContain("typo")
79
+ expect(request.prompt).toContain("semicolon")
80
+ expect(request.prompt).toContain("Leave `.gtd/REVIEW.md` untouched")
81
+ })
82
+ })
@@ -0,0 +1,92 @@
1
+ import { human } from "../../flows/index.js"
2
+ import * as t from "../shared/text.js"
3
+ import { ARCHITECTURE, coder, FEEDBACK, planner, REQUIREMENTS, REVIEW } from "../shared/steps.js"
4
+
5
+ /** Turn the sketch since `base` into `.gtd/REQUIREMENTS.md`. */
6
+ export const triage = (base: string): Promise<void> =>
7
+ t.agentWithSkills("triage", t.designTriagePrompt(base), {
8
+ label: "Triaging the change",
9
+ file: REQUIREMENTS,
10
+ mode: "qa",
11
+ model: planner(),
12
+ system: t.designSystem(),
13
+ })
14
+
15
+ export const author = (): Promise<void> =>
16
+ t.agentWithSkills("author", t.architectureAuthorPrompt(), {
17
+ label: "Refining the technical plan",
18
+ file: ARCHITECTURE,
19
+ mode: "qa",
20
+ model: planner(),
21
+ system: t.architectSystem(),
22
+ })
23
+
24
+ export const decompose = (): Promise<void> =>
25
+ t.agentWithSkills("decompose.decomposing", t.architectureDecomposePrompt(), {
26
+ label: "Decomposing into packages",
27
+ model: planner(),
28
+ system: t.architectSystem(),
29
+ })
30
+
31
+ export const answerProductQuestions = (): Promise<void> =>
32
+ human("gate.answer", {
33
+ message: t.designGateAnswerMessage(),
34
+ label: "Awaiting your product answers",
35
+ file: REQUIREMENTS,
36
+ mode: "qa",
37
+ acceptClean: true,
38
+ })
39
+
40
+ export const answerTechnicalQuestions = (): Promise<void> =>
41
+ human("gate.answer", {
42
+ message: t.architectureGateAnswerMessage(),
43
+ label: "Awaiting your technical answers",
44
+ file: ARCHITECTURE,
45
+ mode: "qa",
46
+ acceptClean: true,
47
+ })
48
+
49
+ export const build = (pkg: string): Promise<void> =>
50
+ t.agentWithSkills("building", t.packagesItemBuildingPrompt(pkg), {
51
+ label: "Building",
52
+ model: coder(),
53
+ system: t.builderSystem(),
54
+ })
55
+
56
+ export const fixSuite = (): Promise<void> =>
57
+ t.agentWithSkills("fix.suite.fixing", t.packagesItemFixSuitePrompt(), {
58
+ label: "Fixing the check",
59
+ file: FEEDBACK,
60
+ model: coder(),
61
+ system: t.builderSystem(),
62
+ })
63
+
64
+ /** Answer every `question` note inline in `.gtd/REVIEW.md`. */
65
+ export const answerReviewQuestions = (notes: readonly t.NoteInput[]): Promise<void> =>
66
+ t.agentWithSkills("review.answer-review-questions", t.buildReviewAnswerQuestionsPrompt(notes), {
67
+ label: "Answering your questions",
68
+ file: REVIEW,
69
+ mode: "review",
70
+ model: planner(),
71
+ system: t.reviewerSystem(),
72
+ })
73
+
74
+ /** Fix every `nit` note in one turn. */
75
+ export const fixNits = (notes: readonly t.NoteInput[]): Promise<void> =>
76
+ t.agentWithSkills("review.fix.nits.fixing", t.buildReviewFixNitsPrompt(notes), {
77
+ label: "Fixing your nits",
78
+ file: REVIEW,
79
+ model: planner(),
80
+ system: t.reviewerSystem(),
81
+ })
82
+
83
+ /** Turn the review round `capture` describes into `.gtd/REQUIREMENTS.md`, or change nothing. */
84
+ export const collecting = (capture: string): Promise<void> =>
85
+ t.agentWithSkills("review.collecting", t.buildReviewCollectingPrompt(capture), {
86
+ label: "Collecting your feedback",
87
+ file: REQUIREMENTS,
88
+ mode: "qa",
89
+ model: planner(),
90
+ system: t.reviewerSystem(),
91
+ allowEmpty: true,
92
+ })
@@ -0,0 +1,94 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext, type StepRequest } from "../../flows/index.js"
3
+ import { fixtureContext } from "../shared/text.fixture.js"
4
+ import { fixFlow } from "./fix.js"
5
+
6
+ afterEach(() => installContext(undefined))
7
+
8
+ const REVIEW = ".gtd/REVIEW.md"
9
+ const ORIG = ".gtd/REVIEW.orig.md"
10
+
11
+ const reviewDoc = (...notes: string[]): string =>
12
+ [
13
+ "# Review: abc1234",
14
+ "",
15
+ "<!-- base: 0000000 -->",
16
+ "",
17
+ "## calc",
18
+ ...notes.flatMap((note, i) => [`- [ ] ./src/calc.ts#${i + 1}`, note]),
19
+ "",
20
+ ].join("\n")
21
+
22
+ class Stop extends Error {}
23
+
24
+ /** Run `fix` over `files` until its third step; returns the steps it asked for. */
25
+ const drive = async (files: Readonly<Record<string, string>>): Promise<StepRequest[]> => {
26
+ const steps: StepRequest[] = []
27
+ installContext(
28
+ fixtureContext(
29
+ {},
30
+ {
31
+ step: (request: StepRequest) => {
32
+ steps.push(request)
33
+ if (steps.length >= 3) throw new Stop()
34
+ return Promise.resolve()
35
+ },
36
+ refuse: (message: string): never => {
37
+ throw new Error(message)
38
+ },
39
+ pushScope: () => undefined,
40
+ popScope: () => undefined,
41
+ // Closing removes both review files, as the real tree would show.
42
+ read: (path) =>
43
+ steps.some((s) => "name" in s && s.name === "closing") ? undefined : files[path],
44
+ head: () => "c1",
45
+ start: () => "c0",
46
+ },
47
+ ),
48
+ )
49
+ await fixFlow().catch((e: unknown) => {
50
+ if (!(e instanceof Stop)) throw e
51
+ })
52
+ return steps
53
+ }
54
+
55
+ describe("the fix workflow", () => {
56
+ it("with no handed-off review, repairs a red baseline from fix-precheck", async () => {
57
+ const [first] = await drive({})
58
+ expect(first).toMatchObject({ kind: "run", name: "fix-precheck" })
59
+ })
60
+
61
+ it("fixes a handed-off review's notes and risks in one turn, then removes both review files", async () => {
62
+ const steps = await drive({
63
+ [REVIEW]: reviewDoc("add — rename it to sum", "Risk: drops the carry"),
64
+ [ORIG]: reviewDoc("add", "Risk: drops the carry"),
65
+ })
66
+ const [fixing, closing] = steps
67
+ if (fixing?.kind !== "agent" || closing?.kind !== "run") throw new Error("unexpected steps")
68
+ expect(fixing.name).toBe("fixing")
69
+ expect(fixing.prompt).toContain("rename it to sum")
70
+ expect(fixing.prompt).toContain("Risk: drops the carry")
71
+ expect(closing.name).toBe("closing")
72
+ expect(closing.body).toContain(REVIEW)
73
+ expect(closing.body).toContain(ORIG)
74
+ })
75
+
76
+ it("after the notes, checks health before the suite is trusted", async () => {
77
+ const steps = await drive({ [REVIEW]: reviewDoc("add"), [ORIG]: reviewDoc("add") })
78
+ expect(steps.map((s) => [s.kind, "name" in s ? s.name : undefined])).toEqual([
79
+ ["agent", "fixing"],
80
+ ["run", "closing"],
81
+ ["run", "health.check"],
82
+ ])
83
+ })
84
+
85
+ it("refuses a reviewer's copy left with no review", async () => {
86
+ await expect(drive({ [ORIG]: reviewDoc("add") })).rejects.toThrow(/has no \.gtd\/REVIEW\.md/)
87
+ })
88
+
89
+ it("refuses a review with no reviewer's copy, so no note is deleted unread", async () => {
90
+ await expect(drive({ [REVIEW]: reviewDoc("Risk: drops the carry") })).rejects.toThrow(
91
+ /not handed off/,
92
+ )
93
+ })
94
+ })
@@ -0,0 +1,52 @@
1
+ import { read, refuse, removeScript, run, scope, start } from "../../flows/index.js"
2
+ import { reviewNotes, reviewRisks } from "../../steering/index.js"
3
+ import { baseline } from "../shared/health.js"
4
+ import { shipped } from "../shared/options.js"
5
+ import { coder, REVIEW } from "../shared/steps.js"
6
+ import { handOffTail, REVIEW_ORIG } from "../shared/tail.js"
7
+ import * as t from "../shared/text.js"
8
+
9
+ /**
10
+ * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
11
+ * risk its reviewer marked, in one turn; the review files go once it is done.
12
+ */
13
+ export const fixReviewNotes = (): Promise<void> =>
14
+ scope("notes", async () => {
15
+ const record = read(REVIEW) ?? ""
16
+ const orig = read(REVIEW_ORIG)
17
+ // Without the reviewer's copy no note can be told from the review's own text,
18
+ // and closing would delete notes never read — also keeps a stray review out.
19
+ if (orig === undefined) {
20
+ return refuse(
21
+ `gtd: ${REVIEW} has no ${REVIEW_ORIG}, so it was not handed off by a review — remove it, or restore ${REVIEW_ORIG}.`,
22
+ )
23
+ }
24
+ const notes = reviewNotes(orig, record)
25
+ await t.agentWithSkills("fixing", t.fixReviewNotesPrompt(notes, reviewRisks(record)), {
26
+ label: "Fixing your review notes",
27
+ file: REVIEW,
28
+ model: coder(),
29
+ system: t.finisherSystem(),
30
+ // Praise and questions are answered without an edit; the hand-off review that follows catches an unaddressed note.
31
+ allowEmpty: true,
32
+ })
33
+ await run("closing", removeScript([REVIEW, REVIEW_ORIG]), { label: "Closing the review" })
34
+ })
35
+
36
+ /**
37
+ * Fix what a review handed on, else repair a red baseline — then the
38
+ * hand-off review of the result. A note there is handed to the next `fix`.
39
+ */
40
+ export const fixFlow = async (): Promise<void> => {
41
+ if (read(REVIEW) !== undefined) {
42
+ await fixReviewNotes()
43
+ return handOffTail(false, start())
44
+ }
45
+ if (read(REVIEW_ORIG) !== undefined) {
46
+ return refuse(`gtd: ${REVIEW_ORIG} has no ${REVIEW} — remove it, or restore ${REVIEW}.`)
47
+ }
48
+ if (await baseline("fix-precheck")) return
49
+ await handOffTail(true, start())
50
+ }
51
+
52
+ export const fix = shipped(fixFlow, { fix: {} })