@pmelab/gtd 24.0.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 (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  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 +43339 -55575
  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 +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,661 @@
1
+ import type { Paragraph, Root } from "mdast"
2
+ import {
3
+ blockNodeAt,
4
+ lineRange,
5
+ parseMarkdown,
6
+ sourceText,
7
+ spanRange,
8
+ toLspPosition,
9
+ toLspPositionFromOffset,
10
+ } from "./MarkdownTree.js"
11
+ import type {
12
+ SteeringAction,
13
+ SteeringEdit,
14
+ SteeringFinding,
15
+ SteeringOutlineNode,
16
+ SteeringPointer,
17
+ SteeringViewThread,
18
+ } from "./SteeringFormat.js"
19
+ import { eolOf } from "./Eol.js"
20
+ import { collectThreads } from "./Threads.js"
21
+ import type { Thread } from "./Threads.js"
22
+
23
+ /** One `[^name]` marker's anchor: line AND column of its opening `[` — never the word or sentence it follows, which the reader reads itself. `endCharacter` is the column right after the closing `]`, on the same line (a marker's name has no whitespace, so it never spans a line) — the reference node's (or, for an orphan, the regex match's) own end, never hand-computed from `name.length`. */
24
+ export interface FootnoteMarker {
25
+ readonly name: string
26
+ readonly line: number
27
+ readonly character: number
28
+ readonly endCharacter: number
29
+ }
30
+
31
+ /**
32
+ * One `[^name]:` definition — matched to its marker(s) by NAME alone, never
33
+ * by position. `line`/`endLine` are the real GFM continuation span (from the
34
+ * tree, not a hand-rolled rule): a continuation line needs FOUR spaces of
35
+ * indent, not the two the old line-based parser used to accept, and an
36
+ * unindented line right after one still extends the body as a LAZY paragraph
37
+ * continuation — both a change from this package's predecessor, since GFM's
38
+ * own rule differs from it.
39
+ */
40
+ export interface FootnoteDefinition {
41
+ readonly name: string
42
+ readonly line: number
43
+ readonly endLine: number
44
+ readonly body: string
45
+ }
46
+
47
+ export interface Footnotes {
48
+ readonly markers: readonly FootnoteMarker[]
49
+ readonly definitions: readonly FootnoteDefinition[]
50
+ readonly findings: readonly SteeringFinding[]
51
+ }
52
+
53
+ /** The footnote-addition action's title — the ONE shared literal `qa`/`review` offer it under and `src/lsp/Lsp.ts` matches to compute the reveal position. A drifted copy in any one place silently breaks the cursor jump without failing a single test, so this constant is the only place the string is spelled. */
54
+ export const FOOTNOTE_ACTION_TITLE = "gtd: add a footnote"
55
+
56
+ /** The thread-reply action's title — shared with `src/lsp/Lsp.ts` for the same reason as `FOOTNOTE_ACTION_TITLE`. */
57
+ export const THREAD_REPLY_ACTION_TITLE = "gtd: reply"
58
+
59
+ /**
60
+ * A marker's shape once it's plain text: `[^name]`, name has no whitespace
61
+ * and no `]`. Used to recognize an ORPHAN `[^name]` sitting inside an
62
+ * ordinary `text` node — GFM only turns `[^name]` into a real
63
+ * `footnoteReference` node when a matching definition exists; without one it
64
+ * stays literal text, which is exactly the shape this pattern matches.
65
+ */
66
+ const ORPHAN_MARKER_RE = /\[\^([^\s\]]+)\]/g
67
+
68
+ /** Markdown-whitespace-collapsed, case-folded form of a footnote name, for matching a marker to its definition regardless of authored casing — mirrors mdast's own `identifier` normalization (footnote names never contain internal whitespace, so case-folding alone suffices here). */
69
+ const foldName = (name: string): string => name.toLowerCase()
70
+
71
+ /** A `FootnoteMarker`'s own `[^name]` span, as a `SteeringFinding.range` — the marker NODE the finding is about. */
72
+ const markerRange = (marker: FootnoteMarker) => ({
73
+ start: { line: marker.line, character: marker.character },
74
+ end: { line: marker.line, character: marker.endCharacter },
75
+ })
76
+
77
+ /** A `FootnoteDefinition`'s own span (its `[^name]:` line through its last continuation line), as a `SteeringFinding.range` — the definition NODE the finding is about. */
78
+ const definitionRange = (lines: readonly string[], def: FootnoteDefinition) => ({
79
+ start: { line: def.line, character: 0 },
80
+ end: { line: def.endLine, character: (lines[def.endLine] ?? "").length },
81
+ })
82
+
83
+ /** The stranded paragraph right after `def`, when the raw next line is indented 1-3 spaces (GFM's own threshold: 4+ joins the body, a tab expands to the next 4-column stop and joins too) and that line itself opens the top-level `paragraph` the tree sees there — never a second definition, heading, list, fence or other block, which the type check excludes structurally rather than via a hand-rolled list of block openers. */
84
+ const strandedParagraphAfter = (
85
+ tree: Root,
86
+ lines: readonly string[],
87
+ def: FootnoteDefinition,
88
+ ): Paragraph | undefined => {
89
+ const nextLine = def.endLine + 1
90
+ if (!/^ {1,3}\S/.test(lines[nextLine] ?? "")) return undefined
91
+ const block = blockNodeAt(tree, nextLine)
92
+ if (block?.type !== "paragraph") return undefined
93
+ if (toLspPosition(block.position!.start).line !== nextLine) return undefined
94
+ return block
95
+ }
96
+
97
+ const computeFindings = (
98
+ content: string,
99
+ lines: readonly string[],
100
+ tree: Root,
101
+ markers: readonly FootnoteMarker[],
102
+ definitions: readonly FootnoteDefinition[],
103
+ ): readonly SteeringFinding[] => {
104
+ const findings: SteeringFinding[] = []
105
+ const definedIds = new Set(definitions.map((d) => foldName(d.name)))
106
+ const referencedIds = new Set(markers.map((m) => foldName(m.name)))
107
+ const seenDefinitionIds = new Set<string>()
108
+
109
+ for (const marker of markers) {
110
+ if (!definedIds.has(foldName(marker.name))) {
111
+ findings.push({
112
+ message: `Footnote marker "[^${marker.name}]" has no matching definition`,
113
+ line: marker.line,
114
+ range: markerRange(marker),
115
+ })
116
+ }
117
+ }
118
+
119
+ for (const def of definitions) {
120
+ const id = foldName(def.name)
121
+ if (seenDefinitionIds.has(id)) {
122
+ findings.push({
123
+ message: `Duplicate footnote definition "[^${def.name}]"`,
124
+ line: def.line,
125
+ range: definitionRange(lines, def),
126
+ })
127
+ } else {
128
+ seenDefinitionIds.add(id)
129
+ }
130
+ if (!referencedIds.has(id)) {
131
+ findings.push({
132
+ message: `Footnote definition "[^${def.name}]" has no marker referencing it`,
133
+ line: def.line,
134
+ range: definitionRange(lines, def),
135
+ })
136
+ }
137
+ // The body being empty has two distinct causes that read the same on
138
+ // `def` alone (`def.body` is empty either way) — telling them apart
139
+ // needs the raw source below the definition, which no other finding
140
+ // here depends on.
141
+ if (def.body.trim() === "") {
142
+ const stranded = strandedParagraphAfter(tree, lines, def)
143
+ if (stranded) {
144
+ findings.push({
145
+ message: `Footnote definition "[^${def.name}]": the text below it is indented too little to belong to it — indent continuation lines by 4 spaces`,
146
+ line: def.line,
147
+ range: spanRange(lines, def.line, toLspPosition(stranded.position!.end).line),
148
+ })
149
+ } else {
150
+ findings.push({
151
+ message: `Footnote definition "[^${def.name}]" has an empty body`,
152
+ line: def.line,
153
+ range: definitionRange(lines, def),
154
+ })
155
+ }
156
+ }
157
+ }
158
+
159
+ findings.push(...collectThreads(content, tree, markers).findings)
160
+ return findings.sort((a, b) => (a.line ?? 0) - (b.line ?? 0))
161
+ }
162
+
163
+ /**
164
+ * Every `[^name]` in a `text` node's SOURCE slice, never `node.value` — a
165
+ * character reference makes `value` shorter than its source and throws off
166
+ * every subsequent column. Each hit is an orphan by construction: GFM keeps
167
+ * `[^name]` as literal text only when no definition matches. A `text` node can
168
+ * span several lines, so positions come from offsets, never one line per node.
169
+ */
170
+ const orphanMarkers = (content: string, tree: Root): FootnoteMarker[] => {
171
+ const found: FootnoteMarker[] = []
172
+ const walk = (node: {
173
+ readonly type: string
174
+ readonly position?: unknown
175
+ readonly children?: unknown
176
+ }): void => {
177
+ if (node.type === "text" && node.position) {
178
+ const { start, end } = node.position as { start: { offset: number }; end: { offset: number } }
179
+ const slice = content.slice(start.offset, end.offset)
180
+ const re = new RegExp(ORPHAN_MARKER_RE)
181
+ let match: RegExpExecArray | null
182
+ while ((match = re.exec(slice)) !== null) {
183
+ const position = toLspPositionFromOffset(content, start.offset + match.index)
184
+ const endPosition = toLspPositionFromOffset(
185
+ content,
186
+ start.offset + match.index + match[0].length,
187
+ )
188
+ found.push({
189
+ name: match[1]!,
190
+ line: position.line,
191
+ character: position.character,
192
+ endCharacter: endPosition.character,
193
+ })
194
+ }
195
+ }
196
+ const children = node.children as readonly (typeof node)[] | undefined
197
+ if (children) children.forEach(walk)
198
+ }
199
+ walk(tree)
200
+ return found
201
+ }
202
+
203
+ /**
204
+ * Parses every footnote marker and definition out of `content` off a single
205
+ * mdast parse (`parseMarkdown`'s own memo makes a second call on the same
206
+ * string free). Total and side-effect-free: always returns a result, never
207
+ * throws, because `parseMarkdown` never does. Fenced code, indented code,
208
+ * and inline-code spans are excluded from marker/definition recognition
209
+ * structurally — they simply parse as other node types — rather than by a
210
+ * hand-rolled skip list.
211
+ */
212
+ type FootnoteTreeNode = {
213
+ readonly type: string
214
+ readonly position?: unknown
215
+ readonly children?: unknown
216
+ readonly label?: string
217
+ readonly identifier?: string
218
+ }
219
+
220
+ /** Pushes `node` (a real `footnoteReference`) onto `markers` as a `FootnoteMarker`, its span taken from the reference node's own start/end — never hand-computed from `name.length`. */
221
+ const collectMarker = (node: FootnoteTreeNode, markers: FootnoteMarker[]): void => {
222
+ const pos = node.position as {
223
+ start: { line: number; column: number }
224
+ end: { line: number; column: number }
225
+ }
226
+ const position = toLspPosition(pos.start)
227
+ const endPosition = toLspPosition(pos.end)
228
+ markers.push({
229
+ name: node.label ?? node.identifier ?? "",
230
+ line: position.line,
231
+ character: position.character,
232
+ endCharacter: endPosition.character,
233
+ })
234
+ }
235
+
236
+ /** Pushes `node` (a real `footnoteDefinition`) onto `definitions` as a `FootnoteDefinition`, its body joined from `sourceText` over each child. */
237
+ const collectDefinition = (
238
+ node: FootnoteTreeNode,
239
+ content: string,
240
+ definitions: FootnoteDefinition[],
241
+ ): void => {
242
+ const pos = node.position as {
243
+ start: { line: number; column: number }
244
+ end: { line: number; column: number }
245
+ }
246
+ const children = (node.children as readonly Parameters<typeof sourceText>[1][]) ?? []
247
+ const body = children
248
+ .map((child) => sourceText(content, child))
249
+ .join(" ")
250
+ .trim()
251
+ definitions.push({
252
+ name: node.label ?? node.identifier ?? "",
253
+ line: toLspPosition(pos.start).line,
254
+ endLine: toLspPosition(pos.end).line,
255
+ body,
256
+ })
257
+ }
258
+
259
+ export const parseFootnotes = (content: string): Footnotes => {
260
+ const tree = parseMarkdown(content)
261
+ const markers: FootnoteMarker[] = []
262
+ const definitions: FootnoteDefinition[] = []
263
+
264
+ const walk = (node: FootnoteTreeNode): void => {
265
+ if (node.type === "footnoteReference" && node.position) {
266
+ collectMarker(node, markers)
267
+ } else if (node.type === "footnoteDefinition" && node.position) {
268
+ collectDefinition(node, content, definitions)
269
+ }
270
+ const children = node.children as readonly FootnoteTreeNode[] | undefined
271
+ if (children) children.forEach(walk)
272
+ }
273
+ walk(tree)
274
+
275
+ markers.push(...orphanMarkers(content, tree))
276
+ markers.sort((a, b) => a.line - b.line || a.character - b.character)
277
+
278
+ const lines = content.split(/\r?\n/)
279
+ return {
280
+ markers,
281
+ definitions,
282
+ findings: computeFindings(content, lines, tree, markers, definitions),
283
+ }
284
+ }
285
+
286
+ /** Every thread (a footnote whose body is a `H:`/`A:` list) in `content` and its syntax findings, each prefixed `Footnote thread "[^name]": `, off the same `parseMarkdown` memo as `parseFootnotes`. */
287
+ export const parseThreadsWithFindings = (
288
+ content: string,
289
+ ): { readonly threads: readonly Thread[]; readonly findings: readonly SteeringFinding[] } =>
290
+ collectThreads(content, parseMarkdown(content), parseFootnotes(content).markers)
291
+
292
+ export const parseThreads = (content: string): readonly Thread[] =>
293
+ parseThreadsWithFindings(content).threads
294
+
295
+ /** One-entry memo: a view walks many runs of ONE document, each building its own lookup. */
296
+ let memo: { content: string; threads: ReadonlyMap<string, Thread> } | undefined
297
+ const threadsByName = (content: string, footnotes: Footnotes): ReadonlyMap<string, Thread> => {
298
+ if (memo?.content !== content) {
299
+ const threads = collectThreads(content, parseMarkdown(content), footnotes.markers).threads
300
+ memo = { content, threads: new Map(threads.map((t) => [foldName(t.name), t])) }
301
+ }
302
+ return memo.threads
303
+ }
304
+
305
+ /**
306
+ * A view node's note, resolved by the line its markers sit on. A thread fills
307
+ * `thread` and suppresses `note`; a one-shot footnote fills `note`. Built once
308
+ * per document so a view walking many nodes never re-parses per node.
309
+ */
310
+ export const noteLookup = (
311
+ content: string,
312
+ footnotes: Footnotes = parseFootnotes(content),
313
+ ): ((line: number) => { readonly note?: string; readonly thread?: SteeringViewThread }) => {
314
+ const threads = threadsByName(content, footnotes)
315
+ const bodies = new Map(footnotes.definitions.map((d) => [d.name, d.body]))
316
+ return (line) => {
317
+ const named = footnotes.markers.filter((m) => m.line === line)
318
+ const thread = named.map((m) => threads.get(foldName(m.name))).find((t) => t !== undefined)
319
+ if (thread) {
320
+ return {
321
+ thread: {
322
+ name: thread.name,
323
+ entries: thread.entries.map((e) => ({ author: e.author, text: e.text })),
324
+ waitingOn: thread.waitingOn,
325
+ },
326
+ }
327
+ }
328
+ const note = named
329
+ .map((m) => bodies.get(m.name))
330
+ .filter((body): body is string => body !== undefined)
331
+ .join(" ")
332
+ return note === "" ? {} : { note }
333
+ }
334
+ }
335
+
336
+ /** Thread syntax findings alone — what free-form `validate` reports without inheriting the other footnote findings. */
337
+ export const threadFindings = (content: string): readonly SteeringFinding[] =>
338
+ collectThreads(content, parseMarkdown(content), []).findings
339
+
340
+ /** The first integer unused by any `fnN` marker or definition already in the document — deterministic (no clock, no randomness), so "add a footnote" is testable and idempotent under re-run: applying it twice yields `fn1` then `fn2`, never a collision. Counts orphan markers too (via `parseFootnotes`), so it never reuses a name that's already written but undefined. */
341
+ export const nextFootnoteName = (content: string): string => {
342
+ const { markers, definitions } = parseFootnotes(content)
343
+ const NAME_RE = /^fn(\d+)$/i
344
+ const used = new Set<number>()
345
+ for (const name of [...markers.map((m) => m.name), ...definitions.map((d) => d.name)]) {
346
+ const match = NAME_RE.exec(name)
347
+ if (match) used.add(Number(match[1]))
348
+ }
349
+ let n = 1
350
+ while (used.has(n)) n += 1
351
+ return `fn${n}`
352
+ }
353
+
354
+ /** The first line index at or after `from` that is non-blank, or `lines.length` when none remain (EOF). */
355
+ const firstNonBlankFrom = (lines: readonly string[], from: number): number => {
356
+ let i = from
357
+ while (i < lines.length && lines[i]!.trim().length === 0) i += 1
358
+ return i
359
+ }
360
+
361
+ /** The marker whose `[^name]` span (inclusive of both brackets) contains `position`, or `undefined`. Shared by `footnotePointerAt` and `isOnExistingFootnote` so the two can never disagree about what counts as "inside a marker". */
362
+ const markerAt = (
363
+ markers: readonly FootnoteMarker[],
364
+ position: { readonly line: number; readonly character: number },
365
+ ): FootnoteMarker | undefined =>
366
+ markers.find((m) => {
367
+ if (m.line !== position.line) return false
368
+ return position.character >= m.character && position.character <= m.endCharacter
369
+ })
370
+
371
+ /** The guard "gtd: add a footnote" refuses itself on, rather than planting a marker inside another marker's name or a definition between an existing label and its body. */
372
+ export const isOnExistingFootnote = (
373
+ content: string,
374
+ position: { readonly line: number; readonly character: number },
375
+ ): boolean => {
376
+ const { markers, definitions } = parseFootnotes(content)
377
+ if (markerAt(markers, position)) return true
378
+ return definitions.some((d) => position.line >= d.line && position.line <= d.endLine)
379
+ }
380
+
381
+ /**
382
+ * Deterministic short id derived from an anchor's own `key` — FNV-1a over the
383
+ * UTF-8 bytes of `key`, base36-encoded — never a counter. Two concurrent
384
+ * attaches at two different anchors (different `key`s) land on two distinct
385
+ * ids without either seeing the other's write; two attaches at the SAME
386
+ * anchor (same `key`) always land on the SAME id, which is exactly what lets
387
+ * the caller reject the second one as a collision rather than double-attach.
388
+ */
389
+ const anchorId = (key: string): string => {
390
+ let hash = 0x811c9dc5
391
+ for (let i = 0; i < key.length; i += 1) {
392
+ hash ^= key.charCodeAt(i)
393
+ hash = Math.imul(hash, 0x01000193)
394
+ }
395
+ return `na${(hash >>> 0).toString(36)}`
396
+ }
397
+
398
+ /** Where the server's `annotate` identifies the spot to attach a note: `line`/`endCharacter` are the anchor's own end (where the marker lands), `blockEndLine` is where the definition is planted (mirrors `footnoteAdditionEdits`'s `blockEndLine`), and `key` is a string uniquely identifying the anchor itself (never a cursor position) — the input to `anchorId`. */
399
+ export interface FootnoteAnchor {
400
+ readonly line: number
401
+ readonly endCharacter: number
402
+ readonly blockEndLine: number
403
+ readonly key: string
404
+ }
405
+
406
+ /** Entry text with continuation lines indented past the bullet's content column, so they stay inside the entry. */
407
+ const entryText = (text: string, eol: string): string => text.split(/\r?\n/).join(`${eol} `)
408
+
409
+ export type FootnoteAttachResult =
410
+ | { readonly ok: true; readonly id: string; readonly edits: readonly SteeringEdit[] }
411
+ | { readonly ok: false; readonly reason: "id-collision" }
412
+
413
+ /**
414
+ * Attaches a note at `anchor`'s end: a marker plus a definition seeded with
415
+ * `text` VERBATIM, never the empty seed `footnoteAdditionEdits` writes — a
416
+ * server-attached note has its real body at attach time, so the document
417
+ * validates clean immediately instead of tripping the empty-body finding.
418
+ * `id` derives from `anchor.key` alone, never a counter, so two concurrent
419
+ * requests need no shared state.
420
+ *
421
+ * A SECOND call at the SAME anchor is an EDIT, not a collision: a marker whose
422
+ * folded name equals `id` AND sits on `anchor.line` — the exact spot this
423
+ * anchor's own marker edit would land — can only be its earlier attach. That
424
+ * replaces the definition's body span in one edit. The same name collision
425
+ * with NO marker there is genuinely ambiguous and still refuses.
426
+ */
427
+ export const footnoteAttachEdits = (
428
+ content: string,
429
+ anchor: FootnoteAnchor,
430
+ text: string,
431
+ ): FootnoteAttachResult => {
432
+ const { markers, definitions } = parseFootnotes(content)
433
+ const id = anchorId(anchor.key)
434
+ const existing = definitions.find((d) => foldName(d.name) === foldName(id))
435
+ if (!existing) return { ok: true, id, edits: newThreadEdits(content, anchor, id, text) }
436
+ const attachedHere = markers.some(
437
+ (m) => foldName(m.name) === foldName(id) && m.line === anchor.line,
438
+ )
439
+ if (!attachedHere) return { ok: false, reason: "id-collision" }
440
+ return { ok: true, id, edits: editExistingEdits(content, existing, id, text) }
441
+ }
442
+
443
+ /** The edit for a second attach at an anchor whose note already exists: reply to / replace into a thread, or replace a one-shot body. */
444
+ const editExistingEdits = (
445
+ content: string,
446
+ existing: FootnoteDefinition,
447
+ id: string,
448
+ text: string,
449
+ ): readonly SteeringEdit[] => {
450
+ const lines = content.split(/\r?\n/)
451
+ const eol = eolOf(content)
452
+ const thread = parseThreads(content).find((t) => foldName(t.name) === foldName(id))
453
+ const last = thread?.entries[thread.entries.length - 1]
454
+ const endOf = (line: number) => ({ line, character: (lines[line] ?? "").length })
455
+ if (thread && last) {
456
+ const entry = `- H: ${entryText(text, eol)}`
457
+ if (thread.waitingOn === "human") {
458
+ // An entry opening on the definition's own line has no bullet column to copy.
459
+ const indent = " ".repeat(last.line === thread.line ? 4 : last.column)
460
+ const at = endOf(last.endLine)
461
+ return [{ range: { start: at, end: at }, newText: `${eol}${indent}${entry}` }]
462
+ }
463
+ return [
464
+ {
465
+ range: {
466
+ start: { line: last.line, character: last.column },
467
+ end: endOf(last.endLine),
468
+ },
469
+ newText: entry,
470
+ },
471
+ ]
472
+ }
473
+ return [
474
+ {
475
+ range: { start: { line: existing.line, character: 0 }, end: endOf(existing.endLine) },
476
+ newText: `[^${id}]: ${text}`,
477
+ },
478
+ ]
479
+ }
480
+
481
+ /** A marker at the anchor's end plus a fresh single-entry `- H:` thread after its block. */
482
+ const newThreadEdits = (
483
+ content: string,
484
+ anchor: FootnoteAnchor,
485
+ id: string,
486
+ text: string,
487
+ ): readonly SteeringEdit[] => {
488
+ const lines = content.split(/\r?\n/)
489
+ // The document's own newline style, preserved in newly-written bytes — a
490
+ // splice into a CRLF file that inserts bare `\n` would leave a mixed-EOL
491
+ // file behind, which is exactly the byte-level corruption this whole
492
+ // package's offset-splice discipline exists to avoid.
493
+ const eol = eolOf(content)
494
+ const markerEdit: SteeringEdit = {
495
+ range: {
496
+ start: { line: anchor.line, character: anchor.endCharacter },
497
+ end: { line: anchor.line, character: anchor.endCharacter },
498
+ },
499
+ newText: `[^${id}]`,
500
+ }
501
+
502
+ const insertLine = anchor.blockEndLine + 1
503
+ const start = { line: insertLine, character: 0 }
504
+ const nextContentLine = firstNonBlankFrom(lines, insertLine)
505
+ const atEof = nextContentLine >= lines.length
506
+ const body = `[^${id}]:${eol} - H: ${entryText(text, eol)}`
507
+ const definitionEdit: SteeringEdit = {
508
+ range: { start, end: atEof ? start : { line: nextContentLine, character: 0 } },
509
+ newText: atEof ? `${eol}${body}${eol}` : `${eol}${body}${eol}${eol}`,
510
+ }
511
+ return [markerEdit, definitionEdit]
512
+ }
513
+
514
+ /**
515
+ * The two edits behind "gtd: add a footnote": a marker at exactly the cursor
516
+ * position (no scan, no `+1`) and an EMPTY definition after `blockEndLine`,
517
+ * which each caller resolves itself. The definition edit REPLACES the whole
518
+ * blank-line run after it, so the result is deterministic whatever the
519
+ * surrounding whitespace.
520
+ *
521
+ * That edit starts at `(blockEndLine + 1, 0)`, never at the end of
522
+ * `blockEndLine` — a line past the last index is a legal LSP position,
523
+ * clamped to EOF. Anchoring one line later is what guarantees the two ranges
524
+ * never touch: LSP gives coincident ranges in one action no defined
525
+ * application order, and a naive apply corrupts whatever sits at that offset.
526
+ */
527
+ export const footnoteAdditionEdits = (
528
+ content: string,
529
+ position: { readonly line: number; readonly character: number },
530
+ blockEndLine: number,
531
+ ): readonly SteeringEdit[] => {
532
+ const lines = content.split(/\r?\n/)
533
+ const name = nextFootnoteName(content)
534
+
535
+ const markerEdit: SteeringEdit = {
536
+ range: {
537
+ start: { line: position.line, character: position.character },
538
+ end: { line: position.line, character: position.character },
539
+ },
540
+ newText: `[^${name}]`,
541
+ }
542
+
543
+ const insertLine = blockEndLine + 1
544
+ const start = { line: insertLine, character: 0 }
545
+ const nextContentLine = firstNonBlankFrom(lines, insertLine)
546
+ const atEof = nextContentLine >= lines.length
547
+ const definitionEdit: SteeringEdit = {
548
+ range: { start, end: atEof ? start : { line: nextContentLine, character: 0 } },
549
+ newText: atEof ? `\n[^${name}]:\n` : `\n[^${name}]:\n\n`,
550
+ }
551
+
552
+ return [markerEdit, definitionEdit]
553
+ }
554
+
555
+ /**
556
+ * The footnote half of `pointerAt`. `review` tries this FIRST because
557
+ * footnotes are column-scoped and its hunk jump is line-scoped, which would
558
+ * otherwise shadow a marker inside a hunk's note. `undefined` means "not a
559
+ * footnote, try the next resolver"; `{ pointer: undefined }` means "resolved,
560
+ * but to nothing" — the caller must NOT fall through.
561
+ */
562
+ export const footnotePointerAt = (
563
+ content: string,
564
+ position: { readonly line: number; readonly character: number },
565
+ ): { readonly pointer: SteeringPointer | undefined } | undefined => {
566
+ const { markers, definitions } = parseFootnotes(content)
567
+
568
+ const marker = markerAt(markers, position)
569
+ if (marker) {
570
+ const definition = definitions.find((d) => foldName(d.name) === foldName(marker.name))
571
+ return { pointer: definition ? { line: definition.line } : undefined }
572
+ }
573
+
574
+ // The definition's OWN span, from the tree's real GFM continuation rule
575
+ // (four spaces, plus lazy-paragraph continuation) — a looser, hand-rolled
576
+ // "any indent continues" rule would disagree with it on some lines, and
577
+ // trusting that instead would resolve a position to "on a definition, but
578
+ // no pointer" when it isn't on this definition at all — wrongly blocking a
579
+ // caller's fallback (e.g. `review`'s hunk jump) instead of correctly not
580
+ // applying.
581
+ const definition = definitions.find((d) => position.line >= d.line && position.line <= d.endLine)
582
+ if (definition) {
583
+ const firstMarker = markers.find((m) => foldName(m.name) === foldName(definition.name))
584
+ return {
585
+ pointer: firstMarker
586
+ ? { line: firstMarker.line, character: firstMarker.character }
587
+ : undefined,
588
+ }
589
+ }
590
+
591
+ return undefined
592
+ }
593
+
594
+ /** The open threads, shaped for `openThreads` in every format that supports it. */
595
+ export const openThreadsOf = (
596
+ content: string,
597
+ ): readonly { readonly name: string; readonly line: number; readonly firstMe: string }[] =>
598
+ parseThreads(content)
599
+ .filter((t) => t.waitingOn === "human")
600
+ .map((t) => ({
601
+ name: t.name,
602
+ line: t.line,
603
+ firstMe: t.entries.find((e) => e.author === "me")?.text ?? "",
604
+ }))
605
+
606
+ /** Threads as outline leaves, each paired with the line of its first marker (`undefined` when it has none) so a format can nest it under the block that marker sits in. A one-shot footnote is not a thread and yields nothing. */
607
+ export const threadOutlineNodes = (
608
+ content: string,
609
+ ): readonly { readonly markerLine: number | undefined; readonly node: SteeringOutlineNode }[] => {
610
+ const lines = content.split(/\r?\n/)
611
+ return parseThreads(content).map((t) => ({
612
+ markerLine: t.markers[0]?.line,
613
+ node: {
614
+ name: `[^${t.name}]`,
615
+ detail: t.waitingOn === "human" ? "waiting on you" : "waiting on the agent",
616
+ range: spanRange(lines, t.line, t.endLine),
617
+ selectionRange: lineRange(lines, t.line),
618
+ leaf: true,
619
+ },
620
+ }))
621
+ }
622
+
623
+ /** One `Information` finding per open thread, on its last entry — what an editor flags as "your turn". */
624
+ export const openThreadFindings = (content: string): readonly SteeringFinding[] => {
625
+ const lines = content.split(/\r?\n/)
626
+ return parseThreads(content)
627
+ .filter((t) => t.waitingOn === "human")
628
+ .map((t) => {
629
+ const last = t.entries[t.entries.length - 1]!
630
+ return {
631
+ message: `Footnote thread "[^${t.name}]" is waiting on you — reply with an "H:" entry`,
632
+ line: last.line,
633
+ range: spanRange(lines, last.line, last.endLine),
634
+ }
635
+ })
636
+ }
637
+
638
+ /** "gtd: reply" on an open thread when `position` is on one of its markers or anywhere in its definition. Offered only while the agent spoke last — a second `H:` would break strict alternation. */
639
+ export const threadReplyActions = (
640
+ content: string,
641
+ position: { readonly line: number; readonly character: number },
642
+ ): readonly SteeringAction[] => {
643
+ const thread = parseThreads(content).find(
644
+ (t) =>
645
+ t.waitingOn === "human" &&
646
+ ((position.line >= t.line && position.line <= t.endLine) ||
647
+ markerAt(t.markers, position) !== undefined),
648
+ )
649
+ const last = thread?.entries[thread.entries.length - 1]
650
+ if (!thread || !last) return []
651
+ const lines = content.split(/\r?\n/)
652
+ // An entry opening on the definition's own line has no bullet column of its own to copy.
653
+ const indent = " ".repeat(last.line === thread.line ? 4 : last.column)
654
+ const at = { line: last.endLine, character: (lines[last.endLine] ?? "").length }
655
+ return [
656
+ {
657
+ title: THREAD_REPLY_ACTION_TITLE,
658
+ edits: [{ range: { start: at, end: at }, newText: `${eolOf(content)}${indent}- H: ` }],
659
+ },
660
+ ]
661
+ }
@@ -0,0 +1,7 @@
1
+ // Test-support for `Inline.test.ts`: `projectInline` takes already-parsed
2
+ // phrasing content, but a test only has raw markdown source — re-exporting
3
+ // the shared parser here (rather than `Inline.test.ts` reaching into
4
+ // `MarkdownTree.ts` directly) keeps the boundary rule "a test reaches a
5
+ // neighbour only through its own fixture or the boundary's index.ts"
6
+ // satisfied.
7
+ export { parseMarkdown } from "./MarkdownTree.js"