@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,120 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import {
3
+ landFields,
4
+ landProseText,
5
+ noopText,
6
+ renderLandJson,
7
+ renderLandPlain,
8
+ type LandFields,
9
+ type LandResultSource,
10
+ } from "./Land.js"
11
+ import { WIRE_SCHEMA } from "./index.js"
12
+
13
+ describe("landFields / renderLandJson", () => {
14
+ const sample: LandFields = {
15
+ schema: WIRE_SCHEMA,
16
+ script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
17
+ settled: false,
18
+ idle: false,
19
+ state: "build.review.closing",
20
+ subject: "gtd(agent): build.fixing",
21
+ cost: 0.42,
22
+ model: "smart",
23
+ }
24
+
25
+ it("renderLandJson emits exactly schema/script/settled/idle/state/subject/cost/model, newline-terminated", () => {
26
+ const line = renderLandJson(landFields(sample))
27
+ expect(line.endsWith("\n")).toBe(true)
28
+ expect(JSON.parse(line)).toEqual(sample)
29
+ })
30
+
31
+ it("renderLandJson carries null subject/cost/model verbatim for a genuine no-op — never omitted", () => {
32
+ const noop: LandFields = {
33
+ schema: WIRE_SCHEMA,
34
+ script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
35
+ settled: true,
36
+ idle: true,
37
+ state: "idle",
38
+ subject: null,
39
+ cost: null,
40
+ model: null,
41
+ }
42
+ expect(renderLandJson(landFields(noop))).toBe(JSON.stringify(noop) + "\n")
43
+ })
44
+
45
+ it("landFields is a real reorder, not an identity copy: takes a LandResultSource-shaped value (Program.ts's LandResult field order) and pins it into LandFields' own declared order", () => {
46
+ // Written in `LandResultSource`'s (== `LandResult`'s) own field order —
47
+ // state/subject/cost/model/script/settled/idle — the opposite of
48
+ // `LandFields`' script/settled/idle/state/subject/cost/model, so this
49
+ // test would fail if `landFields` stopped reordering.
50
+ const resultShaped: LandResultSource = {
51
+ state: "build.review.closing",
52
+ subject: "gtd(agent): build.fixing",
53
+ cost: 0.42,
54
+ model: "smart",
55
+ script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
56
+ settled: false,
57
+ idle: false,
58
+ }
59
+ expect(Object.keys(landFields(resultShaped))).toEqual([
60
+ "schema",
61
+ "script",
62
+ "settled",
63
+ "idle",
64
+ "state",
65
+ "subject",
66
+ "cost",
67
+ "model",
68
+ ])
69
+ })
70
+ })
71
+
72
+ describe("noopText / landProseText", () => {
73
+ it("noopText names the state", () => {
74
+ expect(noopText("idle")).toBe('nothing to do at "idle"\n')
75
+ })
76
+
77
+ it("landProseText names the commit subject, newline-terminated", () => {
78
+ expect(landProseText("gtd(human): idle → working")).toBe(
79
+ "commit everything with this message: gtd(human): idle → working\n",
80
+ )
81
+ })
82
+
83
+ it("landProseText carries no ANSI escape sequence", () => {
84
+ // eslint-disable-next-line no-control-regex -- asserting the ABSENCE of an escape byte
85
+ expect(landProseText("gtd(agent): drafting")).not.toMatch(/\x1b/)
86
+ })
87
+ })
88
+
89
+ describe("renderLandPlain", () => {
90
+ const sample: LandFields = {
91
+ schema: WIRE_SCHEMA,
92
+ script: "printf '%s %s\\n' '[commit]' 'gtd(agent): build.fixing'\ngit commit ...\n",
93
+ settled: false,
94
+ idle: false,
95
+ state: "build.review.closing",
96
+ subject: "gtd(agent): build.fixing",
97
+ cost: 0.42,
98
+ model: "smart",
99
+ }
100
+
101
+ it("names the commit subject and points at gtd land --json=script at a real landing", () => {
102
+ const plain = renderLandPlain(sample)
103
+ expect(plain).toContain("gtd(agent): build.fixing")
104
+ expect(plain).toContain("gtd land --json=script")
105
+ })
106
+
107
+ it("prints the no-op note when nothing landed", () => {
108
+ const noop: LandFields = {
109
+ schema: WIRE_SCHEMA,
110
+ script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
111
+ settled: true,
112
+ idle: true,
113
+ state: "idle",
114
+ subject: null,
115
+ cost: null,
116
+ model: null,
117
+ }
118
+ expect(renderLandPlain(noop)).toBe(noopText("idle"))
119
+ })
120
+ })
@@ -0,0 +1,75 @@
1
+ import type { StateName } from "./types.js"
2
+ import { renderFormat, WIRE_SCHEMA } from "./constants.js"
3
+
4
+ /**
5
+ * `gtd land`'s whole field set, in the object's own key order (also the JSON
6
+ * key order). `subject`/`cost`/`model` are `null` (never omitted) for a
7
+ * genuine no-op, mirroring `LandResult` itself.
8
+ */
9
+ export interface LandFields {
10
+ readonly schema: number
11
+ readonly script: string
12
+ readonly settled: boolean
13
+ readonly idle: boolean
14
+ readonly state: StateName
15
+ readonly subject: string | null
16
+ readonly cost: number | null
17
+ readonly model: string | null
18
+ }
19
+
20
+ /**
21
+ * `Program.ts`'s `LandResult`, expressed structurally so `wire` never
22
+ * imports it — the seven fields `LandFields` carries beside `schema`, but in `LandResult`'s
23
+ * own declaration order (`state, subject, cost, model, script, settled,
24
+ * idle`), not the wire's. Deliberately a different shape from `LandFields`:
25
+ * `landFields` below reorders INTO the wire's pinned key order, so its input
26
+ * type cannot equal its output type — an identity-typed
27
+ * `(input: LandFields) => LandFields` would let a future caller pass an
28
+ * already-wire-shaped object through unchanged, silently hiding a dropped
29
+ * reorder.
30
+ */
31
+ export interface LandResultSource {
32
+ readonly state: StateName
33
+ readonly subject: string | null
34
+ readonly cost: number | null
35
+ readonly model: string | null
36
+ readonly script: string
37
+ readonly settled: boolean
38
+ readonly idle: boolean
39
+ }
40
+
41
+ /** Assembles one `gtd land` result's fields in `LandFields`' declared order — the ONLY place `gtd land --json`'s wire shape is built. */
42
+ export const landFields = (input: LandResultSource): LandFields => ({
43
+ schema: WIRE_SCHEMA,
44
+ script: input.script,
45
+ settled: input.settled,
46
+ idle: input.idle,
47
+ state: input.state,
48
+ subject: input.subject,
49
+ cost: input.cost,
50
+ model: input.model,
51
+ })
52
+
53
+ export const renderLandJson = (fields: LandFields): string => JSON.stringify(fields) + "\n"
54
+
55
+ const FMT_NOOP = 'nothing to do at "%s"\n'
56
+ const FMT_LAND_PROSE = "commit everything with this message: %s\n"
57
+
58
+ /** `nothing to do at "<state>"` — a no-op step's plain-text line, and the text a print-only script's own `printf` carries. */
59
+ export const noopText = (state: string): string => renderFormat(FMT_NOOP, state)
60
+
61
+ /** `commit everything with this message: <subject>` — plain `gtd land`'s own stdout at a pending diff (no script); `--json` keeps emitting the script itself, unaffected. */
62
+ export const landProseText = (subject: string): string => renderFormat(FMT_LAND_PROSE, subject)
63
+
64
+ /**
65
+ * `gtd land`'s plain-text encoding — names the commit subject at a real
66
+ * landing and points at `--json=script`, since the script itself is
67
+ * unreachable from plain output; prints the existing no-op note otherwise.
68
+ * `command` names the invocation to point at — `"land"` by default, but
69
+ * `gtd judge answer` (the only other caller of `planLanding`/`landFields`)
70
+ * passes its own two-word name so the hint stays runnable verbatim.
71
+ */
72
+ export const renderLandPlain = (fields: LandFields, command = "land"): string =>
73
+ fields.subject !== null
74
+ ? `${landProseText(fields.subject).trimEnd()}\n(run \`gtd ${command} --json=script | sh\` to get the landing script)\n`
75
+ : noopText(fields.state)
@@ -0,0 +1,148 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { describe, expect, it } from "vitest"
3
+ import { selectPath } from "./Select.js"
4
+
5
+ describe("selectPath — scalar/boolean/list leaves", () => {
6
+ it("returns value with the raw string for a scalar leaf", () => {
7
+ expect(selectPath({ kind: "prompt" }, "kind")).toEqual({ kind: "value", text: "prompt" })
8
+ })
9
+
10
+ it("returns value with true/false for a boolean leaf", () => {
11
+ expect(selectPath({ idle: true }, "idle")).toEqual({ kind: "value", text: "true" })
12
+ expect(selectPath({ idle: false }, "idle")).toEqual({ kind: "value", text: "false" })
13
+ })
14
+
15
+ it("returns value with one JSON.stringify per entry, newline-joined, for a list leaf", () => {
16
+ const result = selectPath({ changes: [{ path: "a" }, { path: "b" }] }, "changes")
17
+ expect(result).toEqual({
18
+ kind: "value",
19
+ text: '{"path":"a"}\n{"path":"b"}',
20
+ })
21
+ })
22
+
23
+ it("never carries a trailing newline in value.text", () => {
24
+ const result = selectPath({ changes: [{ path: "a" }, { path: "b" }] }, "changes")
25
+ expect(result.kind).toBe("value")
26
+ if (result.kind === "value") {
27
+ expect(result.text.endsWith("\n")).toBe(false)
28
+ }
29
+ })
30
+ })
31
+
32
+ describe("selectPath — absent vs unknown", () => {
33
+ it("returns absent for a key that is present with an undefined value", () => {
34
+ expect(selectPath({ label: undefined }, "label")).toEqual({ kind: "absent" })
35
+ })
36
+
37
+ it("returns unknown for a key that is missing from the object", () => {
38
+ expect(selectPath({ kind: "prompt" }, "nope")).toEqual({ kind: "unknown", path: "nope" })
39
+ })
40
+
41
+ it("returns absent for the whole remaining path when a parent is present-but-undefined", () => {
42
+ expect(selectPath({ session: undefined }, "session.id")).toEqual({ kind: "absent" })
43
+ })
44
+
45
+ it("resolves an in-range all-digits segment against a list, descending into that entry", () => {
46
+ expect(selectPath({ changes: [{ path: "a" }] }, "changes.0.path")).toEqual({
47
+ kind: "value",
48
+ text: "a",
49
+ })
50
+ })
51
+
52
+ it("returns unknown for an out-of-range index against a list", () => {
53
+ expect(selectPath({ skills: ["a"] }, "skills.1")).toEqual({
54
+ kind: "unknown",
55
+ path: "skills.1",
56
+ })
57
+ })
58
+
59
+ it("returns unknown for a non-numeric segment against a list — no inherited array member (length, map, ...) resolves", () => {
60
+ expect(selectPath({ skills: ["a"] }, "skills.length")).toEqual({
61
+ kind: "unknown",
62
+ path: "skills.length",
63
+ })
64
+ })
65
+ })
66
+
67
+ describe("selectPath — array indexing", () => {
68
+ it("skills.0 reads the first declared skill, matching a driver's own read", () => {
69
+ expect(selectPath({ skills: ["code-review", "testing"] }, "skills.0")).toEqual({
70
+ kind: "value",
71
+ text: "code-review",
72
+ })
73
+ })
74
+ })
75
+
76
+ describe("selectPath — never throws", () => {
77
+ it("returns unknown when descending a path through a plain scalar", () => {
78
+ expect(selectPath({ a: 5 }, "a.b")).toEqual({ kind: "unknown", path: "a.b" })
79
+ })
80
+
81
+ it("returns unknown when the root itself is a primitive", () => {
82
+ expect(selectPath(5, "a")).toEqual({ kind: "unknown", path: "a" })
83
+ })
84
+
85
+ it("returns absent for the whole remaining path when a parent is null — never descends into it, never unknown", () => {
86
+ expect(selectPath({ a: null }, "a.b")).toEqual({ kind: "absent" })
87
+ })
88
+ })
89
+
90
+ describe('selectPath — a null leaf is absent, never the string "null"', () => {
91
+ it('returns absent, not a value with text "null", for a null-valued leaf', () => {
92
+ expect(selectPath({ subject: null }, "subject")).toEqual({ kind: "absent" })
93
+ })
94
+
95
+ it("BeatDocument.next is | null on no match — next.target reads as absent, not unknown, so a driver's read is never fatal", () => {
96
+ expect(selectPath({ next: null }, "next.target")).toEqual({ kind: "absent" })
97
+ })
98
+ })
99
+
100
+ describe("selectPath — own-property presence only, never inherited/prototype members", () => {
101
+ const fields = { kind: "message", changes: [{ path: "a" }] }
102
+
103
+ it("returns unknown for constructor — inherited from Object.prototype, not a document field", () => {
104
+ expect(selectPath(fields, "constructor")).toEqual({ kind: "unknown", path: "constructor" })
105
+ })
106
+
107
+ it("returns unknown for toString — inherited from Object.prototype", () => {
108
+ expect(selectPath(fields, "toString")).toEqual({ kind: "unknown", path: "toString" })
109
+ })
110
+
111
+ it("returns unknown for hasOwnProperty — inherited from Object.prototype", () => {
112
+ expect(selectPath(fields, "hasOwnProperty")).toEqual({
113
+ kind: "unknown",
114
+ path: "hasOwnProperty",
115
+ })
116
+ })
117
+
118
+ it("returns unknown for valueOf — inherited from Object.prototype", () => {
119
+ expect(selectPath(fields, "valueOf")).toEqual({ kind: "unknown", path: "valueOf" })
120
+ })
121
+
122
+ it("returns unknown for changes.length — inherited from Array.prototype, not an index into the array", () => {
123
+ expect(selectPath(fields, "changes.length")).toEqual({
124
+ kind: "unknown",
125
+ path: "changes.length",
126
+ })
127
+ })
128
+
129
+ it("returns unknown for changes.map — inherited from Array.prototype", () => {
130
+ expect(selectPath(fields, "changes.map")).toEqual({ kind: "unknown", path: "changes.map" })
131
+ })
132
+ })
133
+
134
+ describe("selectPath — nested field reach", () => {
135
+ it("reaches a nested scalar field", () => {
136
+ expect(selectPath({ session: { id: "abc123" } }, "session.id")).toEqual({
137
+ kind: "value",
138
+ text: "abc123",
139
+ })
140
+ })
141
+ })
142
+
143
+ describe("src/wire/Select.ts — zero imports", () => {
144
+ it("contains no import statement", () => {
145
+ const source = readFileSync(new URL("./Select.ts", import.meta.url), "utf8")
146
+ expect(source).not.toMatch(/^\s*import\b/m)
147
+ })
148
+ })
@@ -0,0 +1,78 @@
1
+ // Zero imports on purpose — a pure, dependency-free vocabulary tier; keep it that way.
2
+
3
+ export type Selection =
4
+ | { readonly kind: "value"; readonly text: string }
5
+ | { readonly kind: "absent" }
6
+ | { readonly kind: "unknown"; readonly path: string }
7
+
8
+ /**
9
+ * Renders a fully-walked leaf value: scalars/booleans stringify directly,
10
+ * arrays become one `JSON.stringify` per entry, newline-joined.
11
+ * `selectPath`'s loop already returns `absent` for `undefined`/`null` after
12
+ * every segment (including the last), so neither ever reaches here.
13
+ */
14
+ const toSelection = (value: unknown): Selection => {
15
+ if (Array.isArray(value)) {
16
+ return { kind: "value", text: value.map((entry) => JSON.stringify(entry)).join("\n") }
17
+ }
18
+ if (typeof value === "object") {
19
+ return { kind: "value", text: JSON.stringify(value) }
20
+ }
21
+ return { kind: "value", text: String(value) }
22
+ }
23
+
24
+ /** A segment that resolves to no key at all (missing from an object, out-of-range or non-numeric against an array) — distinct from a present key holding `undefined`. */
25
+ const NOT_FOUND: unique symbol = Symbol("not-found")
26
+
27
+ /** An all-digits segment (`"0"`, `"12"`) — the only shape an array segment resolves against. */
28
+ const ARRAY_INDEX = /^\d+$/
29
+
30
+ /**
31
+ * One step of the walk: looks `segment` up on `current`, or reports
32
+ * `NOT_FOUND` for a key that was never there (a primitive has no keys).
33
+ * Against an array, only an all-digits segment resolves — an in-range index
34
+ * (`skills.0` reads the first declared skill), never a non-index own key
35
+ * (`length`/`map`/every other inherited array member stays `NOT_FOUND`).
36
+ * Against an object, presence is an OWN-property test
37
+ * (`Object.prototype.hasOwnProperty`), never the `in` operator — `in` walks
38
+ * the prototype chain, so it would resolve inherited members
39
+ * (`constructor`, `toString`, `hasOwnProperty`, `valueOf`, ...) as real
40
+ * document fields.
41
+ */
42
+ const resolveSegment = (current: unknown, segment: string): unknown => {
43
+ if (Array.isArray(current)) {
44
+ return ARRAY_INDEX.test(segment) && Number(segment) < current.length
45
+ ? current[Number(segment)]
46
+ : NOT_FOUND
47
+ }
48
+ if (typeof current === "object" && current !== null) {
49
+ const record = current as Record<string, unknown>
50
+ return Object.prototype.hasOwnProperty.call(record, segment) ? record[segment] : NOT_FOUND
51
+ }
52
+ return NOT_FOUND
53
+ }
54
+
55
+ /**
56
+ * Walks `fields` by dotted key path. Never throws — any unwalkable shape
57
+ * (a primitive mid-path, an out-of-range or non-numeric array segment)
58
+ * degrades to `unknown`, and a present-but-`undefined` value anywhere along
59
+ * the path short-circuits the whole remaining path to `absent` rather than
60
+ * reporting a key that was never actually missing.
61
+ */
62
+ export const selectPath = (fields: unknown, path: string): Selection => {
63
+ try {
64
+ let current: unknown = fields
65
+ for (const segment of path.split(".")) {
66
+ current = resolveSegment(current, segment)
67
+ if (current === NOT_FOUND) return { kind: "unknown", path }
68
+ // `null` is treated exactly like `undefined` here: a `null`-typed field
69
+ // (e.g. `BeatDocument.next`, `LandFields.subject`/`cost`/`model`) is a
70
+ // legitimate "nothing here" value, not a value to descend into or print
71
+ // as the literal string "null".
72
+ if (current === undefined || current === null) return { kind: "absent" }
73
+ }
74
+ return toSelection(current)
75
+ } catch {
76
+ return { kind: "unknown", path }
77
+ }
78
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { GTD_SESSION_NAMESPACE, resolveSession, uuidv5 } from "./Sessions.js"
3
+
4
+ // RFC 4122 §4.3 test vector — the DNS namespace uuidv5-hashing "www.example.com"
5
+ // is the standard, tool-independent proof that a hand-rolled v5 implementation
6
+ // is correct (unlike a gtd-namespace id, which no external source publishes).
7
+ const DNS_NAMESPACE = "6ba7b810-9dad-11d1-80b4-00c04fd430c8"
8
+
9
+ describe("uuidv5", () => {
10
+ it("matches the RFC 4122 test vector", () => {
11
+ expect(uuidv5(DNS_NAMESPACE, "www.example.com")).toBe("2ed6657d-e927-568b-95e1-2665a8aea6a2")
12
+ })
13
+
14
+ it("sets the version nibble to 5 and the variant bits to 10xx", () => {
15
+ const id = uuidv5(GTD_SESSION_NAMESPACE, "build.fix#abc1234")
16
+ expect(id[14]).toBe("5")
17
+ expect(["8", "9", "a", "b"]).toContain(id[19])
18
+ })
19
+
20
+ it("is deterministic: the same name always yields the same id", () => {
21
+ expect(uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234")).toBe(
22
+ uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
23
+ )
24
+ })
25
+
26
+ it("a one-character-different name yields a different id", () => {
27
+ expect(uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234")).not.toBe(
28
+ uuidv5(GTD_SESSION_NAMESPACE, "root#abc1235"),
29
+ )
30
+ })
31
+ })
32
+
33
+ describe("resolveSession", () => {
34
+ it("derives the same id for the same memory key, passing resume through verbatim", () => {
35
+ expect(resolveSession("root#abc1234", false)).toEqual({
36
+ sessionId: uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
37
+ resume: false,
38
+ })
39
+ expect(resolveSession("root#abc1234", true)).toEqual({
40
+ sessionId: uuidv5(GTD_SESSION_NAMESPACE, "root#abc1234"),
41
+ resume: true,
42
+ })
43
+ })
44
+
45
+ it("a different memory key derives a different id", () => {
46
+ const a = resolveSession("root#abc1234", false)
47
+ const b = resolveSession("root#feed000", false)
48
+ expect(a.sessionId).not.toBe(b.sessionId)
49
+ })
50
+
51
+ it("mints a random, non-repeating id and forces resume: false when there is no memory key", () => {
52
+ let calls = 0
53
+ const mint = () => `minted-${++calls}`
54
+ const first = resolveSession(undefined, true, mint)
55
+ const second = resolveSession(undefined, true, mint)
56
+ expect(first).toEqual({ sessionId: "minted-1", resume: false })
57
+ expect(second).toEqual({ sessionId: "minted-2", resume: false })
58
+ })
59
+ })
@@ -0,0 +1,39 @@
1
+ import { createHash, randomUUID } from "node:crypto"
2
+
3
+ /** Fixed forever — every derived session id descends from this one constant. */
4
+ export const GTD_SESSION_NAMESPACE = "ca4be249-805e-41c1-8b6a-75a11c011e25"
5
+
6
+ const toBytes = (uuid: string): Buffer => Buffer.from(uuid.replace(/-/g, ""), "hex")
7
+
8
+ const toUuidString = (bytes: Buffer): string => {
9
+ const hex = bytes.toString("hex")
10
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`
11
+ }
12
+
13
+ /**
14
+ * UUIDv5 (RFC 4122 §4.3), hand-rolled over `node:crypto`'s SHA-1 rather than
15
+ * a dependency — correctness is checked against the RFC's own DNS-namespace
16
+ * test vector in `Sessions.test.ts`.
17
+ */
18
+ export const uuidv5 = (namespace: string, name: string): string => {
19
+ const hash = createHash("sha1").update(toBytes(namespace)).update(name, "utf8").digest()
20
+ const bytes = hash.subarray(0, 16)
21
+ bytes[6] = (bytes[6]! & 0x0f) | 0x50
22
+ bytes[8] = (bytes[8]! & 0x3f) | 0x80
23
+ return toUuidString(bytes)
24
+ }
25
+
26
+ /**
27
+ * Resolves a `prompt` rest's session id: with no `memoryKey` there's no
28
+ * history to derive a stable id from, so it mints an ephemeral one and forces
29
+ * `resume: false`; otherwise the id is deterministic (`uuidv5`) and `resume`
30
+ * is passed through as given.
31
+ */
32
+ export const resolveSession = (
33
+ memoryKey: string | undefined,
34
+ resume: boolean,
35
+ mint: () => string = randomUUID,
36
+ ): { readonly sessionId: string; readonly resume: boolean } =>
37
+ memoryKey === undefined
38
+ ? { sessionId: mint(), resume: false }
39
+ : { sessionId: uuidv5(GTD_SESSION_NAMESPACE, memoryKey), resume }
@@ -0,0 +1,162 @@
1
+ import { readdirSync, readFileSync } from "node:fs"
2
+ import { resolve } from "node:path"
3
+ import { describe, expect, it } from "vitest"
4
+ import {
5
+ BEAT_KINDS,
6
+ beatDocument,
7
+ demandOf,
8
+ judgeJson,
9
+ landFields,
10
+ renderBeatJson,
11
+ renderLandJson,
12
+ statusOf,
13
+ WIRE_SCHEMA,
14
+ type BeatKind,
15
+ } from "./index.js"
16
+
17
+ const GOLDEN = resolve(import.meta.dirname, "golden")
18
+
19
+ // One golden set per schema. Removing or renaming a field bumps WIRE_SCHEMA
20
+ // and adds golden/<WIRE_SCHEMA>/ beside the old sets; adding a field needs
21
+ // neither. Only shape is compared, so reworded content never asks for a bump.
22
+ const DIR = resolve(GOLDEN, String(WIRE_SCHEMA))
23
+ const golden: ReadonlyMap<string, unknown> = new Map(
24
+ readdirSync(DIR)
25
+ .sort()
26
+ .map((file) => [file, JSON.parse(readFileSync(resolve(DIR, file), "utf8"))]),
27
+ )
28
+
29
+ /** Every key path with its leaf type; `judge` is a JSON document carried as a string, so it is parsed first. */
30
+ const shape = (value: unknown, path = ""): string[] => {
31
+ if (path === "judge" && typeof value === "string") return shape(JSON.parse(value), path)
32
+ if (Array.isArray(value)) return value.flatMap((item) => shape(item, `${path}[]`))
33
+ if (value !== null && typeof value === "object")
34
+ return Object.entries(value).flatMap(([key, item]) =>
35
+ shape(item, path ? `${path}.${key}` : key),
36
+ )
37
+ return [`${path}: ${value === null ? "null" : typeof value}`]
38
+ }
39
+
40
+ const missing = (actual: unknown, expected: unknown): string[] => {
41
+ const has = new Set(shape(actual))
42
+ return [...new Set(shape(expected))].filter((leaf) => !has.has(leaf))
43
+ }
44
+
45
+ const JUDGE = judgeJson({ diff: "…" }, [
46
+ {
47
+ id: "q1",
48
+ primitive: "noul",
49
+ instructions: "Is the change done?",
50
+ criteria: "yes: every item ticked",
51
+ },
52
+ ])
53
+
54
+ const beat = (kind: BeatKind): string => {
55
+ const rendered = {
56
+ state: "build.fixing",
57
+ actor: "agent",
58
+ content: "fix it",
59
+ model: "opus",
60
+ system: "You are a careful senior engineer.",
61
+ memory: "build#a1b2c3d",
62
+ file: "TODO.md",
63
+ mode: "qa",
64
+ label: "Fixing",
65
+ judge: JUDGE,
66
+ skills: ["code-review"],
67
+ access: { read: null, write: [".gtd/TODO.md"] },
68
+ }
69
+ const demand = demandOf(
70
+ kind === "prompt"
71
+ ? {
72
+ rendered,
73
+ kind,
74
+ session: { id: "8f2c", resume: true },
75
+ validate: "gtd check qa 'TODO.md'",
76
+ }
77
+ : { rendered, kind },
78
+ )
79
+ const status = statusOf({
80
+ rendered,
81
+ idle: false,
82
+ initial: false,
83
+ workflow: "feature",
84
+ log: ".git/gtd-loop.log",
85
+ changes: [{ status: "M", path: "TODO.md" }],
86
+ next: { target: "idle" },
87
+ cost: 12,
88
+ costByModel: [{ model: "opus", cost: 12 }],
89
+ })
90
+ return renderBeatJson(beatDocument(demand, status))
91
+ }
92
+
93
+ const documents: Readonly<Record<string, string>> = {
94
+ ...Object.fromEntries(BEAT_KINDS.map((kind) => [`beat-${kind}.json`, beat(kind)])),
95
+ "land.json": renderLandJson(
96
+ landFields({
97
+ state: "build.review.closing",
98
+ subject: "gtd(agent): build.fixing",
99
+ cost: 0.42,
100
+ model: "smart",
101
+ script: "git commit ...\n",
102
+ settled: false,
103
+ idle: false,
104
+ }),
105
+ ),
106
+ "land-noop.json": renderLandJson(
107
+ landFields({
108
+ state: "idle",
109
+ subject: null,
110
+ cost: null,
111
+ model: null,
112
+ script: "printf '%s\\n' 'nothing to do at \"idle\"'\n",
113
+ settled: true,
114
+ idle: true,
115
+ }),
116
+ ),
117
+ "judge.json": JUDGE,
118
+ }
119
+
120
+ describe(`wire schema ${WIRE_SCHEMA}`, () => {
121
+ it("the golden set covers every document kind", () => {
122
+ expect([...golden.keys()]).toEqual(Object.keys(documents).sort())
123
+ })
124
+
125
+ for (const [file, json] of Object.entries(documents)) {
126
+ it(`${file}: still emits every golden field under its name and type — removing or renaming one needs a WIRE_SCHEMA bump`, () => {
127
+ expect(golden.get(file)).toHaveProperty("schema", WIRE_SCHEMA)
128
+ expect(missing(JSON.parse(json), golden.get(file))).toEqual([])
129
+ })
130
+ }
131
+
132
+ describe("the shape check", () => {
133
+ const land = JSON.parse(documents["land.json"]!) as Record<string, unknown>
134
+ const { subject, ...rest } = land
135
+
136
+ it("reports a removed field", () => {
137
+ expect(missing(rest, golden.get("land.json"))).toEqual(["subject: string"])
138
+ })
139
+
140
+ it("reports a renamed field", () => {
141
+ expect(missing({ ...rest, title: subject }, golden.get("land.json"))).toEqual([
142
+ "subject: string",
143
+ ])
144
+ })
145
+
146
+ it("accepts an added field and changed content", () => {
147
+ expect(missing({ ...land, extra: true, script: "other" }, golden.get("land.json"))).toEqual(
148
+ [],
149
+ )
150
+ })
151
+
152
+ it("reads inside the judge document a beat carries", () => {
153
+ const beat = JSON.parse(documents["beat-message.json"]!) as Record<string, unknown>
154
+ const judge = JSON.parse(beat.judge as string) as Record<string, unknown>
155
+ const { state, ...withoutState } = judge
156
+ expect(state).toBeDefined()
157
+ expect(
158
+ missing({ ...beat, judge: JSON.stringify(withoutState) }, golden.get("beat-message.json")),
159
+ ).toEqual(["judge.state.diff: string"])
160
+ })
161
+ })
162
+ })