@pmelab/gtd 24.0.0 → 24.2.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 (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,109 @@
1
+ import { Cause, Effect, Exit } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { resolveScopeAccess, resolveScopeSkills } from "./skills.js"
4
+
5
+ const FILE = "/repo/gtd.config.ts"
6
+
7
+ const failure = async (skills: (vars: Record<string, string>) => unknown): Promise<string> => {
8
+ const exit = await Effect.runPromiseExit(
9
+ resolveScopeSkills({ skills: skills as never, configuredSkills: {}, skillsOrigin: FILE }, {}),
10
+ )
11
+ expect(Exit.isFailure(exit)).toBe(true)
12
+ return Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
13
+ }
14
+
15
+ describe("resolveScopeSkills", () => {
16
+ it("wraps a throwing export with the config prefix and file", async () => {
17
+ const message = await failure(() => {
18
+ throw new Error("boom")
19
+ })
20
+ expect(message).toContain(`gtd config:\n - ${FILE}: boom`)
21
+ })
22
+
23
+ it("rejects a non-object return", async () => {
24
+ expect(await failure(() => ["a"])).toContain(
25
+ `gtd config:\n - ${FILE}: skills export must return an object of scope -> skill names`,
26
+ )
27
+ })
28
+
29
+ it("rejects a non-array value", async () => {
30
+ expect(await failure(() => ({ a: "x" }))).toContain(
31
+ `gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
32
+ )
33
+ })
34
+
35
+ it("rejects a non-string entry", async () => {
36
+ expect(await failure(() => ({ a: ["x", 1] }))).toContain(
37
+ `gtd config:\n - ${FILE}: skills export: "a" must be an array of skill names`,
38
+ )
39
+ })
40
+
41
+ it("reports an unknown config key at gtd.config.ts", async () => {
42
+ const exit = await Effect.runPromiseExit(
43
+ resolveScopeSkills(
44
+ { skills: () => ({ a: ["x"] }), configuredSkills: { nope: [] }, skillsOrigin: FILE },
45
+ {},
46
+ ),
47
+ )
48
+ expect(String(Exit.isFailure(exit) ? Cause.squash(exit.cause) : "")).toContain(
49
+ `${FILE}: config.skills.nope: "skills.nope" is not a scope that runs a turn — known scopes: a`,
50
+ )
51
+ })
52
+ })
53
+
54
+ describe("resolveScopeAccess", () => {
55
+ const run = (
56
+ def: {
57
+ access: (vars: Record<string, string>) => unknown
58
+ configuredAccess?: Record<string, object>
59
+ },
60
+ skills: Record<string, readonly string[]> = {},
61
+ ) =>
62
+ Effect.runPromiseExit(
63
+ resolveScopeAccess(
64
+ {
65
+ access: def.access as never,
66
+ configuredAccess: def.configuredAccess ?? {},
67
+ skillsOrigin: FILE,
68
+ },
69
+ skills,
70
+ {},
71
+ ),
72
+ )
73
+ const message = (exit: Awaited<ReturnType<typeof run>>): string =>
74
+ Exit.isFailure(exit) ? String(Cause.squash(exit.cause)) : ""
75
+
76
+ it("returns a well-shaped record", async () => {
77
+ const exit = await run({ access: () => ({ a: { write: ["x"] }, b: {} }) })
78
+ expect(Exit.isSuccess(exit) && exit.value).toEqual({ a: { write: ["x"] }, b: {} })
79
+ })
80
+
81
+ it("rejects a malformed export with the file", async () => {
82
+ expect(message(await run({ access: () => ["a"] }))).toContain(
83
+ `gtd config:\n - ${FILE}: access export must return an object`,
84
+ )
85
+ expect(message(await run({ access: () => ({ a: { write: "x" } }) }))).toContain(
86
+ `${FILE}: access export: "a": access.write must be an array`,
87
+ )
88
+ })
89
+
90
+ it("knows the scopes of both the skills and access exports", async () => {
91
+ const exit = await run(
92
+ {
93
+ access: () => ({ fromAccess: {} }),
94
+ configuredAccess: { fromSkills: {}, fromAccess: {} },
95
+ },
96
+ { fromSkills: ["s"] },
97
+ )
98
+ expect(Exit.isSuccess(exit)).toBe(true)
99
+ })
100
+
101
+ it("reports an unknown key with the exact message and known scopes", async () => {
102
+ const text = message(
103
+ await run({ access: () => ({ b: {} }), configuredAccess: { nope: {} } }, { a: ["x"] }),
104
+ )
105
+ expect(text).toContain(
106
+ `${FILE}: config.access.nope: "access.nope" is not a scope that runs a turn — known scopes: a, b`,
107
+ )
108
+ })
109
+ })
@@ -0,0 +1,90 @@
1
+ import { Effect } from "effect"
2
+ import { GtdError } from "../platform/index.js"
3
+ import type { WorkflowDefinition } from "./Workflow.js"
4
+ import { accessShapeFault } from "../replay/index.js"
5
+ import type { ScopeAccess } from "../flows/index.js"
6
+ import type { Diagnostic } from "./Diagnostic.js"
7
+ import { failOnErrors } from "./load.js"
8
+
9
+ const fail = (origin: string, message: string): GtdError =>
10
+ new GtdError(`gtd config:\n - ${origin}: ${message}`)
11
+
12
+ const checkShape = (value: unknown): Record<string, readonly string[]> => {
13
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
14
+ throw new Error("skills export must return an object of scope -> skill names")
15
+ }
16
+ for (const [scope, names] of Object.entries(value)) {
17
+ if (!Array.isArray(names) || names.some((n) => typeof n !== "string")) {
18
+ throw new Error(`skills export: "${scope}" must be an array of skill names`)
19
+ }
20
+ }
21
+ return value as Record<string, readonly string[]>
22
+ }
23
+
24
+ /** Every `config` key that names no known scope is a load error. */
25
+ const checkKeys = (
26
+ configured: Readonly<Record<string, unknown>>,
27
+ origin: string,
28
+ known: readonly string[],
29
+ section: "skills" | "access",
30
+ ): Effect.Effect<void, GtdError> => {
31
+ const sorted = [...known].sort()
32
+ const diagnostics: Diagnostic[] = Object.keys(configured)
33
+ .filter((key) => !sorted.includes(key))
34
+ .sort()
35
+ .map((key) => ({
36
+ severity: "error" as const,
37
+ path: ["config", section, key],
38
+ message: `"${section}.${key}" is not a scope that runs a turn — known scopes: ${sorted.join(", ")}`,
39
+ origin,
40
+ }))
41
+ return failOnErrors(diagnostics)
42
+ }
43
+
44
+ /**
45
+ * Skill lists for the settings actually in use. `config.skills` keys are
46
+ * judged here, not at config load: only now are the pinned/entry vars known.
47
+ */
48
+ export const resolveScopeSkills = (
49
+ def: Pick<WorkflowDefinition, "skills" | "configuredSkills" | "skillsOrigin">,
50
+ vars: Readonly<Record<string, string>>,
51
+ ): Effect.Effect<Record<string, readonly string[]>, GtdError> =>
52
+ Effect.gen(function* () {
53
+ const skills = yield* Effect.try({
54
+ try: () => checkShape(def.skills(vars)),
55
+ catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
56
+ })
57
+ const known = Object.keys(skills)
58
+ yield* checkKeys(def.configuredSkills, def.skillsOrigin, known, "skills")
59
+ return skills
60
+ })
61
+
62
+ const checkAccessShape = (value: unknown): Record<string, ScopeAccess> => {
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
64
+ throw new Error("access export must return an object of scope -> { read?, write? }")
65
+ }
66
+ for (const [scope, access] of Object.entries(value)) {
67
+ const fault = accessShapeFault(access)
68
+ if (fault !== undefined) throw new Error(`access export: "${scope}": ${fault}`)
69
+ }
70
+ return value as Record<string, ScopeAccess>
71
+ }
72
+
73
+ /**
74
+ * Access for the settings actually in use. A `config.access` key may name
75
+ * any scope the workflow gives skills or access to.
76
+ */
77
+ export const resolveScopeAccess = (
78
+ def: Pick<WorkflowDefinition, "access" | "configuredAccess" | "skillsOrigin">,
79
+ skills: Readonly<Record<string, readonly string[]>>,
80
+ vars: Readonly<Record<string, string>>,
81
+ ): Effect.Effect<Record<string, ScopeAccess>, GtdError> =>
82
+ Effect.gen(function* () {
83
+ const access = yield* Effect.try({
84
+ try: () => checkAccessShape(def.access(vars)),
85
+ catch: (e) => fail(def.skillsOrigin, e instanceof Error ? e.message : String(e)),
86
+ })
87
+ const known = [...new Set([...Object.keys(skills), ...Object.keys(access)])]
88
+ yield* checkKeys(def.configuredAccess, def.skillsOrigin, known, "access")
89
+ return access
90
+ })
@@ -0,0 +1,33 @@
1
+ const PREFIX = "GTD_"
2
+
3
+ /**
4
+ * One kind of setting, later wins: the workflow's own defaults, `--var`
5
+ * overrides (process settings only; environment settings pass `{}`), then
6
+ * `GTD_<NAME>` for any name an earlier layer declared (a shell variable never
7
+ * introduces a name).
8
+ */
9
+ export const resolveVars = (
10
+ workflowVars: Readonly<Record<string, string>>,
11
+ entryVars: Readonly<Record<string, string>>,
12
+ env: Readonly<Record<string, string | undefined>>,
13
+ ): Record<string, string> => {
14
+ const merged = { ...workflowVars, ...entryVars }
15
+ for (const name of Object.keys(merged)) {
16
+ const value = env[PREFIX + name.toUpperCase()]
17
+ if (value !== undefined) merged[name] = value
18
+ }
19
+ return merged
20
+ }
21
+
22
+ /** The first process setting whose value spans lines — a `Gtd-Var:` trailer cannot carry it. */
23
+ export const multilineSetting = (vars: Readonly<Record<string, string>>): string | undefined =>
24
+ Object.keys(vars)
25
+ .sort()
26
+ .find((name) => /[\r\n]/.test(vars[name]!))
27
+
28
+ const SETTING_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
29
+
30
+ /** A name is written verbatim into a `Gtd-Var: <name>=<value>` trailer, so `=` or a newline would corrupt it. */
31
+ export const isSettingName = (name: string): boolean => SETTING_NAME.test(name)
32
+
33
+ export const SETTING_NAME_RULE = 'a setting name is a letter or "_", then letters, digits or "_"'
@@ -1,38 +1,32 @@
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
- }
1
+ import { describe, expect, it } from "vitest"
2
+ import { testRepo } from "../../testkit/index.js"
3
+ import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
+ import { reviewDoc } from "../shared/config.fixture.js"
12
5
 
13
6
  describe("an ordinary feature start", () => {
7
+ it("unwinds a captured TODO, checks the baseline and starts design from it", async () => {
8
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
9
+ repo.write("TODO.md", "# Add numbers\n\nadd(a, b) returns the sum.\n")
10
+ const rest = await repo.drive({ unwind: () => undefined, "start-gate.check": () => undefined })
11
+ expect(rest).toMatchObject({
12
+ state: "design.triage",
13
+ actor: "agent",
14
+ file: ".gtd/REQUIREMENTS.md",
15
+ })
16
+ expect(repo.subjects().slice(1)).toEqual([
17
+ "gtd(human): idle → unwind",
18
+ "gtd(check): unwind → start-gate.check",
19
+ "gtd(check): start-gate.check → design.triage",
20
+ ])
21
+ })
22
+
14
23
  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"])
24
+ const repo = testRepo({
25
+ workflow: CONFIG_SCAFFOLD,
26
+ files: { ".gtd/REVIEW.md": reviewDoc("note"), ".gtd/REVIEW.orig.md": reviewDoc("orig") },
27
+ })
28
+ repo.write("TODO.md", "# Add numbers\n")
29
+ await expect(repo.land()).rejects.toThrow(".gtd/REVIEW.orig.md is a pending hand-off")
30
+ expect(repo.subjects()).toEqual(["chore: initial commit"])
37
31
  })
38
32
  })
@@ -0,0 +1,10 @@
1
+ import { feature } from "./feature.js"
2
+
3
+ export * from "./steps.js"
4
+ export * from "./planning.js"
5
+ export * from "./packages.js"
6
+ export { buildTail, type ReviewOutcome } from "./review.js"
7
+ export * from "./feature.js"
8
+ export * from "../shared/reserved.js"
9
+
10
+ export default feature
@@ -0,0 +1,80 @@
1
+ Feature: fix fixes the notes review hands it
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.
6
+
7
+ Background:
8
+ Given the shipped workflows, as gtd install scaffolds them
9
+ And an environment variable "GTD_QUALITYREVIEWS" set to ""
10
+ And a file "src/calc.ts" with:
11
+ """
12
+ export const add = (a: number, b: number) => a + b
13
+ """
14
+ And the working tree is committed as "feat: add calculator"
15
+
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:
20
+ """
21
+ # Review: abc1234
22
+
23
+ <!-- base: 0000000 -->
24
+
25
+ ## calc
26
+
27
+ - [ ] ./src/calc.ts#1-1
28
+ new add function
29
+ """
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:
35
+ """
36
+ # Review: abc1234
37
+
38
+ <!-- base: 0000000 -->
39
+
40
+ ## calc
41
+
42
+ - [ ] ./src/calc.ts#1-1
43
+ new add function — rename it to sum
44
+ """
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"
50
+
51
+ Given step "notes.fixing" writes "src/calc.ts" with:
52
+ """
53
+ export const sum = (a: number, b: number) => a + b
54
+ """
55
+ And step "notes.closing" runs its script
56
+ When gtd opens door "fix"
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.
60
+ When the process is driven
61
+ Then the process rests at "build.review.await-review"
62
+ And the commit subjects end with:
63
+ """
64
+ gtd(human): notes.fixing
65
+ gtd(agent): notes.fixing → notes.closing
66
+ gtd(check): notes.closing → build.health.check
67
+ gtd(check): build.health.check → build.review.reviewing
68
+ gtd(agent): build.review.reviewing → build.review.await-review
69
+ """
70
+ And ".gtd/REVIEW.orig.md" does not exist
71
+ And ".gtd/REVIEW.md" contains "new add function"
72
+ And "src/calc.ts" contains "sum"
73
+
74
+ Scenario: a reviewer's copy with no review is refused
75
+ Given a file ".gtd/REVIEW.orig.md" with:
76
+ """
77
+ # Review: abc1234
78
+ """
79
+ And the working tree is committed as "chore: stray copy"
80
+ Then opening door "fix" is refused with "has no .gtd/REVIEW.md"
@@ -1,94 +1,74 @@
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))
1
+ import { describe, expect, it } from "vitest"
2
+ import { runsItsScript, testRepo } from "../../testkit/index.js"
3
+ import { CONFIG_SCAFFOLD } from "../../install/index.js"
4
+ import { reviewDoc } from "../shared/config.fixture.js"
7
5
 
8
6
  const REVIEW = ".gtd/REVIEW.md"
9
7
  const ORIG = ".gtd/REVIEW.orig.md"
8
+ const CALC = "export const add = (a: number, b: number) => a + b\n"
10
9
 
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",
10
+ describe("the fix workflow", () => {
11
+ it("with no handed-off review, repairs a red baseline and hands it to review", async () => {
12
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
13
+ await repo.start("fix")
14
+ const rest = await repo.drive({
15
+ "fix-precheck": (_, r) => r.write(".gtd/FEEDBACK.md", "1 test failing\n"),
16
+ "build.fix": (beat, r) => {
17
+ expect(beat.content).toContain(".gtd/FEEDBACK.md")
18
+ r.remove(".gtd/FEEDBACK.md")
19
+ r.write("src/repair.ts", "export const repaired = true\n")
46
20
  },
47
- ),
48
- )
49
- await fixFlow().catch((e: unknown) => {
50
- if (!(e instanceof Stop)) throw e
21
+ "build.health.check": () => undefined,
22
+ })
23
+ expect(rest).toMatchObject({ state: "build.review.reviewing", actor: "agent" })
24
+ expect(repo.subjects().slice(1)).toEqual([
25
+ "gtd(human): fix-precheck",
26
+ "gtd(check): fix-precheck → build.fix",
27
+ "gtd(agent): build.fix → build.health.check",
28
+ "gtd(check): build.health.check → build.review.reviewing",
29
+ ])
51
30
  })
52
- return steps
53
- }
54
31
 
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" })
32
+ it("with a green baseline, has nothing to fix", async () => {
33
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, env: { GTD_QUALITYREVIEWS: "" } })
34
+ await repo.start("fix")
35
+ const rest = await repo.drive({ "fix-precheck": () => undefined })
36
+ expect(rest.state).toBe("idle")
37
+ expect(repo.subjects().at(-1)).toBe("gtd(check): fix-precheck → idle")
59
38
  })
60
39
 
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"),
40
+ it("fixes a handed-off review's notes and risks in one turn, removes both review files, then checks health", async () => {
41
+ const repo = testRepo({
42
+ workflow: CONFIG_SCAFFOLD,
43
+ env: { GTD_QUALITYREVIEWS: "" },
44
+ files: {
45
+ "src/calc.ts": CALC,
46
+ [REVIEW]: reviewDoc("add — rename it to sum", "Risk: drops the carry"),
47
+ [ORIG]: reviewDoc("add", "Risk: drops the carry"),
48
+ },
65
49
  })
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"],
50
+ await repo.door("fix")
51
+ const rest = await repo.drive({
52
+ "notes.fixing": (beat, r) => {
53
+ expect(beat.content).toContain("rename it to sum")
54
+ expect(beat.content).toContain("Risk: drops the carry")
55
+ r.write("src/calc.ts", CALC.replace("add", "sum"))
56
+ },
57
+ "notes.closing": runsItsScript,
58
+ })
59
+ expect(rest.state).toBe("build.health.check")
60
+ expect(repo.subjects().slice(1)).toEqual([
61
+ "gtd(human): notes.fixing",
62
+ "gtd(agent): notes.fixing → notes.closing",
63
+ "gtd(check): notes.closing → build.health.check",
82
64
  ])
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/)
65
+ expect(repo.read("src/calc.ts")).toContain("sum")
66
+ expect(repo.read(REVIEW)).toBeUndefined()
67
+ expect(repo.read(ORIG)).toBeUndefined()
87
68
  })
88
69
 
89
70
  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
- )
71
+ const repo = testRepo({ workflow: CONFIG_SCAFFOLD, files: { [REVIEW]: reviewDoc("add") } })
72
+ await expect(repo.door("fix")).rejects.toThrow(/not handed off/)
93
73
  })
94
74
  })
@@ -0,0 +1,6 @@
1
+ import { fix } from "./fix.js"
2
+
3
+ export { fix, fixFlow, fixReviewNotes } from "./fix.js"
4
+ export * from "../shared/reserved.js"
5
+
6
+ export default fix
@@ -1,15 +1,11 @@
1
1
  // The `@pmelab/gtd/workflows` entry, built on its own: core never imports it,
2
2
  // a repository's `gtd.config.ts` does.
3
- import type { Doors, Summary } from "../flows/index.js"
3
+ import type { Doors } from "../flows/index.js"
4
4
  import { feature } from "./feature/feature.js"
5
- import * as t from "./shared/text.js"
6
- import { ARCHITECTURE, REQUIREMENTS, REVIEW } from "./shared/steps.js"
7
5
 
8
6
  export { threads, type ThreadInfo } from "../flows/index.js"
9
- export { defaults, envDefaults } from "./shared/vars.js"
7
+ export * from "./shared/reserved.js"
10
8
  export { ShippedOptions } from "./shared/options.js"
11
- export { skills } from "./shared/skills.js"
12
- export { access } from "./shared/access.js"
13
9
  export { agentWithSkills } from "./shared/text.js"
14
10
  export * from "./shared/steps.js"
15
11
  export * from "./shared/health.js"
@@ -35,10 +31,6 @@ export { fix, fixFlow, fixReviewNotes } from "./fix/fix.js"
35
31
 
36
32
  export default feature
37
33
 
38
- export const summary: Summary = t.summaryPrompt
39
-
40
- export const steering = { [REQUIREMENTS]: "qa", [ARCHITECTURE]: "qa", [REVIEW]: "review" }
41
-
42
34
  /**
43
35
  * @deprecated Empty: each shipped workflow builds its own doors, so a config
44
36
  * gets `fix` and `review [base]` by exporting `fix` and `review`.
@@ -0,0 +1,6 @@
1
+ import { review } from "./review.js"
2
+
3
+ export { review, reviewFlow } from "./review.js"
4
+ export * from "../shared/reserved.js"
5
+
6
+ export default review