@pmelab/gtd 23.2.0 → 24.1.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 (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,31 @@
1
+ import type { StateName } from "../workflow/index.js"
2
+
3
+ /**
4
+ * The core's git effect, as DATA — never shell text. `src/emit/GitScript.ts` is
5
+ * the only place a `GitWrite` becomes a command; `src/step/` never imports
6
+ * that module, which is what makes writing a live git call inside the core
7
+ * impossible rather than merely discouraged.
8
+ */
9
+ export type GitWrite = { readonly kind: "commitAll"; readonly message: string }
10
+
11
+ /** A landed step's trailing report, rendered by `GitScript.ts` into a `printf` line. */
12
+ export type Outcome =
13
+ | { readonly kind: "transition"; readonly from: StateName; readonly to: StateName }
14
+ | { readonly kind: "commit"; readonly subject: StateName | string }
15
+ | { readonly kind: "note"; readonly text: string }
16
+
17
+ /**
18
+ * One step of a landing script, as DATA — no step carries a literal shell
19
+ * string; `GitScript.ts`'s `ScriptSurface` is the only place any of these
20
+ * becomes shell text (and the only place a bare path becomes a quoted
21
+ * argument). `"uncheck"` is the human-review-gate's reset command
22
+ * (`gtd uncheck <file>`, ahead of the commit); `"command"`'s own `command`
23
+ * is a driver-facing shell fragment `src/step/` never constructs today (no
24
+ * current emitter produces one) but the shape stays available for a future
25
+ * step that genuinely needs an arbitrary command.
26
+ */
27
+ export type LandStep =
28
+ | { readonly kind: "gitWrite"; readonly write: GitWrite }
29
+ | { readonly kind: "uncheck"; readonly file: string }
30
+ | { readonly kind: "command"; readonly command: string; readonly onFailure?: string }
31
+ | { readonly kind: "outcome"; readonly outcome: Outcome }
@@ -0,0 +1,29 @@
1
+ import type { CommitSpec } from "../replay/index.js"
2
+ import type { PendingChange, StateName, StepDef } from "../workflow/index.js"
3
+
4
+ /**
5
+ * What landing the pending turn does, decided at the edge by replaying the
6
+ * episode with the working tree as the pending turn.
7
+ */
8
+ export type Landing =
9
+ | { readonly kind: "refusal"; readonly message: string }
10
+ /** Nothing to land. `settled` when re-running the rest cannot change that. */
11
+ | { readonly kind: "noop"; readonly settled: boolean }
12
+ /** An empty agent turn: recorded, but the process stays at the step. */
13
+ | { readonly kind: "attempt"; readonly subject: string }
14
+ | { readonly kind: "commit"; readonly to: StateName; readonly spec: CommitSpec }
15
+
16
+ /**
17
+ * Everything `planStep` needs, already resolved
18
+ * from git/config/workspace ONCE, before any perform — a frozen value cannot
19
+ * observe a perform.
20
+ */
21
+ export interface RepoSnapshot {
22
+ readonly stepDef: StepDef
23
+ readonly state: StateName
24
+ readonly actor: string
25
+ readonly changes: readonly PendingChange[]
26
+ /** The resting step's steering file, `undefined` when it declares none. */
27
+ readonly file: string | undefined
28
+ readonly landing: Landing
29
+ }
@@ -0,0 +1,58 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { ACCESS_REFUSAL } from "../wire/index.js"
3
+ import { accessRefusal } from "./accessRefusal.js"
4
+
5
+ describe("accessRefusal", () => {
6
+ it("allows changes inside the write globs", () => {
7
+ expect(
8
+ accessRefusal(
9
+ [
10
+ { status: "M", path: ".gtd/REVIEW.md" },
11
+ { status: "A", path: "docs/deep/a.md" },
12
+ ],
13
+ [".gtd/REVIEW.md", "docs/**"],
14
+ ),
15
+ ).toBeUndefined()
16
+ })
17
+
18
+ it("refuses a path outside, naming it and the allowed globs", () => {
19
+ const message = accessRefusal(
20
+ [
21
+ { status: "M", path: "src/foo.ts" },
22
+ { status: "M", path: ".gtd/REVIEW.md" },
23
+ ],
24
+ [".gtd/REVIEW.md"],
25
+ )
26
+ expect(message).toBe(`${ACCESS_REFUSAL}\n - src/foo.ts\nallowed: .gtd/REVIEW.md`)
27
+ })
28
+
29
+ it("refuses a delete outside", () => {
30
+ expect(accessRefusal([{ status: "D", path: "src/gone.ts" }], ["docs/**"])).toContain(
31
+ " - src/gone.ts",
32
+ )
33
+ })
34
+
35
+ it("refuses only the side of a rename that lands outside the write globs", () => {
36
+ const message = accessRefusal(
37
+ [
38
+ { status: "D", path: "docs/a.md" },
39
+ { status: "A", path: "src/a.md" },
40
+ ],
41
+ ["docs/**"],
42
+ )
43
+ expect(message).toContain(" - src/a.md")
44
+ expect(message).not.toContain(" - docs/a.md")
45
+ })
46
+
47
+ it("never refuses a null write", () => {
48
+ expect(accessRefusal([{ status: "M", path: "anything" }], null)).toBeUndefined()
49
+ })
50
+
51
+ it("says nothing is allowed for an empty list", () => {
52
+ expect(accessRefusal([{ status: "A", path: "x" }], [])).toContain("allowed: (nothing)")
53
+ })
54
+
55
+ it("starts with the marker line", () => {
56
+ expect(accessRefusal([{ status: "A", path: "x" }], ["y"])?.split("\n")[0]).toBe(ACCESS_REFUSAL)
57
+ })
58
+ })
@@ -0,0 +1,26 @@
1
+ import { globMatches } from "../wire/index.js"
2
+ import type { PendingChange } from "../workflow/index.js"
3
+ import { ACCESS_REFUSAL } from "../wire/index.js"
4
+
5
+ /**
6
+ * The landing refusal for changes outside `write`, or `undefined` when every
7
+ * path is allowed. `null` is unrestricted. The message is the fix prompt: gtd
8
+ * runs no fix turn of its own.
9
+ */
10
+ export const accessRefusal = (
11
+ changes: readonly PendingChange[],
12
+ write: readonly string[] | null,
13
+ ): string | undefined => {
14
+ if (write === null) return undefined
15
+ const offending = [
16
+ ...new Set(
17
+ changes.filter((c) => !write.some((glob) => globMatches(c.path, glob))).map((c) => c.path),
18
+ ),
19
+ ]
20
+ if (offending.length === 0) return undefined
21
+ return [
22
+ ACCESS_REFUSAL,
23
+ ...offending.map((path) => ` - ${path}`),
24
+ `allowed: ${write.length === 0 ? "(nothing)" : write.join(", ")}`,
25
+ ].join("\n")
26
+ }
@@ -0,0 +1,5 @@
1
+ export { planStep, type JudgeVerdict } from "./planStep.js"
2
+ export { accessRefusal } from "./accessRefusal.js"
3
+ export { planStart } from "./planStart.js"
4
+ export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
5
+ export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
@@ -0,0 +1,202 @@
1
+ import { Effect } from "effect"
2
+ import { describe, expect, it } from "vitest"
3
+ import { planStart, type StartOutcome } from "./planStart.js"
4
+ import { InMemRepo, testLayers } from "../testkit/index.js"
5
+ import { ConfigService } from "../workflow/index.js"
6
+
7
+ const WORKFLOW = `import { agent, human } from "@pmelab/gtd/flows"
8
+
9
+ export default async () => {
10
+ await human("idle", { message: "hi" })
11
+ await agent("work", "work-prompt")
12
+ }
13
+
14
+ export const working = async () => {
15
+ await agent("work", "work-prompt")
16
+ }
17
+
18
+ export const reviewcheck = async () => {
19
+ await agent("work", "work-prompt")
20
+ }
21
+
22
+ export const defaults = { base: "" }
23
+
24
+ export const base = (workflow, vars) => (workflow === "reviewcheck" ? (vars.base ?? "") : undefined)
25
+ `
26
+
27
+ const repoAt = (): InMemRepo => {
28
+ const repo = new InMemRepo()
29
+ repo.writeFile("gtd.config.ts", WORKFLOW)
30
+ repo.commitAllWithPrefix("chore: add custom workflow")
31
+ return repo
32
+ }
33
+
34
+ /** Loads the real compiled workflows (via `ConfigService`, same as `Edge.ts`'s `restAt`) so `planStart`'s checks see the loaded workflow, then runs `planStart` against it. */
35
+ const start = (
36
+ repo: InMemRepo,
37
+ state: string,
38
+ actor: string,
39
+ req: {
40
+ readonly workflow: string
41
+ readonly commandLabel: string
42
+ readonly vars: Record<string, string>
43
+ },
44
+ startRefusal?: string,
45
+ ): Promise<StartOutcome> =>
46
+ Effect.runPromise(
47
+ Effect.gen(function* () {
48
+ const config = yield* (yield* ConfigService).load
49
+ const workflow = config.workflowNamed(req.workflow)
50
+ if (workflow === undefined) throw new Error(`no workflow ${req.workflow}`)
51
+ return yield* planStart({ state, idle: state === config.workflow.initial }, actor, {
52
+ workflow,
53
+ first: startRefusal === undefined ? { firstStep: "work" } : { refusal: startRefusal },
54
+ commandLabel: req.commandLabel,
55
+ vars: req.vars,
56
+ config: {},
57
+ })
58
+ }).pipe(Effect.provide(testLayers(repo))),
59
+ )
60
+
61
+ describe("planStart", () => {
62
+ it("refuses when a process is already underway", async () => {
63
+ const repo = repoAt()
64
+ const plan = await start(repo, "working", "human", {
65
+ workflow: "working",
66
+ commandLabel: "gtd test",
67
+ vars: {},
68
+ })
69
+ expect(plan.kind).toBe("refusal")
70
+ expect(plan.kind === "refusal" && plan.message).toContain("already underway")
71
+ })
72
+
73
+ it("refuses, under the command's label, a workflow that will not start", async () => {
74
+ const repo = repoAt()
75
+ const plan = await start(
76
+ repo,
77
+ "idle",
78
+ "human",
79
+ { workflow: "working", commandLabel: "gtd test", vars: {} },
80
+ 'workflow "working" cannot start — it reaches no step',
81
+ )
82
+ expect(plan).toEqual({
83
+ kind: "refusal",
84
+ message: 'gtd test: workflow "working" cannot start — it reaches no step',
85
+ })
86
+ })
87
+
88
+ it("refuses an undeclared --var name", async () => {
89
+ const repo = repoAt()
90
+ const plan = await start(repo, "idle", "human", {
91
+ workflow: "working",
92
+ commandLabel: "gtd test",
93
+ vars: { nope: "x" },
94
+ })
95
+ expect(plan.kind).toBe("refusal")
96
+ expect(plan.kind === "refusal" && plan.message).toContain("not declared by this workflow")
97
+ })
98
+
99
+ describe("a workflow's diff base", () => {
100
+ const REVIEW = { workflow: "reviewcheck", commandLabel: "gtd test" }
101
+
102
+ it("resolves a blank base to the default branch's merge-base with HEAD", async () => {
103
+ const repo = repoAt()
104
+ const fork = repo.resolveRef("HEAD")!
105
+ repo.updateRef("main", fork)
106
+ repo.writeFile("a.txt", "a")
107
+ repo.commitAllWithPrefix("feat: ahead of main")
108
+ const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
109
+ if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
110
+ const write = plan.steps.find((s) => s.kind === "gitWrite")
111
+ if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
112
+ expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
113
+ })
114
+
115
+ it("pins the merge-base, not the named base, when the base has diverged", async () => {
116
+ const repo = repoAt()
117
+ const fork = repo.resolveRef("HEAD")!
118
+ repo.writeFile("other.txt", "o")
119
+ repo.commitAllWithPrefix("feat: other branch")
120
+ repo.updateRef("other", repo.resolveRef("HEAD")!)
121
+ repo.hardResetTo(fork)
122
+ repo.writeFile("mine.txt", "m")
123
+ repo.commitAllWithPrefix("feat: my branch")
124
+ const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "other" } })
125
+ if (plan.kind !== "start") throw new Error(`expected start, got ${JSON.stringify(plan)}`)
126
+ const write = plan.steps.find((s) => s.kind === "gitWrite")
127
+ if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
128
+ expect(write.write.message).toContain(`Gtd-Review-Base: ${fork}`)
129
+ })
130
+
131
+ it("refuses a base that does not resolve", async () => {
132
+ const plan = await start(repoAt(), "idle", "human", { ...REVIEW, vars: { base: "nope" } })
133
+ expect(plan).toEqual({
134
+ kind: "refusal",
135
+ message: 'gtd test: "nope" does not resolve to a commit',
136
+ })
137
+ })
138
+
139
+ it("refuses a base that shares no common ancestor with HEAD", async () => {
140
+ const repo = repoAt()
141
+ repo.updateRef("old-root", repo.resolveRef("HEAD")!)
142
+ repo.mixedResetTo("4b825dc642cb6eb9a060e54bf8d69288fbee4904")
143
+ repo.writeFile("gtd.config.ts", WORKFLOW)
144
+ repo.commitAllWithPrefix("chore: a second root")
145
+ const plan = await start(repo, "idle", "human", { ...REVIEW, vars: { base: "old-root" } })
146
+ expect(plan).toEqual({
147
+ kind: "refusal",
148
+ message: 'gtd test: "old-root" shares no common ancestor with HEAD',
149
+ })
150
+ })
151
+
152
+ it("refuses when HEAD has no commits beyond the base", async () => {
153
+ const repo = repoAt()
154
+ const plan = await start(repo, "idle", "human", { ...REVIEW, vars: {} })
155
+ expect(plan).toEqual({
156
+ kind: "refusal",
157
+ message: "gtd test: nothing to review: HEAD has no commits beyond main",
158
+ })
159
+ })
160
+ })
161
+
162
+ it("succeeds and emits a commitAll + commit-outcome LandStep pair, data only", async () => {
163
+ const repo = repoAt()
164
+ const plan = await start(repo, "idle", "human", {
165
+ workflow: "working",
166
+ commandLabel: "gtd test",
167
+ vars: {},
168
+ })
169
+ if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
170
+ expect(plan.subject).toBe("gtd(human): work")
171
+ expect(plan.steps).toEqual([
172
+ {
173
+ kind: "gitWrite",
174
+ write: {
175
+ kind: "commitAll",
176
+ message: "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=\nGtd-Format: 1",
177
+ },
178
+ },
179
+ { kind: "outcome", outcome: { kind: "commit", subject: "gtd(human): work" } },
180
+ ])
181
+ })
182
+
183
+ it("folds --var overrides into a Gtd-Var: trailer on the commit message, never the outcome subject", async () => {
184
+ const repo = repoAt()
185
+ const plan = await start(repo, "idle", "human", {
186
+ workflow: "working",
187
+ commandLabel: "gtd test",
188
+ vars: { base: "main" },
189
+ })
190
+ if (plan.kind !== "start") throw new Error(`expected start, got ${plan.kind}`)
191
+ const write = plan.steps.find((s) => s.kind === "gitWrite")
192
+ if (write?.kind !== "gitWrite") throw new Error("expected a gitWrite step")
193
+ expect(write.write.message).toBe(
194
+ "gtd(human): work\n\nGtd-Workflow: working\nGtd-Var: base=main\nGtd-Format: 1",
195
+ )
196
+ const outcome = plan.steps.find((s) => s.kind === "outcome")
197
+ expect(outcome).toEqual({
198
+ kind: "outcome",
199
+ outcome: { kind: "commit", subject: "gtd(human): work" },
200
+ })
201
+ })
202
+ })
@@ -0,0 +1,167 @@
1
+ import { Effect, Option } from "effect"
2
+ import { GtdUsageError, Narrator, GitService, Host, Workspace } from "../platform/index.js"
3
+ import {
4
+ ConfigDiscovery,
5
+ ConfigService,
6
+ multilineSetting,
7
+ resolveVars,
8
+ type ConfigFingerprint,
9
+ type ResolvedWorkflow,
10
+ } from "../workflow/index.js"
11
+ import { formatCommitMessage, formatSubject } from "../replay/index.js"
12
+ import type { LandStep } from "./LandStep.js"
13
+
14
+ /** Just enough of the current rest for `planStart`'s checks. */
15
+ export interface StartCurrent {
16
+ /** The step the process rests at. */
17
+ readonly state: string
18
+ /** Whether no process is underway: the default workflow's first step, first visit. */
19
+ readonly idle: boolean
20
+ }
21
+
22
+ export type StartOutcome =
23
+ | { readonly kind: "refusal"; readonly message: string }
24
+ | {
25
+ readonly kind: "start"
26
+ readonly workflow: string
27
+ readonly subject: string
28
+ /** The `LandStep`s a driver runs to write the opening commit — data, never shell text. */
29
+ readonly steps: readonly LandStep[]
30
+ }
31
+
32
+ type Refused = Extract<StartOutcome, { kind: "refusal" }>
33
+
34
+ const refusal = (message: string): Refused => ({ kind: "refusal", message })
35
+
36
+ const checkOverrides = (
37
+ commandLabel: string,
38
+ varOverrides: Record<string, string>,
39
+ declaredNames: readonly string[],
40
+ envNames: readonly string[],
41
+ ): Effect.Effect<StartOutcome | undefined, Error> =>
42
+ Effect.gen(function* () {
43
+ const environmentOnly = Object.keys(varOverrides).filter(
44
+ (n) => !declaredNames.includes(n) && envNames.includes(n),
45
+ )
46
+ if (environmentOnly.length > 0) {
47
+ return yield* Effect.fail(
48
+ new GtdUsageError(
49
+ `${commandLabel}: --var pins process settings only; ${environmentOnly.join(", ")} ${
50
+ environmentOnly.length === 1 ? "is an environment setting" : "are environment settings"
51
+ } — set it as a workflow option in gtd.config.ts or with a GTD_<NAME> environment variable`,
52
+ ),
53
+ )
54
+ }
55
+ const undeclared = Object.keys(varOverrides).filter((n) => !declaredNames.includes(n))
56
+ if (undeclared.length > 0) {
57
+ return refusal(
58
+ `${commandLabel}: --var name(s) not declared by this workflow: ${undeclared.join(
59
+ ", ",
60
+ )} — declared: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}`,
61
+ )
62
+ }
63
+ return undefined
64
+ })
65
+
66
+ /**
67
+ * `gtd --workflow <name>`: start a brand NEW process on a workflow, writing an
68
+ * opening commit that names the workflow's first step and carries a
69
+ * `Gtd-Workflow:` trailer, every process setting as a `Gtd-Var:` trailer, plus
70
+ * a `Gtd-Review-Base:` trailer when the workflow fixes the process's diff base,
71
+ * and a `Gtd-Config:` trailer per config file.
72
+ * All validation is a REFUSAL, not an Effect failure.
73
+ */
74
+ export const planStart = (
75
+ current: StartCurrent,
76
+ actor: string,
77
+ start: {
78
+ readonly workflow: ResolvedWorkflow
79
+ /** The workflow's first step, or why it will not start a process. */
80
+ readonly first: { readonly firstStep: string } | { readonly refusal: string }
81
+ readonly commandLabel: string
82
+ readonly vars: Record<string, string>
83
+ readonly config: ConfigFingerprint
84
+ },
85
+ ): Effect.Effect<
86
+ StartOutcome,
87
+ Error,
88
+ GitService | ConfigService | ConfigDiscovery | Workspace | Host | Narrator
89
+ > =>
90
+ Effect.gen(function* () {
91
+ const { workflow, first, commandLabel, vars: varOverrides } = start
92
+ const name = workflow.name
93
+
94
+ if (!current.idle) {
95
+ return refusal(
96
+ `${commandLabel}: a process is already underway (resting at "${current.state}") — finish it, or run \`gtd abandon\`, before starting another`,
97
+ )
98
+ }
99
+
100
+ if ("refusal" in first) return refusal(`${commandLabel}: ${first.refusal}`)
101
+ const { firstStep } = first
102
+
103
+ const overrideRefusal = yield* checkOverrides(
104
+ commandLabel,
105
+ varOverrides,
106
+ Object.keys(workflow.vars),
107
+ Object.keys(workflow.env),
108
+ )
109
+ if (overrideRefusal !== undefined) return overrideRefusal
110
+
111
+ const baseOf = workflow.def.base
112
+ const vars = resolveVars(workflow.vars, varOverrides, (yield* Host).env)
113
+ const multiline = multilineSetting(vars)
114
+ if (multiline !== undefined) {
115
+ return refusal(
116
+ `${commandLabel}: process setting "${multiline}" spans several lines — a process setting is pinned in a commit trailer and must be a single line`,
117
+ )
118
+ }
119
+ const template = yield* Effect.try({
120
+ try: () => baseOf?.(name, vars),
121
+ catch: (e) => new Error(`${commandLabel}: ${e instanceof Error ? e.message : String(e)}`),
122
+ })
123
+ const base = template === undefined ? undefined : yield* resolveBase(commandLabel, template)
124
+ if (typeof base === "object") return base
125
+
126
+ const subject = formatSubject(actor, firstStep)
127
+ const message = formatCommitMessage({
128
+ actor,
129
+ to: firstStep,
130
+ workflow: name,
131
+ ...(base !== undefined ? { reviewBase: base } : {}),
132
+ vars,
133
+ config: start.config,
134
+ })
135
+ const steps: readonly LandStep[] = [
136
+ { kind: "gitWrite", write: { kind: "commitAll", message } },
137
+ { kind: "outcome", outcome: { kind: "commit", subject } },
138
+ ]
139
+ return { kind: "start", workflow: name, subject, steps } as const
140
+ })
141
+
142
+ /**
143
+ * The workflow's diff base: `git merge-base <base> HEAD`, where a blank base
144
+ * means the default branch. The merge-base is what gets pinned, so a base on
145
+ * another branch reviews exactly this branch's own commits.
146
+ */
147
+ const resolveBase = (
148
+ commandLabel: string,
149
+ value: string,
150
+ ): Effect.Effect<string | Refused, Error, GitService> =>
151
+ Effect.gen(function* () {
152
+ const git = yield* GitService
153
+ const rendered = value.trim()
154
+ const target = rendered === "" ? yield* git.defaultBranch() : rendered
155
+ const resolvedBase = yield* Effect.either(git.resolveRef(target))
156
+ if (resolvedBase._tag === "Left") {
157
+ return refusal(`${commandLabel}: "${target}" does not resolve to a commit`)
158
+ }
159
+ const mergeBase = yield* git.mergeBase(resolvedBase.right, "HEAD")
160
+ if (Option.isNone(mergeBase)) {
161
+ return refusal(`${commandLabel}: "${target}" shares no common ancestor with HEAD`)
162
+ }
163
+ if (mergeBase.value === (yield* git.resolveRef("HEAD"))) {
164
+ return refusal(`${commandLabel}: nothing to review: HEAD has no commits beyond ${target}`)
165
+ }
166
+ return mergeBase.value
167
+ })
@@ -0,0 +1,104 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { planStep } from "./planStep.js"
3
+ import { snapshot } from "./snapshot.fixture.js"
4
+
5
+ const commitTo = (from: string, to: string, actor = "human") =>
6
+ ({
7
+ kind: "commit",
8
+ to,
9
+ spec: { actor, from, to, step: { name: from, occurrence: 1 } },
10
+ }) as const
11
+
12
+ describe("planStep — refusal and no-op pass through", () => {
13
+ it("a refused landing becomes a refusal outcome", () => {
14
+ const s = snapshot({
15
+ state: "building",
16
+ stepDef: {},
17
+ landing: { kind: "refusal", message: "gtd land: nope" },
18
+ })
19
+ expect(planStep(s)).toEqual({ kind: "refusal", message: "gtd land: nope" })
20
+ })
21
+
22
+ it("a no-op keeps its settled flag", () => {
23
+ const s = snapshot({ state: "gate", stepDef: {}, landing: { kind: "noop", settled: true } })
24
+ expect(planStep(s)).toEqual({ kind: "noop", state: "gate", settled: true })
25
+ })
26
+ })
27
+
28
+ describe("planStep — commit", () => {
29
+ it("an attempt commits the subject with no step trailer and bypasses guards", () => {
30
+ const s = snapshot({
31
+ state: "await-answers",
32
+ stepDef: { actor: "agent", kind: "prompt", mode: "qa" },
33
+ actor: "agent",
34
+ landing: { kind: "attempt", subject: "gtd(agent): await-answers" },
35
+ })
36
+ const outcome = planStep(s)
37
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
38
+ expect(outcome.to).toBe("await-answers")
39
+ expect(outcome.steps).toEqual([
40
+ {
41
+ kind: "gitWrite",
42
+ write: { kind: "commitAll", message: "gtd(agent): await-answers\n\nGtd-Format: 1" },
43
+ },
44
+ { kind: "outcome", outcome: { kind: "commit", subject: "gtd(agent): await-answers" } },
45
+ ])
46
+ })
47
+
48
+ it("a transition commit carries the step trailer and reports both steps", () => {
49
+ const s = snapshot({
50
+ state: "building",
51
+ stepDef: {},
52
+ changes: [{ status: "M", path: ".gtd/FILE.md" }],
53
+ landing: commitTo("building", "done"),
54
+ })
55
+ const outcome = planStep(s)
56
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
57
+ expect(outcome.subject).toBe("gtd(human): building → done")
58
+ expect(outcome.steps[0]).toEqual({
59
+ kind: "gitWrite",
60
+ write: {
61
+ kind: "commitAll",
62
+ message: "gtd(human): building → done\n\nGtd-Step: building#1\nGtd-Format: 1",
63
+ },
64
+ })
65
+ expect(outcome.steps.at(-1)).toEqual({
66
+ kind: "outcome",
67
+ outcome: { kind: "transition", from: "building", to: "done" },
68
+ })
69
+ })
70
+
71
+ it("prepends `gtd uncheck` at the human review gate", () => {
72
+ const s = snapshot({
73
+ state: "await-review",
74
+ stepDef: { mode: "review", file: ".gtd/REVIEW.md" },
75
+ changes: [{ status: "M", path: "src/a.ts" }],
76
+ landing: commitTo("await-review", "deciding"),
77
+ })
78
+ const outcome = planStep(s)
79
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
80
+ expect(outcome.steps[0]).toEqual({ kind: "uncheck", file: ".gtd/REVIEW.md" })
81
+ })
82
+
83
+ it("records Gtd-Cost, then one Gtd-Judge per verdict", () => {
84
+ const s = snapshot({ state: "a", stepDef: {}, landing: commitTo("a", "b", "judge") })
85
+ const outcome = planStep(s, {
86
+ cost: 1.5,
87
+ model: "m",
88
+ judge: [
89
+ { id: "q1", answer: true, p: 0.9 },
90
+ { id: "q2", answer: "x", p: 0.4 },
91
+ ],
92
+ })
93
+ if (outcome.kind !== "commit") throw new Error(`expected commit, got ${outcome.kind}`)
94
+ const write = outcome.steps.find((step) => step.kind === "gitWrite")
95
+ if (write?.kind !== "gitWrite") throw new Error("expected a git write")
96
+ const trailers = write.write.message.split("\n").slice(2)
97
+ expect(trailers[0]).toBe("Gtd-Step: a#1")
98
+ expect(trailers[1]).toBe("Gtd-Cost: 1.5 m")
99
+ expect(trailers[2]).toBe('Gtd-Judge: {"id":"q1","answer":true,"p":0.9}')
100
+ expect(trailers[3]).toBe('Gtd-Judge: {"id":"q2","answer":"x","p":0.4}')
101
+ expect(trailers[4]).toBe("Gtd-Format: 1")
102
+ expect(trailers).toHaveLength(5)
103
+ })
104
+ })