@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,22 @@
1
+ // Zero imports on purpose: a leaf both `Edge.ts` and this module's siblings
2
+ // can import, so `"unspecified"` exists exactly once in the codebase.
3
+
4
+ /**
5
+ * The top-level `schema` of every JSON document a driver reads. Bumped only
6
+ * when a field is removed or renamed — never for an added one — and never tied
7
+ * to the package version, since a driver can be installed apart from gtd.
8
+ */
9
+ export const WIRE_SCHEMA = 1
10
+
11
+ /** The bucket a cost with no `--model` tag is grouped under, kept distinct so a mixed history still totals correctly. */
12
+ export const UNATTRIBUTED_MODEL = "unspecified"
13
+
14
+ /** The plain-text twin of `OutcomeScript.ts`'s `printfLine`. Lives here so `src/wire/`'s encoders reach it without importing out of the leaf. */
15
+ export const renderFormat = (fmt: string, ...args: readonly string[]): string => {
16
+ let i = 0
17
+ return fmt.replace(/%s/g, () => args[i++] ?? "")
18
+ }
19
+
20
+ /** The first line of the landing refusal for a turn that wrote outside its `write` access — a stable marker drivers and tests grep for. */
21
+ export const ACCESS_REFUSAL =
22
+ "gtd land: this turn wrote outside its write access — revert these paths, then run `gtd land` again:"
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 1,
3
+ "kind": "capture",
4
+ "content": "fix it",
5
+ "idle": false,
6
+ "initial": false,
7
+ "model": "opus",
8
+ "system": "You are a careful senior engineer.",
9
+ "log": ".git/gtd-loop.log",
10
+ "state": "build.fixing",
11
+ "actor": "agent",
12
+ "workflow": "feature",
13
+ "label": "Fixing",
14
+ "memory": "build#a1b2c3d",
15
+ "file": "TODO.md",
16
+ "mode": "qa",
17
+ "changes": [
18
+ {
19
+ "status": "M",
20
+ "path": "TODO.md"
21
+ }
22
+ ],
23
+ "next": {
24
+ "target": "idle"
25
+ },
26
+ "cost": 12,
27
+ "costByModel": [
28
+ {
29
+ "model": "opus",
30
+ "cost": 12
31
+ }
32
+ ],
33
+ "judge": "{\"schema\":1,\"state\":{\"diff\":\"…\"},\"questions\":[{\"id\":\"q1\",\"primitive\":\"noul\",\"instructions\":\"Is the change done?\",\"criteria\":\"yes: every item ticked\"}]}"
34
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 1,
3
+ "kind": "message",
4
+ "content": "fix it",
5
+ "idle": false,
6
+ "initial": false,
7
+ "model": "opus",
8
+ "system": "You are a careful senior engineer.",
9
+ "log": ".git/gtd-loop.log",
10
+ "state": "build.fixing",
11
+ "actor": "agent",
12
+ "workflow": "feature",
13
+ "label": "Fixing",
14
+ "memory": "build#a1b2c3d",
15
+ "file": "TODO.md",
16
+ "mode": "qa",
17
+ "changes": [
18
+ {
19
+ "status": "M",
20
+ "path": "TODO.md"
21
+ }
22
+ ],
23
+ "next": {
24
+ "target": "idle"
25
+ },
26
+ "cost": 12,
27
+ "costByModel": [
28
+ {
29
+ "model": "opus",
30
+ "cost": 12
31
+ }
32
+ ],
33
+ "judge": "{\"schema\":1,\"state\":{\"diff\":\"…\"},\"questions\":[{\"id\":\"q1\",\"primitive\":\"noul\",\"instructions\":\"Is the change done?\",\"criteria\":\"yes: every item ticked\"}]}"
34
+ }
@@ -0,0 +1,44 @@
1
+ {
2
+ "schema": 1,
3
+ "kind": "prompt",
4
+ "content": "fix it",
5
+ "idle": false,
6
+ "initial": false,
7
+ "session": {
8
+ "id": "8f2c",
9
+ "resume": true
10
+ },
11
+ "model": "opus",
12
+ "system": "You are a careful senior engineer.",
13
+ "validate": "gtd check qa 'TODO.md'",
14
+ "log": ".git/gtd-loop.log",
15
+ "state": "build.fixing",
16
+ "actor": "agent",
17
+ "workflow": "feature",
18
+ "label": "Fixing",
19
+ "memory": "build#a1b2c3d",
20
+ "file": "TODO.md",
21
+ "mode": "qa",
22
+ "changes": [
23
+ {
24
+ "status": "M",
25
+ "path": "TODO.md"
26
+ }
27
+ ],
28
+ "next": {
29
+ "target": "idle"
30
+ },
31
+ "cost": 12,
32
+ "costByModel": [
33
+ {
34
+ "model": "opus",
35
+ "cost": 12
36
+ }
37
+ ],
38
+ "judge": "{\"schema\":1,\"state\":{\"diff\":\"…\"},\"questions\":[{\"id\":\"q1\",\"primitive\":\"noul\",\"instructions\":\"Is the change done?\",\"criteria\":\"yes: every item ticked\"}]}",
39
+ "skills": ["code-review"],
40
+ "access": {
41
+ "read": null,
42
+ "write": [".gtd/TODO.md"]
43
+ }
44
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 1,
3
+ "kind": "script",
4
+ "content": "fix it",
5
+ "idle": false,
6
+ "initial": false,
7
+ "model": "opus",
8
+ "system": "You are a careful senior engineer.",
9
+ "log": ".git/gtd-loop.log",
10
+ "state": "build.fixing",
11
+ "actor": "agent",
12
+ "workflow": "feature",
13
+ "label": "Fixing",
14
+ "memory": "build#a1b2c3d",
15
+ "file": "TODO.md",
16
+ "mode": "qa",
17
+ "changes": [
18
+ {
19
+ "status": "M",
20
+ "path": "TODO.md"
21
+ }
22
+ ],
23
+ "next": {
24
+ "target": "idle"
25
+ },
26
+ "cost": 12,
27
+ "costByModel": [
28
+ {
29
+ "model": "opus",
30
+ "cost": 12
31
+ }
32
+ ],
33
+ "judge": "{\"schema\":1,\"state\":{\"diff\":\"…\"},\"questions\":[{\"id\":\"q1\",\"primitive\":\"noul\",\"instructions\":\"Is the change done?\",\"criteria\":\"yes: every item ticked\"}]}"
34
+ }
@@ -0,0 +1,34 @@
1
+ {
2
+ "schema": 1,
3
+ "kind": "stalled",
4
+ "content": "stalled at \"build.fixing\": the last gtd(agent): build.fixing turn landed an empty attempt, the tree is clean, and another dispatch would repeat it.\n\nTwo ways out:\n - sharpen the step's prompt so the turn has something concrete to author\n - pass allowEmpty: true to the step, so an empty turn completes it and the\n flow decides what follows — finishing, or an escalation step after N\n empty turns\n",
5
+ "idle": false,
6
+ "initial": false,
7
+ "model": "opus",
8
+ "system": "You are a careful senior engineer.",
9
+ "log": ".git/gtd-loop.log",
10
+ "state": "build.fixing",
11
+ "actor": "agent",
12
+ "workflow": "feature",
13
+ "label": "Fixing",
14
+ "memory": "build#a1b2c3d",
15
+ "file": "TODO.md",
16
+ "mode": "qa",
17
+ "changes": [
18
+ {
19
+ "status": "M",
20
+ "path": "TODO.md"
21
+ }
22
+ ],
23
+ "next": {
24
+ "target": "idle"
25
+ },
26
+ "cost": 12,
27
+ "costByModel": [
28
+ {
29
+ "model": "opus",
30
+ "cost": 12
31
+ }
32
+ ],
33
+ "judge": "{\"schema\":1,\"state\":{\"diff\":\"…\"},\"questions\":[{\"id\":\"q1\",\"primitive\":\"noul\",\"instructions\":\"Is the change done?\",\"criteria\":\"yes: every item ticked\"}]}"
34
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "schema": 1,
3
+ "state": {
4
+ "diff": "…"
5
+ },
6
+ "questions": [
7
+ {
8
+ "id": "q1",
9
+ "primitive": "noul",
10
+ "instructions": "Is the change done?",
11
+ "criteria": "yes: every item ticked"
12
+ }
13
+ ]
14
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": 1,
3
+ "script": "printf '%s\\n' 'nothing to do at \"idle\"'\n",
4
+ "settled": true,
5
+ "idle": true,
6
+ "state": "idle",
7
+ "subject": null,
8
+ "cost": null,
9
+ "model": null
10
+ }
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": 1,
3
+ "script": "git commit ...\n",
4
+ "settled": false,
5
+ "idle": false,
6
+ "state": "build.review.closing",
7
+ "subject": "gtd(agent): build.fixing",
8
+ "cost": 0.42,
9
+ "model": "smart"
10
+ }
@@ -0,0 +1,12 @@
1
+ export * from "./constants.js"
2
+ export * from "./ExitCodes.js"
3
+ export * from "./types.js"
4
+ export * from "./Demand.js"
5
+ export * from "./BeatStatus.js"
6
+ export * from "./BeatDocument.js"
7
+ export * from "./BeatPlain.js"
8
+ export * from "./Land.js"
9
+ export * from "./Judge.js"
10
+ export { selectPath } from "./Select.js"
11
+ export { resolveSession } from "./Sessions.js"
12
+ export { globMatches } from "./Glob.js"
@@ -0,0 +1,46 @@
1
+ // Zero imports on purpose — this is the wire's own vocabulary tier, so
2
+ // `wire` stays a leaf every other module may import.
3
+
4
+ /** No closed vocabulary of "kinds" — any workflow-defined string. */
5
+ export type Actor = string
6
+
7
+ /** Defined by whatever keys `WorkflowDefinition.states` declares — not a closed vocabulary. */
8
+ export type StateName = string
9
+
10
+ /** The three content kinds a state can carry — exactly one per state. */
11
+ export type ContentKind = "script" | "prompt" | "message"
12
+
13
+ /** The name of a steering-file mode. Not a closed vocabulary: the valid set derives from the active definition (`BUILT_IN_MODES` plus whatever `modes:` declares). */
14
+ export type StateMode = string
15
+
16
+ /** One model's summed token cost — the shape `gtd summary`'s template iterates as `it.processCostByModel`, and `gtd next --json`'s `costByModel` entries. */
17
+ export interface ModelCost {
18
+ readonly model: string
19
+ readonly cost: number
20
+ }
21
+
22
+ /**
23
+ * The subset of `Edge.ts`'s `RenderedRest` the beat builders below read,
24
+ * expressed structurally so this module never imports `RenderedRest` itself
25
+ * — that import (even type-only) would be a Sheriff edge back to root.
26
+ * `RenderedRest` satisfies this shape today and must keep doing so.
27
+ */
28
+ export interface StepAccess {
29
+ readonly read: readonly string[] | null
30
+ readonly write: readonly string[] | null
31
+ }
32
+
33
+ export interface RenderedDemandSource {
34
+ readonly state: StateName
35
+ readonly actor: Actor
36
+ readonly content: string
37
+ readonly model?: string
38
+ readonly system?: string
39
+ readonly label?: string
40
+ readonly memory?: string
41
+ readonly file?: string
42
+ readonly mode?: StateMode
43
+ readonly judge?: string
44
+ readonly skills?: readonly string[]
45
+ readonly access?: StepAccess
46
+ }
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ compareConfigPaths,
4
+ dedupeDiagnostics,
5
+ formatDiagnostic,
6
+ sortDiagnostics,
7
+ type Diagnostic,
8
+ } from "./Diagnostic.js"
9
+
10
+ const diag = (over: Partial<Diagnostic>): Diagnostic => ({
11
+ severity: "error",
12
+ message: "boom",
13
+ path: [],
14
+ origin: "/repo/gtd.config.ts",
15
+ ...over,
16
+ })
17
+
18
+ describe("compareConfigPaths", () => {
19
+ it("orders numeric segments numerically, not lexicographically", () => {
20
+ expect(compareConfigPaths(["on", 2], ["on", 10])).toBeLessThan(0)
21
+ })
22
+
23
+ it("orders string segments lexicographically", () => {
24
+ expect(compareConfigPaths(["a"], ["b"])).toBeLessThan(0)
25
+ })
26
+
27
+ it("orders a shorter path before a longer one sharing a common prefix", () => {
28
+ expect(
29
+ compareConfigPaths(["machines", "design"], ["machines", "design", "states"]),
30
+ ).toBeLessThan(0)
31
+ })
32
+
33
+ it("is zero for identical paths", () => {
34
+ expect(compareConfigPaths(["machines", "design"], ["machines", "design"])).toBe(0)
35
+ })
36
+ })
37
+
38
+ describe("sortDiagnostics", () => {
39
+ it("sorts by config path", () => {
40
+ const a = diag({ path: ["on", 2] })
41
+ const b = diag({ path: ["on", 10] })
42
+ expect(sortDiagnostics([b, a])).toEqual([a, b])
43
+ })
44
+ })
45
+
46
+ describe("dedupeDiagnostics", () => {
47
+ it("collapses two findings identical in all four fields to one", () => {
48
+ const first = diag({ origin: "/repo/gtd.config.ts" })
49
+ const second = diag({ origin: "/repo/gtd.config.ts" })
50
+ expect(dedupeDiagnostics([first, second])).toEqual([first])
51
+ })
52
+
53
+ it("keeps two findings that differ only by path", () => {
54
+ const a = diag({ path: ["a"] })
55
+ const b = diag({ path: ["b"] })
56
+ expect(dedupeDiagnostics([a, b])).toEqual([a, b])
57
+ })
58
+
59
+ it("keeps two findings that differ only by severity", () => {
60
+ const a = diag({ severity: "error" })
61
+ const b = diag({ severity: "warning" })
62
+ expect(dedupeDiagnostics([a, b])).toEqual([a, b])
63
+ })
64
+ })
65
+
66
+ describe("formatDiagnostic", () => {
67
+ it("renders `<origin>: <config.path>: <message>`", () => {
68
+ const d = diag({
69
+ origin: "/repo/gtd.config.ts",
70
+ path: ["machines", "root", "states", "idle", "on", "* **"],
71
+ message: 'state "idle": "on" target "nowhere" is not a defined state',
72
+ })
73
+ expect(formatDiagnostic(d)).toBe(
74
+ '/repo/gtd.config.ts: machines.root.states.idle.on.* **: state "idle": "on" target "nowhere" is not a defined state',
75
+ )
76
+ })
77
+
78
+ it("renders a top-level (path-less) finding with a `(top level)` path placeholder", () => {
79
+ const d = diag({
80
+ origin: "/repo/gtd.config.ts",
81
+ path: [],
82
+ message: "unknown top-level key(s) foo",
83
+ })
84
+ expect(formatDiagnostic(d)).toBe(
85
+ "/repo/gtd.config.ts: (top level): unknown top-level key(s) foo",
86
+ )
87
+ })
88
+
89
+ it("joins a numeric path segment the same as a string one", () => {
90
+ const d = diag({ origin: "/repo/gtd.config.ts", path: ["on", 2], message: "boom" })
91
+ expect(formatDiagnostic(d)).toBe("/repo/gtd.config.ts: on.2: boom")
92
+ })
93
+ })
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One config-compile finding. `path` mirrors the raw config's own JSON shape
3
+ * (e.g. `["machines","design","states","triage","on",0]`) so a user can find
4
+ * exactly where to fix it; `origin` is the file it came from.
5
+ */
6
+ export interface Diagnostic {
7
+ readonly severity: "error" | "warning"
8
+ readonly message: string
9
+ readonly path: readonly (string | number)[]
10
+ readonly origin: string
11
+ }
12
+
13
+ const compareSegment = (a: string | number, b: string | number): number => {
14
+ if (typeof a === "number" && typeof b === "number") return a - b
15
+ if (typeof a === "number") return -1
16
+ if (typeof b === "number") return 1
17
+ return a < b ? -1 : a > b ? 1 : 0
18
+ }
19
+
20
+ /** Element-wise path comparison, numbers ordered numerically so `["on", 2]` precedes `["on", 10]`. */
21
+ export const compareConfigPaths = (
22
+ a: readonly (string | number)[],
23
+ b: readonly (string | number)[],
24
+ ): number => {
25
+ const len = Math.min(a.length, b.length)
26
+ for (let i = 0; i < len; i++) {
27
+ const c = compareSegment(a[i]!, b[i]!)
28
+ if (c !== 0) return c
29
+ }
30
+ return a.length - b.length
31
+ }
32
+
33
+ /** Sort by config path, so a report lists findings in the order of the file's shape. */
34
+ export const sortDiagnostics = (diagnostics: readonly Diagnostic[]): Diagnostic[] =>
35
+ [...diagnostics].sort((a, b) => compareConfigPaths(a.path, b.path))
36
+
37
+ /** One finding, printable: `<origin>: <path>: <message>` — the structured replacement for the old prose bullet list. */
38
+ export const formatDiagnostic = (d: Diagnostic): string => {
39
+ const path = d.path.length > 0 ? d.path.join(".") : "(top level)"
40
+ return `${d.origin}: ${path}: ${d.message}`
41
+ }
42
+
43
+ /** Dedup by `(severity, path, message, origin)`, first occurrence wins. */
44
+ export const dedupeDiagnostics = (diagnostics: readonly Diagnostic[]): Diagnostic[] => {
45
+ const seen = new Set<string>()
46
+ const out: Diagnostic[] = []
47
+ for (const d of diagnostics) {
48
+ const key = JSON.stringify([d.severity, d.path, d.message, d.origin])
49
+ if (seen.has(key)) continue
50
+ seen.add(key)
51
+ out.push(d)
52
+ }
53
+ return out
54
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import fc from "fast-check"
3
+ import { isSeededValidateCommand, seededValidateCommand } from "./SteeringFormats.js"
4
+ import { BUILT_IN_MODE_NAMES } from "../steering/index.js"
5
+
6
+ describe("seededValidateCommand / isSeededValidateCommand", () => {
7
+ it("recognizes its own seeded command for every built-in mode", () => {
8
+ fc.assert(
9
+ fc.property(fc.constantFrom(...BUILT_IN_MODE_NAMES), (mode) => {
10
+ expect(isSeededValidateCommand(mode, seededValidateCommand(mode))).toBe(true)
11
+ }),
12
+ )
13
+ })
14
+
15
+ it("rejects a hand-written command", () => {
16
+ expect(isSeededValidateCommand("qa", "npx prettier --check $GTD_FILE")).toBe(false)
17
+ })
18
+
19
+ it("rejects another mode's seeded command", () => {
20
+ expect(isSeededValidateCommand("review", seededValidateCommand("qa"))).toBe(false)
21
+ })
22
+
23
+ it("rejects near-miss whitespace/quoting variants", () => {
24
+ expect(isSeededValidateCommand("qa", "gtd check qa '$GTD_FILE'")).toBe(false)
25
+ expect(isSeededValidateCommand("qa", "gtd check qa $GTD_FILE")).toBe(false)
26
+ })
27
+ })
@@ -0,0 +1,6 @@
1
+ /** The unrendered `validate:` template a workflow compiler seeds for a built-in mode — quoted so a path with spaces survives interpolation. */
2
+ export const seededValidateCommand = (mode: string): string => `gtd check ${mode} "$GTD_FILE"`
3
+
4
+ /** True when `command` is exactly `mode`'s seeded template (compared literally, not rendered) — distinguishes gtd's own seeding from a user override. */
5
+ export const isSeededValidateCommand = (mode: string, command: string): boolean =>
6
+ command === seededValidateCommand(mode)
@@ -0,0 +1,77 @@
1
+ import type { WorkflowBase, Flow, ScopeAccess, Summary } from "../flows/index.js"
2
+ import type { Actor, ContentKind, StateMode, StateName, StepAccess } from "../wire/index.js"
3
+
4
+ export type { Actor, ContentKind, StateMode, StateName }
5
+
6
+ export type ChangeStatus = "A" | "M" | "D"
7
+
8
+ export interface PendingChange {
9
+ readonly status: ChangeStatus
10
+ readonly path: string
11
+ }
12
+
13
+ /**
14
+ * One steering-file mode: shell commands (reading the file from `$GTD_FILE`) that
15
+ * format and validate a file of this format. Both run at the edge, never in
16
+ * the engine.
17
+ */
18
+ export interface ModeDef {
19
+ readonly format?: string
20
+ readonly validate?: string
21
+ }
22
+
23
+ /**
24
+ * The rest a process waits at, described for the edge: who acts, its one
25
+ * content kind, and the steering-file options the guards and the wire read.
26
+ * Built from the step replay reached — never authored.
27
+ */
28
+ export interface StepDef {
29
+ readonly actor: Actor
30
+ readonly kind: ContentKind
31
+ readonly content: string
32
+ readonly label?: string
33
+ readonly file?: string
34
+ readonly mode?: StateMode
35
+ readonly model?: string
36
+ readonly system?: string
37
+ readonly judge?: string
38
+ readonly requireProgress?: boolean
39
+ readonly answerGate?: boolean
40
+ readonly requireRevert?: boolean
41
+ readonly allowEmpty?: boolean
42
+ readonly acceptClean?: boolean
43
+ readonly skills?: readonly string[]
44
+ readonly access?: StepAccess
45
+ }
46
+
47
+ /**
48
+ * The loaded workflow module: its default export is the flow, and `summary`
49
+ * and `base` are the optional named exports the engine reads. Every other
50
+ * export is the module's own business — helpers other workflows import.
51
+ */
52
+ export interface WorkflowDefinition {
53
+ readonly flow: Flow
54
+ readonly summary?: Summary | undefined
55
+ readonly base?: WorkflowBase | undefined
56
+ /** Steering files by path, with their mode — what the LSP knows before a step declaring one is reached. */
57
+ readonly steering: Readonly<Record<string, StateMode>>
58
+ /** Every mode a step may name: the built-in registry merged with `config.modes`. */
59
+ readonly modes: Readonly<Record<StateMode, ModeDef>>
60
+ /** Bundled skill lists keyed by scope full name, given the process's resolved vars — the fallback beneath `configuredSkills` and a `scope()` option. */
61
+ readonly skills: (
62
+ vars: Readonly<Record<string, string>>,
63
+ ) => Readonly<Record<string, readonly string[]>>
64
+ /** `config.skills` entries by scope full name; they outrank a `scope()` option, which outranks `skills`. Their keys are checked against `skills` once the process's settings are known. */
65
+ readonly configuredSkills: Readonly<Record<string, readonly string[]>>
66
+ /** Bundled access keyed by scope full name, given the process's resolved vars — the fallback beneath `configuredAccess` and a `scope()` option. */
67
+ readonly access: (vars: Readonly<Record<string, string>>) => Readonly<Record<string, ScopeAccess>>
68
+ /** `config.access` entries by scope full name; they outrank a `scope()` option, which outranks `access`. */
69
+ readonly configuredAccess: Readonly<Record<string, ScopeAccess>>
70
+ /** The file the `skills`, `access` and `config` exports came from, for error reports. */
71
+ readonly skillsOrigin: string
72
+ /** The flow's first step on an ordinary start — where a finished episode waits. */
73
+ readonly initial: StateName
74
+ }
75
+
76
+ export const knownModes = (def: Pick<WorkflowDefinition, "modes">): readonly StateMode[] =>
77
+ Object.keys(def.modes)
@@ -0,0 +1,53 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { compileConfig } from "./compile.js"
3
+ import { seededValidateCommand } from "./index.js"
4
+
5
+ const FILE = "/repo/gtd.config.ts"
6
+
7
+ describe("compileConfig", () => {
8
+ it("compiles no config export to the built-in modes alone", () => {
9
+ expect(compileConfig(undefined, FILE)).toEqual({
10
+ modes: {
11
+ qa: { validate: seededValidateCommand("qa") },
12
+ review: { validate: seededValidateCommand("review") },
13
+ },
14
+ skills: {},
15
+ access: {},
16
+ diagnostics: [],
17
+ })
18
+ })
19
+
20
+ it("layers a mode over a built-in one per half, and adds a new one", () => {
21
+ const { modes } = compileConfig(
22
+ { modes: { qa: { format: "fmt" }, adr: { validate: "v" } } },
23
+ FILE,
24
+ )
25
+ expect(modes["qa"]).toEqual({ format: "fmt", validate: seededValidateCommand("qa") })
26
+ expect(modes["adr"]).toEqual({ validate: "v" })
27
+ })
28
+
29
+ it("keeps a built-in half a mode leaves undefined", () => {
30
+ const { modes } = compileConfig({ modes: { qa: { format: "fmt", validate: undefined } } }, FILE)
31
+ expect(modes["qa"]).toEqual({ format: "fmt", validate: seededValidateCommand("qa") })
32
+ })
33
+
34
+ it("reports every problem at the file, under config, and compiles nothing else", () => {
35
+ const compiled = compileConfig({ vars: {}, skills: { a: "x" } }, FILE)
36
+ expect(compiled.skills).toEqual({})
37
+ expect(compiled.diagnostics).toEqual([
38
+ { severity: "error", path: ["config", "vars"], message: "is not a known key", origin: FILE },
39
+ {
40
+ severity: "error",
41
+ path: ["config", "skills", "a"],
42
+ message: "must be an array of skill names",
43
+ origin: FILE,
44
+ },
45
+ ])
46
+ })
47
+
48
+ it("keeps a __proto__ skills key as a plain entry", () => {
49
+ const skills = JSON.parse('{ "__proto__": ["x"], "build": ["y"] }') as Record<string, string[]>
50
+ const compiled = compileConfig({ skills }, FILE)
51
+ expect(Object.keys(compiled.skills).sort()).toEqual(["__proto__", "build"])
52
+ })
53
+ })