@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,311 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { freeFormFormat } from "./freeform.js"
3
+ import { BUILT_IN_MODE_NAMES, steeringFormatFor } from "./index.js"
4
+
5
+ /** Applies edits back-to-front — a local copy of the file's own splice pattern, mirroring `qa.test.ts`/`review.test.ts`'s identical helper. */
6
+ const applyEdits = (
7
+ content: string,
8
+ edits: readonly { range: unknown; newText: string }[],
9
+ ): string => {
10
+ const lines = content.split(/\r?\n/)
11
+ const toOffset = (position: { line: number; character: number }): number => {
12
+ let offset = 0
13
+ for (let i = 0; i < position.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
14
+ return offset + position.character
15
+ }
16
+ const sorted = [...edits].sort((a, b) => {
17
+ const ra = a.range as { start: { line: number; character: number } }
18
+ const rb = b.range as { start: { line: number; character: number } }
19
+ return toOffset(rb.start) - toOffset(ra.start)
20
+ })
21
+ let result = content
22
+ for (const edit of sorted) {
23
+ const range = edit.range as {
24
+ start: { line: number; character: number }
25
+ end: { line: number; character: number }
26
+ }
27
+ const start = toOffset(range.start)
28
+ const end = toOffset(range.end)
29
+ result = result.slice(0, start) + edit.newText + result.slice(end)
30
+ }
31
+ return result
32
+ }
33
+
34
+ /**
35
+ * Applies edits with the SAME offset convention `applySteeringEdits`
36
+ * (`src/ui/Write.ts`) uses in production: lines split on a bare `"\n"`, so a
37
+ * CRLF line keeps its `\r` as part of the line's own length. `applyEdits`
38
+ * above (this file's pre-existing helper) instead splits on `/\r?\n/`, which
39
+ * strips `\r` and undercounts offsets in a CRLF document — fine for the
40
+ * LF-only fixtures every other test in this file uses, but it would silently
41
+ * corrupt the byte-exact CRLF assertions below. Not a normalizing helper:
42
+ * mismatched newText/range still round-trips to the WRONG bytes, exactly as
43
+ * production would.
44
+ */
45
+ const applyEditsExact = (
46
+ content: string,
47
+ edits: readonly { range: unknown; newText: string }[],
48
+ ): string => {
49
+ const lines = content.split("\n")
50
+ const toOffset = (position: { line: number; character: number }): number => {
51
+ let offset = 0
52
+ for (let i = 0; i < position.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
53
+ return offset + position.character
54
+ }
55
+ const sorted = [...edits].sort((a, b) => {
56
+ const ra = a.range as { start: { line: number; character: number } }
57
+ const rb = b.range as { start: { line: number; character: number } }
58
+ return toOffset(rb.start) - toOffset(ra.start)
59
+ })
60
+ let result = content
61
+ for (const edit of sorted) {
62
+ const range = edit.range as {
63
+ start: { line: number; character: number }
64
+ end: { line: number; character: number }
65
+ }
66
+ const start = toOffset(range.start)
67
+ const end = toOffset(range.end)
68
+ result = result.slice(0, start) + edit.newText + result.slice(end)
69
+ }
70
+ return result
71
+ }
72
+
73
+ describe("freeFormFormat — registry", () => {
74
+ it("is not registered: steeringFormatFor('freeform') returns undefined", () => {
75
+ expect(steeringFormatFor("freeform")).toBeUndefined()
76
+ })
77
+
78
+ it("is not among the built-in mode names", () => {
79
+ expect(BUILT_IN_MODE_NAMES).not.toContain("freeform")
80
+ })
81
+ })
82
+
83
+ describe("freeFormFormat.sample", () => {
84
+ it("validates clean", () => {
85
+ expect(freeFormFormat.validate(freeFormFormat.sample)).toEqual([])
86
+ })
87
+ })
88
+
89
+ describe("freeFormFormat.validate/outline/actions", () => {
90
+ it("validate always returns []", () => {
91
+ expect(freeFormFormat.validate("# Anything\n\nGoes.\n")).toEqual([])
92
+ })
93
+
94
+ it("outline is [] without threads", () => {
95
+ expect(freeFormFormat.outline("# Anything\n\nGoes.\n")).toEqual([])
96
+ })
97
+
98
+ it("actions are [] away from a thread", () => {
99
+ expect(
100
+ freeFormFormat.actions("Some prose.\n", {
101
+ start: { line: 0, character: 0 },
102
+ end: { line: 0, character: 0 },
103
+ }),
104
+ ).toEqual([])
105
+ })
106
+
107
+ it("has no pointerAt or documentLinks", () => {
108
+ expect(freeFormFormat.pointerAt).toBeUndefined()
109
+ expect(freeFormFormat.documentLinks).toBeUndefined()
110
+ })
111
+ })
112
+
113
+ describe("freeFormFormat.view", () => {
114
+ it("returns every top-level block, in document order, each with a {kind:'paragraph', line} anchor", () => {
115
+ const content = ["# Heading", "", "- one", "- two", "", "Paragraph.", ""].join("\n")
116
+ const view = freeFormFormat.view(content)
117
+ expect(view.nodes.map((n) => n.anchor)).toEqual([
118
+ { kind: "paragraph", line: 0 },
119
+ { kind: "paragraph", line: 2 },
120
+ { kind: "paragraph", line: 5 },
121
+ ])
122
+ })
123
+
124
+ it("a fenced ts code block's own text is the fence-stripped body, byte-identical to qa/review's own view", () => {
125
+ const content = ["```ts", "const x = 1", "```", ""].join("\n")
126
+ const view = freeFormFormat.view(content)
127
+ expect(view.nodes.map((n) => n.block?.text)).toEqual(["const x = 1"])
128
+ })
129
+
130
+ it("a two-line '>' quote's own text is marker-free, byte-identical to qa/review's own view", () => {
131
+ const content = ["> quoted line", "> more", ""].join("\n")
132
+ const view = freeFormFormat.view(content)
133
+ expect(view.nodes.map((n) => n.block?.text)).toEqual(["quoted line more"])
134
+ })
135
+ })
136
+
137
+ describe("freeFormFormat.apply — a block-start anchor now refuses", () => {
138
+ it("refuses anchor-not-found for a line matching a top-level block's own start line, on a non-empty document", () => {
139
+ const content = ["# Heading", "", "Paragraph one.", "", "Paragraph two.", ""].join("\n")
140
+ const result = freeFormFormat.apply(content, { kind: "paragraph", line: 2 }, { text: "x" })
141
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
142
+ })
143
+
144
+ it("refuses a heading's own start line the same way", () => {
145
+ const content = ["# Old heading", "", "Body.", ""].join("\n")
146
+ const result = freeFormFormat.apply(content, { kind: "paragraph", line: 0 }, { text: "# New" })
147
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
148
+ })
149
+ })
150
+
151
+ describe("freeFormFormat.apply — appending", () => {
152
+ it("a line at the document's last line appends text at the end, preceded by a blank line", () => {
153
+ const content = ["Paragraph one.", ""].join("\n")
154
+ const lastLine = content.split(/\r?\n/).length - 1
155
+ const result = freeFormFormat.apply(
156
+ content,
157
+ { kind: "paragraph", line: lastLine },
158
+ { text: "New content." },
159
+ )
160
+ expect(result.ok).toBe(true)
161
+ const applied = result.ok ? applyEdits(content, result.edits) : ""
162
+ expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\n"))
163
+ })
164
+
165
+ it("a line well past the document's end also appends", () => {
166
+ const content = ["Paragraph one.", ""].join("\n")
167
+ const result = freeFormFormat.apply(
168
+ content,
169
+ { kind: "paragraph", line: 9999 },
170
+ { text: "New content." },
171
+ )
172
+ expect(result.ok).toBe(true)
173
+ const applied = result.ok ? applyEdits(content, result.edits) : ""
174
+ expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\n"))
175
+ })
176
+
177
+ it("appending to an empty document produces just the new text", () => {
178
+ const result = freeFormFormat.apply("", { kind: "paragraph", line: 0 }, { text: "First line." })
179
+ expect(result.ok).toBe(true)
180
+ const applied = result.ok ? applyEdits("", result.edits) : ""
181
+ expect(applied).toBe("First line.\n")
182
+ })
183
+
184
+ it("the empty-document save path: Number.MAX_SAFE_INTEGER against '' reaches the append edit, reading no block's raw text", () => {
185
+ const result = freeFormFormat.apply(
186
+ "",
187
+ { kind: "paragraph", line: Number.MAX_SAFE_INTEGER },
188
+ { text: "x" },
189
+ )
190
+ expect(result.ok).toBe(true)
191
+ const applied = result.ok ? applyEdits("", result.edits) : ""
192
+ expect(applied).toBe("x\n")
193
+ })
194
+
195
+ it("an empty append (opts.text omitted, an append row submitted with nothing typed) leaves the document unchanged, not a bare trailing blank line", () => {
196
+ const content = ["Paragraph one.", ""].join("\n")
197
+ const lastLine = content.split(/\r?\n/).length - 1
198
+ const result = freeFormFormat.apply(content, { kind: "paragraph", line: lastLine }, {})
199
+ expect(result.ok).toBe(true)
200
+ const applied = result.ok ? applyEdits(content, result.edits) : ""
201
+ expect(applied).toBe(["Paragraph one.", ""].join("\n"))
202
+ })
203
+ })
204
+
205
+ describe("freeFormFormat.apply — no block at that line", () => {
206
+ it("refuses anchor-not-found for a line resolving to no block, not past the end", () => {
207
+ const content = ["Paragraph one.", "", "Paragraph two.", ""].join("\n")
208
+ const result = freeFormFormat.apply(content, { kind: "paragraph", line: 1 }, { text: "x" })
209
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
210
+ })
211
+ })
212
+
213
+ describe("freeFormFormat.apply — wrong anchor kind", () => {
214
+ it("refuses a non-paragraph anchor", () => {
215
+ const content = "Paragraph.\n"
216
+ const result = freeFormFormat.apply(content, { kind: "chunk", index: 0 }, { text: "x" })
217
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
218
+ })
219
+ })
220
+
221
+ describe("freeFormFormat.annotate", () => {
222
+ it("delegates to Footnotes.ts#footnoteAttachEdits: attaches a marker and a definition", () => {
223
+ const content = ["Paragraph one.", ""].join("\n")
224
+ const result = freeFormFormat.annotate(content, { kind: "paragraph", line: 0 }, "A note.")
225
+ expect(result.ok).toBe(true)
226
+ const applied = result.ok ? applyEdits(content, result.edits) : ""
227
+ expect(applied).toContain("Paragraph one.[^")
228
+ expect(applied).toContain("A note.")
229
+ expect(freeFormFormat.validate(applied)).toEqual([])
230
+ })
231
+
232
+ it("creates a thread, appends to an open one, replaces on a waiting one — each validates clean", () => {
233
+ const anchor = { kind: "paragraph" as const, line: 0 }
234
+ const attach = (content: string, text: string): string => {
235
+ const r = freeFormFormat.annotate(content, anchor, text)
236
+ if (!r.ok) throw new Error("refused")
237
+ return applyEdits(content, r.edits)
238
+ }
239
+ const created = attach("Paragraph one.\n", "first")
240
+ expect(created).toMatch(/\[\^na\w+\]:\n - H: first\n/)
241
+ expect(freeFormFormat.validate(created)).toEqual([])
242
+ const replaced = attach(created, "second")
243
+ expect(replaced).toContain("- H: second")
244
+ expect(replaced).not.toContain("first")
245
+ const open = replaced.replace("- H: second", "- H: second\n - A: why?")
246
+ const replied = attach(open, "because")
247
+ expect(replied).toContain(" - A: why?\n - H: because")
248
+ expect(freeFormFormat.validate(replied)).toEqual([])
249
+ })
250
+
251
+ it("refuses a non-paragraph anchor", () => {
252
+ const result = freeFormFormat.annotate("x\n", { kind: "hunk", chunkIndex: 0, index: 0 }, "note")
253
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
254
+ })
255
+
256
+ it("refuses when the line isn't inside any real block", () => {
257
+ const content = ["Paragraph one.", "", "Paragraph two.", ""].join("\n")
258
+ const result = freeFormFormat.annotate(content, { kind: "paragraph", line: 1 }, "note")
259
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
260
+ })
261
+ })
262
+
263
+ describe("freeFormFormat.apply — CRLF documents preserve untouched bytes", () => {
264
+ it("a block-start anchor refuses on a CRLF document exactly as it does on LF", () => {
265
+ const content = ["# Heading", "", "Paragraph one.", "", "Paragraph two.", ""].join("\r\n")
266
+ const result = freeFormFormat.apply(
267
+ content,
268
+ { kind: "paragraph", line: 2 },
269
+ { text: "Edited." },
270
+ )
271
+ expect(result).toEqual({ ok: false, reason: "anchor-not-found" })
272
+ })
273
+
274
+ it("appending to a CRLF document uses CRLF for the new bytes too", () => {
275
+ const content = ["Paragraph one.", ""].join("\r\n")
276
+ const lastLine = content.split(/\r?\n/).length - 1
277
+ const result = freeFormFormat.apply(
278
+ content,
279
+ { kind: "paragraph", line: lastLine },
280
+ { text: "New content." },
281
+ )
282
+ expect(result.ok).toBe(true)
283
+ const applied = result.ok ? applyEditsExact(content, result.edits) : ""
284
+ expect(applied).toBe(["Paragraph one.", "", "New content.", ""].join("\r\n"))
285
+ })
286
+
287
+ it("pasting LF text into a CRLF document via append introduces no mixed endings", () => {
288
+ const content = ["# Heading", "", "Paragraph one.", ""].join("\r\n")
289
+ const lastLine = content.split(/\r?\n/).length - 1
290
+ const result = freeFormFormat.apply(
291
+ content,
292
+ { kind: "paragraph", line: lastLine },
293
+ { text: "Line one.\nLine two." },
294
+ )
295
+ expect(result.ok).toBe(true)
296
+ const applied = result.ok ? applyEditsExact(content, result.edits) : ""
297
+ expect(applied).toBe(
298
+ ["# Heading", "", "Paragraph one.", "", "Line one.", "Line two.", ""].join("\r\n"),
299
+ )
300
+ expect(applied.replace(/\r\n/g, "")).not.toContain("\n")
301
+ })
302
+ })
303
+
304
+ describe("freeFormFormat — thread outline", () => {
305
+ it("free-form lists threads at top level", () => {
306
+ const doc = "Text [^a].\n\n[^a]:\n - H: hi\n - A: yo\n"
307
+ expect(freeFormFormat.outline(doc).map((n) => [n.name, n.detail])).toEqual([
308
+ ["[^a]", "waiting on you"],
309
+ ])
310
+ })
311
+ })
@@ -0,0 +1,148 @@
1
+ import { blockNodesOf } from "./Blocks.js"
2
+ import { eolOf } from "./Eol.js"
3
+ import {
4
+ footnoteAttachEdits,
5
+ openThreadsOf,
6
+ threadFindings,
7
+ threadOutlineNodes,
8
+ threadReplyActions,
9
+ type FootnoteAnchor,
10
+ } from "./Footnotes.js"
11
+ import { blockNodeAt, parseMarkdown, toLspPosition } from "./MarkdownTree.js"
12
+ import type {
13
+ SteeringAnnotateResult,
14
+ SteeringEdit,
15
+ SteeringFormat,
16
+ SteeringView,
17
+ } from "./SteeringFormat.js"
18
+
19
+ const FREE_FORM_SAMPLE = `Sample plan. Add a thing.
20
+
21
+ ## Notes
22
+
23
+ Nothing here is validated — free-form has no structure to conform to.
24
+ `
25
+
26
+ /** Resolves a `paragraph` anchor: attaches at the end of `line`'s own text, the definition landing after that line's containing top-level block node. `undefined` when `line` isn't inside a real block — mirrors `qa.ts`/`review.ts`'s identical resolver. */
27
+ const resolveParagraphAnchor = (
28
+ tree: ReturnType<typeof parseMarkdown>,
29
+ lines: readonly string[],
30
+ line: number,
31
+ ): FootnoteAnchor | undefined => {
32
+ const block = blockNodeAt(tree, line)
33
+ if (!block?.position) return undefined
34
+ return {
35
+ line,
36
+ endCharacter: (lines[line] ?? "").length,
37
+ blockEndLine: toLspPosition(block.position.end).line,
38
+ key: `paragraph:${line}`,
39
+ }
40
+ }
41
+
42
+ /** Free-form's `annotate`: only a `paragraph` anchor resolves here (it's the only kind `view` ever reports) — delegates the two-edit mechanics to `Footnotes.ts#footnoteAttachEdits`, exactly as both built-ins do. */
43
+ const freeFormAnnotate: SteeringFormat["annotate"] = (
44
+ content,
45
+ anchor,
46
+ text,
47
+ ): SteeringAnnotateResult => {
48
+ if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
49
+ const tree = parseMarkdown(content)
50
+ const lines = content.split(/\r?\n/)
51
+ const resolved = resolveParagraphAnchor(tree, lines, anchor.line)
52
+ if (!resolved) return { ok: false, reason: "anchor-not-found" }
53
+ const result = footnoteAttachEdits(content, resolved, text)
54
+ if (!result.ok) return result
55
+ return { ok: true, edits: result.edits }
56
+ }
57
+
58
+ /**
59
+ * A single edit spanning the WHOLE document, replacing it with `newText` —
60
+ * free-form's `apply` reasons about the resulting document as a plain string
61
+ * (splice/join on lines) rather than surgical offset math, since there is no
62
+ * structure here to preserve beyond the blocks themselves. Its end position
63
+ * is computed from a plain `"\n"` split — matching `applySteeringEdits`'s own
64
+ * offset convention (a CRLF line keeps its `\r` as part of the line's own
65
+ * length) rather than `/\r?\n/`, which would strip it and undercount.
66
+ */
67
+ const wholeDocumentEdit = (content: string, newText: string): SteeringEdit => {
68
+ const lines = content.split("\n")
69
+ return {
70
+ range: {
71
+ start: { line: 0, character: 0 },
72
+ end: { line: lines.length - 1, character: (lines[lines.length - 1] ?? "").length },
73
+ },
74
+ newText,
75
+ }
76
+ }
77
+
78
+ /**
79
+ * The append edit: `text` lands at the very end of the document, preceded by
80
+ * exactly one blank line — trailing whitespace on the existing document is
81
+ * trimmed first so this is idempotent regardless of how many blank lines
82
+ * already trail it. An empty `text` (an append row submitted with nothing
83
+ * typed) appends nothing rather than a bare trailing blank line.
84
+ */
85
+ const appendEdit = (content: string, text: string): SteeringEdit => {
86
+ const eol = eolOf(content)
87
+ const trimmed = content.replace(/\s+$/, "")
88
+ const normalizedText = text.split(/\r?\n/).join(eol)
89
+ if (text.length === 0)
90
+ return wholeDocumentEdit(content, trimmed.length > 0 ? `${trimmed}${eol}` : "")
91
+ const newText =
92
+ trimmed.length > 0 ? `${trimmed}${eol}${eol}${normalizedText}${eol}` : `${normalizedText}${eol}`
93
+ return wholeDocumentEdit(content, newText)
94
+ }
95
+
96
+ /**
97
+ * Free-form's `apply`: only a `paragraph` anchor resolves (the only kind
98
+ * `view` ever reports), and it only ever APPENDS, at or past the document's
99
+ * last line. An `anchor.line` landing on a real top-level block's own start
100
+ * line — or anywhere else inside the document short of its last line —
101
+ * refuses `anchor-not-found`: per-block replace/delete were dropped with the
102
+ * phone's own per-block editing UI (this package), leaving append as the
103
+ * only write this format still performs.
104
+ */
105
+ const freeFormApply: SteeringFormat["apply"] = (content, anchor, opts): SteeringAnnotateResult => {
106
+ if (anchor.kind !== "paragraph") return { ok: false, reason: "anchor-not-found" }
107
+ const text = opts.text ?? ""
108
+ const tree = parseMarkdown(content)
109
+ const lines = content.split(/\r?\n/)
110
+ const lastLineIndex = lines.length - 1
111
+ const isBlockStart = tree.children.some(
112
+ (node) =>
113
+ node.position !== undefined && toLspPosition(node.position.start).line === anchor.line,
114
+ )
115
+
116
+ if (isBlockStart) return { ok: false, reason: "anchor-not-found" }
117
+ if (anchor.line < lastLineIndex) return { ok: false, reason: "anchor-not-found" }
118
+ return { ok: true, edits: [appendEdit(content, text)] }
119
+ }
120
+
121
+ /** Free-form's `view`: every top-level block, in document order — no format-specific structure (no questions, no chunks) to project beyond the shared block walk. */
122
+ const freeFormView = (content: string): SteeringView => {
123
+ const tree = parseMarkdown(content)
124
+ return { nodes: blockNodesOf(content, tree) }
125
+ }
126
+
127
+ /**
128
+ * The free-form `SteeringFormat`: no structure to validate, outline, or
129
+ * offer code actions over — a mode-less steering file has no schema to
130
+ * conform to, so `validate`/`outline`/`actions` are all constant `[]`, and
131
+ * there is nothing to jump to (`pointerAt`/`documentLinks` stay absent).
132
+ * Exported but deliberately NOT registered in `index.ts#REGISTRY` — this
133
+ * package's later tasks wire it in as the FALLBACK for an absent or
134
+ * unregistered `mode`, never a mode name of its own.
135
+ */
136
+ export const freeFormFormat: SteeringFormat = {
137
+ sample: FREE_FORM_SAMPLE,
138
+ validate: threadFindings,
139
+ openThreads: openThreadsOf,
140
+ outline: (content) => threadOutlineNodes(content).map((t) => t.node),
141
+ actions: (content, range) => threadReplyActions(content, range.start),
142
+ view: freeFormView,
143
+ annotate: freeFormAnnotate,
144
+ apply: freeFormApply,
145
+ // Nothing here is a read-progress tick: free-form has no structure whose
146
+ // checkboxes mean anything to this engine.
147
+ clearTicks: (content) => content,
148
+ }
@@ -0,0 +1,62 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { BUILT_IN_MODE_NAMES, steeringFormatFor, unansweredQuestions, viewOf } from "./index.js"
3
+
4
+ const qa = steeringFormatFor("qa")!
5
+ const review = steeringFormatFor("review")!
6
+
7
+ describe("steeringFormatFor / BUILT_IN_MODE_NAMES", () => {
8
+ it("resolves both built-in modes by name", () => {
9
+ expect(steeringFormatFor("qa")).toBeDefined()
10
+ expect(steeringFormatFor("review")).toBeDefined()
11
+ })
12
+
13
+ it("is undefined for a name that isn't a built-in mode", () => {
14
+ expect(steeringFormatFor("bogus")).toBeUndefined()
15
+ })
16
+
17
+ it("lists both built-in mode names, in registry order", () => {
18
+ expect(BUILT_IN_MODE_NAMES).toEqual(["qa", "review"])
19
+ })
20
+ })
21
+
22
+ describe("dispatch over a resolved format — cross-format safety", () => {
23
+ it("each registered format validates with its own rule, not a shared or wrong one", () => {
24
+ expect(qa.validate("")).toEqual([])
25
+ expect(review.validate("").length).toBeGreaterThan(0)
26
+ })
27
+
28
+ it("unansweredQuestions is qa-only — review always answers []", () => {
29
+ expect(unansweredQuestions(review, review.sample)).toEqual([])
30
+ })
31
+
32
+ it("clearTicks is format-parameterised — running review's clearTicks over qa-shaped content is a no-op", () => {
33
+ const qaShapedContent = [
34
+ "Plan.",
35
+ "",
36
+ "## Open Questions",
37
+ "",
38
+ "### Q?",
39
+ "",
40
+ "- [x] Option A",
41
+ "- [ ] Option B",
42
+ "",
43
+ ].join("\n")
44
+ expect(qa.clearTicks(qaShapedContent)).toBe(qaShapedContent)
45
+ expect(review.clearTicks(qaShapedContent)).toBe(qaShapedContent)
46
+ })
47
+ })
48
+
49
+ describe("viewOf", () => {
50
+ it("qa's view has no documentLinks (qa declares none) but does have an outline", () => {
51
+ const view = viewOf(qa, qa.sample)
52
+ expect(view.documentLinks).toEqual([])
53
+ expect(view.outline.length).toBeGreaterThan(0)
54
+ })
55
+
56
+ it("review's documentLinks carry the hunk pointer's own range, matching its outline's hunk-adjacent chunk range", () => {
57
+ const view = viewOf(review, review.sample)
58
+ expect(view.documentLinks.length).toBe(1)
59
+ expect(view.documentLinks[0]!.path).toBe("./sample.ts")
60
+ expect(view.documentLinks[0]!.line).toBe(0)
61
+ })
62
+ })
@@ -0,0 +1,140 @@
1
+ import type {
2
+ SteeringAction,
3
+ SteeringFormat,
4
+ SteeringLink,
5
+ SteeringOutlineNode,
6
+ SteeringPointer,
7
+ } from "./SteeringFormat.js"
8
+ import { qaDescriptor } from "./qa.js"
9
+ import { reviewDescriptor } from "./review.js"
10
+ export { reviewNotes, reviewRisks, type ReviewNote } from "./review.js"
11
+ export { applySteeringEdits } from "./Edits.js"
12
+
13
+ export type { InlineNode } from "./Inline.js"
14
+
15
+ // The free-form format: the mode-less/unregistered-mode FALLBACK —
16
+ // deliberately not a `REGISTRY` entry of its own, since it is never a mode a
17
+ // `mode:` key names.
18
+ import { freeFormFormat } from "./freeform.js"
19
+ export { freeFormFormat }
20
+
21
+ export type {
22
+ BlockListItem,
23
+ SteeringAction,
24
+ SteeringAnchor,
25
+ SteeringFinding,
26
+ SteeringFormat,
27
+ SteeringLink,
28
+ SteeringOutlineNode,
29
+ SteeringPointer,
30
+ SteeringView,
31
+ SteeringViewNode,
32
+ SteeringViewThread,
33
+ } from "./SteeringFormat.js"
34
+
35
+ /** The built-in registry: mode name → its format, in registry order. The single place `qa` and `review` are wired into names — everything else in this module dispatches over a resolved `SteeringFormat`, never a mode-name string. */
36
+ const REGISTRY: ReadonlyMap<string, SteeringFormat> = new Map([
37
+ ["qa", qaDescriptor],
38
+ ["review", reviewDescriptor],
39
+ ])
40
+
41
+ /** Every built-in format name, in registry order. */
42
+ export const BUILT_IN_MODE_NAMES: readonly string[] = [...REGISTRY.keys()]
43
+
44
+ // The two `qa` primitives the phone client itself needs: `src/web/screens/
45
+ // Question.tsx` renders the free-text slot and derives answeredness in the
46
+ // browser, against the SAME constant and predicate the server validates
47
+ // with — a second copy there would drift silently.
48
+ export { FREE_TEXT_PLACEHOLDER, isAnswered } from "./qa.js"
49
+
50
+ // `ModeContradiction.ts`'s round-trip check re-parses a formatted sample's
51
+ // footnotes to prove the formatter moved none of them — the one consumer
52
+ // outside this package that needs the footnote parser itself, not a format.
53
+ export {
54
+ parseFootnotes,
55
+ parseThreads,
56
+ parseThreadsWithFindings,
57
+ openThreadFindings,
58
+ FOOTNOTE_ACTION_TITLE,
59
+ THREAD_REPLY_ACTION_TITLE,
60
+ } from "./Footnotes.js"
61
+
62
+ // Test-observability only: the memo-hit counter is the one way "one parse per
63
+ // document" is assertable from outside `MarkdownTree.ts` — timing is not.
64
+ export { getParseCount } from "./MarkdownTree.js"
65
+
66
+ // A flow's `sections()` is the one caller outside this package.
67
+ export { headingSectionBodies, headingSections } from "./MarkdownTree.js"
68
+
69
+ /** The built-in `SteeringFormat` registered under `mode`'s name, or `undefined` when it isn't a built-in mode at all. The one place a bare mode-name string is ever looked up — every consumer takes the RESOLVED value this returns, never a name, so "mode is required, never inferred from a file's basename" is a type, not a rule to remember. */
70
+ export const steeringFormatFor = (mode: string): SteeringFormat | undefined => REGISTRY.get(mode)
71
+
72
+ /**
73
+ * The ui boundary's own resolution: `mode`'s registered format, or
74
+ * `freeFormFormat` when `mode` is absent or names nothing in `REGISTRY` —
75
+ * never `undefined`. `resolveMode` (`SteeringMode.ts`) stays on
76
+ * `steeringFormatFor` alone, so a workflow naming a mode nothing defines
77
+ * still fails to compile; this fallback is deliberately reachable only from
78
+ * `src/ui/View.ts` and `src/ui/Write.ts`, where a mode-less/unknown-mode
79
+ * document must still render and accept a paragraph note, never refuse.
80
+ */
81
+ export const steeringFormatOrFreeForm = (mode: string | undefined): SteeringFormat =>
82
+ (mode !== undefined ? REGISTRY.get(mode) : undefined) ?? freeFormFormat
83
+
84
+ /**
85
+ * `qa`-only: every OPEN question in `content` not yet answered, as
86
+ * `{ question, headingLine }` pairs — enough for a refusal message ("which
87
+ * question", "where"). Semantically distinct from `validate`'s findings:
88
+ * a document can be perfectly well-formed (zero findings) while still having
89
+ * open questions nobody has answered, which is the whole point of the
90
+ * answer-completeness gate this feeds. Format-parameterised like everything
91
+ * else here: `review` (or any future format with no such concept) yields
92
+ * `[]` rather than this being a `qa`-specific function some caller has to
93
+ * know not to call on the wrong format.
94
+ */
95
+ export const unansweredQuestions = (
96
+ format: SteeringFormat,
97
+ content: string,
98
+ ): readonly { readonly question: string; readonly headingLine: number }[] =>
99
+ format.unansweredQuestions?.(content) ?? []
100
+
101
+ /**
102
+ * Every positional thing an editor (the LSP, in a later pass) needs for one
103
+ * document, off ONE `viewOf` call rather than four separate ones re-deriving
104
+ * ranges each time: `outline` and `documentLinks` are already fully resolved
105
+ * (each node/link carries its own range — see `SteeringOutlineNode`/
106
+ * `SteeringLink`), while `pointerAt`/`actionsAt` stay closures over `content`
107
+ * because they need a cursor position/selection range the caller supplies
108
+ * per request. `Lsp.ts` reads its ranges straight off `outline`/`documentLinks`.
109
+ */
110
+ export interface SteeringEditorView {
111
+ readonly outline: readonly SteeringOutlineNode[]
112
+ readonly documentLinks: readonly SteeringLink[]
113
+ readonly pointerAt: (position: {
114
+ readonly line: number
115
+ readonly character: number
116
+ }) => SteeringPointer | undefined
117
+ readonly actionsAt: (range: {
118
+ readonly start: { readonly line: number; readonly character: number }
119
+ readonly end: { readonly line: number; readonly character: number }
120
+ }) => readonly SteeringAction[]
121
+ }
122
+
123
+ /** Builds `format`'s `SteeringEditorView` of `content` — see `SteeringEditorView`'s own doc for why this is one value, not several. Distinct from `SteeringView` (`SteeringFormat.ts`), which is the format's own node tree the phone client renders. */
124
+ export const viewOf = (format: SteeringFormat, content: string): SteeringEditorView => ({
125
+ outline: format.outline(content),
126
+ documentLinks: format.documentLinks ? format.documentLinks(content) : [],
127
+ pointerAt: (position) => format.pointerAt?.(content, position),
128
+ actionsAt: (range) => format.actions(content, range),
129
+ })
130
+
131
+ /** Every OPEN thread — last entry is the agent's — as `{ name, line, firstMe }` (0-based line). `[]` for a format with no thread concept. */
132
+ export const openThreads = (
133
+ format: SteeringFormat,
134
+ content: string,
135
+ ): readonly { readonly name: string; readonly line: number; readonly firstMe: string }[] =>
136
+ format.openThreads?.(content) ?? []
137
+
138
+ // Line-comment `H:`/`A:` threads in changed code files: a flow's `codeThreads()`
139
+ // and `gtd check --open-threads` are the callers outside this package.
140
+ export { parseCodeThreads, stripCodeThreads } from "./CodeThreads.js"