@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,260 @@
1
+ import { fromMarkdown } from "mdast-util-from-markdown"
2
+ import { gfmAutolinkLiteralFromMarkdown } from "mdast-util-gfm-autolink-literal"
3
+ import { gfmFootnoteFromMarkdown } from "mdast-util-gfm-footnote"
4
+ import { gfmStrikethroughFromMarkdown } from "mdast-util-gfm-strikethrough"
5
+ import { gfmTaskListItemFromMarkdown } from "mdast-util-gfm-task-list-item"
6
+ import { gfmAutolinkLiteral } from "micromark-extension-gfm-autolink-literal"
7
+ import { gfmFootnote } from "micromark-extension-gfm-footnote"
8
+ import { gfmStrikethrough } from "micromark-extension-gfm-strikethrough"
9
+ import { gfmTaskListItem } from "micromark-extension-gfm-task-list-item"
10
+ import type { Heading, ListItem, Node, Root, RootContent } from "mdast"
11
+ import type { SteeringOutlineNode } from "./Outline.js"
12
+
13
+ /** The same 0-based shape the LSP protocol uses, everywhere a converted mdast position ends up. */
14
+ export interface LspPosition {
15
+ readonly line: number
16
+ readonly character: number
17
+ }
18
+
19
+ /**
20
+ * How many times `parseMarkdown` has actually run `fromMarkdown` (memo hits
21
+ * excluded) — the only way "one parse per document" is assertable, since
22
+ * timing is not.
23
+ */
24
+ let parseCount = 0
25
+ export const getParseCount = (): number => parseCount
26
+
27
+ /** One-entry memo: the last content string parsed and its tree, so re-validating the same document never re-parses. */
28
+ let memoContent: string | undefined
29
+ let memoTree: Root | undefined
30
+
31
+ /**
32
+ * Parses `content` into an mdast tree with all four GFM extensions wired —
33
+ * footnotes, task-list checkboxes, strikethrough, and bare-URL autolinks.
34
+ * `gfmTable` is deliberately NOT wired: tables are block-level and outside
35
+ * this parser's scope. Both the micromark and the mdast half of each
36
+ * extension must be CALLED (`gfmTaskListItem()`, not `gfmTaskListItem`);
37
+ * passing the bare function is accepted silently and yields, e.g.,
38
+ * `listItem.checked === null` on every item instead of a real checkbox —
39
+ * the same trap applies to every extension pair here. Total: `fromMarkdown`
40
+ * never throws, so this never does either.
41
+ *
42
+ * Memoized on the content string, one entry deep — the last document parsed
43
+ * stays cached so one `validate` call's footnote pass and format pass share
44
+ * a single parse; a different string evicts it.
45
+ */
46
+ export const parseMarkdown = (content: string): Root => {
47
+ if (memoContent === content && memoTree !== undefined) return memoTree
48
+ const tree = fromMarkdown(content, {
49
+ extensions: [gfmFootnote(), gfmTaskListItem(), gfmStrikethrough(), gfmAutolinkLiteral()],
50
+ mdastExtensions: [
51
+ gfmFootnoteFromMarkdown(),
52
+ gfmTaskListItemFromMarkdown(),
53
+ gfmStrikethroughFromMarkdown(),
54
+ gfmAutolinkLiteralFromMarkdown(),
55
+ ],
56
+ })
57
+ parseCount += 1
58
+ memoContent = content
59
+ memoTree = tree
60
+ return tree
61
+ }
62
+
63
+ /** mdast gives 1-based line/column; the LSP wants 0-based line/character. Every `- 1` lives here. */
64
+ export const toLspPosition = (point: {
65
+ readonly line: number
66
+ readonly column: number
67
+ }): LspPosition => ({
68
+ line: point.line - 1,
69
+ character: point.column - 1,
70
+ })
71
+
72
+ /**
73
+ * Converts an absolute 0-based character offset into `content` to a 0-based
74
+ * LSP line/character — the general form a caller needs for a position that
75
+ * isn't a tree node's own point (a hit found by scanning a `text` node's own
76
+ * source slice for a pattern the parser didn't turn into a node, say).
77
+ */
78
+ export const toLspPositionFromOffset = (content: string, offset: number): LspPosition => {
79
+ let line = 0
80
+ let lineStart = 0
81
+ for (let i = 0; i < offset; i += 1) {
82
+ if (content[i] === "\n") {
83
+ line += 1
84
+ lineStart = i + 1
85
+ }
86
+ }
87
+ return { line, character: offset - lineStart }
88
+ }
89
+
90
+ /** Collects every descendant `footnoteReference` node's own `[start, end)` source-offset range, in document order. */
91
+ const footnoteReferenceRanges = (node: Node): Array<readonly [number, number]> => {
92
+ const ranges: Array<readonly [number, number]> = []
93
+ const walk = (n: Node): void => {
94
+ if (n.type === "footnoteReference" && n.position) {
95
+ ranges.push([n.position.start.offset!, n.position.end.offset!])
96
+ }
97
+ const children = (n as { children?: readonly Node[] }).children
98
+ if (children) children.forEach(walk)
99
+ }
100
+ walk(node)
101
+ return ranges.sort((a, b) => a[0] - b[0])
102
+ }
103
+
104
+ /**
105
+ * `node`'s own source slice, with every descendant `footnoteReference`
106
+ * range excised and every run of whitespace collapsed to a single space.
107
+ * Inline code, links, and emphasis are untouched — only footnote markers are
108
+ * ever cut out, because a marker read back out of a definition's or a
109
+ * heading's body is noise no caller wants.
110
+ */
111
+ export const sourceText = (content: string, node: Node): string => {
112
+ if (!node.position) return ""
113
+ const start = node.position.start.offset!
114
+ const end = node.position.end.offset!
115
+ let result = ""
116
+ let cursor = start
117
+ for (const [rangeStart, rangeEnd] of footnoteReferenceRanges(node)) {
118
+ if (rangeStart < cursor) continue
119
+ result += content.slice(cursor, rangeStart)
120
+ cursor = rangeEnd
121
+ }
122
+ result += content.slice(cursor, end)
123
+ return result.replace(/\s+/g, " ").trim()
124
+ }
125
+
126
+ /** The top-level block of `tree` that contains 0-based `line`, or `undefined` past the end of the document. */
127
+ export const blockNodeAt = (tree: Root, line: number): RootContent | undefined =>
128
+ tree.children.find((node) => {
129
+ if (!node.position) return false
130
+ return line >= node.position.start.line - 1 && line <= node.position.end.line - 1
131
+ })
132
+
133
+ /** Every `listItem` under `node` with `checked !== null` (a real task-list item), at any nesting depth. */
134
+ export const taskItems = (node: Node): readonly ListItem[] => {
135
+ const items: ListItem[] = []
136
+ const walk = (n: Node): void => {
137
+ if (n.type === "listItem" && (n as ListItem).checked !== null) items.push(n as ListItem)
138
+ const children = (n as { children?: readonly Node[] }).children
139
+ if (children) children.forEach(walk)
140
+ }
141
+ walk(node)
142
+ return items
143
+ }
144
+
145
+ /** One whole 0-based `line`'s own LSP range, start to end of its text. */
146
+ export const lineRange = (
147
+ lines: readonly string[],
148
+ line: number,
149
+ ): { readonly start: LspPosition; readonly end: LspPosition } => ({
150
+ start: { line, character: 0 },
151
+ end: { line, character: (lines[line] ?? "").length },
152
+ })
153
+
154
+ /** An LSP range spanning whole lines `startLine` through `endLine` inclusive. */
155
+ export const spanRange = (
156
+ lines: readonly string[],
157
+ startLine: number,
158
+ endLine: number,
159
+ ): { readonly start: LspPosition; readonly end: LspPosition } => ({
160
+ start: { line: startLine, character: 0 },
161
+ end: { line: endLine, character: (lines[endLine] ?? "").length },
162
+ })
163
+
164
+ /** One footnote marker rendered as an outline leaf: `[^name] <body>`. Shared by both built-in descriptors — `qa` and `review` render footnotes identically, only the surrounding tree differs. */
165
+ export const footnoteLeaf = (
166
+ lines: readonly string[],
167
+ definitionByName: ReadonlyMap<string, string>,
168
+ marker: { readonly name: string; readonly line: number },
169
+ ): SteeringOutlineNode => ({
170
+ name: `[^${marker.name}] ${definitionByName.get(marker.name) ?? ""}`.trim(),
171
+ range: lineRange(lines, marker.line),
172
+ selectionRange: lineRange(lines, marker.line),
173
+ leaf: true,
174
+ })
175
+
176
+ /** A parsed `##`/`###` block shared by both descriptors' own block-splitting: a heading node plus its raw body block nodes, up to the next heading. */
177
+ export interface HeadingBlock {
178
+ readonly heading: Heading
179
+ readonly body: readonly RootContent[]
180
+ }
181
+
182
+ /**
183
+ * One heading block's own outline range end: its last body block's own end
184
+ * line, or the heading's own end line for a bare/empty block — never "the
185
+ * next heading's line minus one", which would swallow trailing blank lines
186
+ * (or, worse, unrelated content) between this block and the next.
187
+ */
188
+ export const blockEndLine = (block: HeadingBlock): number => {
189
+ const last = [...block.body].reverse().find((n) => n.position !== undefined)
190
+ return toLspPosition((last?.position ?? block.heading.position!).end).line
191
+ }
192
+
193
+ /**
194
+ * A depth-N heading's own text (footnote references excised via
195
+ * `sourceText`, internal whitespace collapsed) — `""` for a bare heading with
196
+ * no inline content. Built from the heading's own CHILDREN span, never the
197
+ * heading node's own position: that starts at the `#` run, which would pull
198
+ * the marker and its separating space into `sourceText`'s slice. `strip`
199
+ * (default: identity) runs on the extracted text before the final whitespace
200
+ * collapse — `qa`'s own `[^name]`-marker stripping needs this second pass
201
+ * since removing a marker can leave a double space `sourceText` already
202
+ * collapsed once, before the strip ever ran.
203
+ */
204
+ export const headingText = (
205
+ content: string,
206
+ heading: Heading,
207
+ strip: (text: string) => string = (text) => text,
208
+ ): string => {
209
+ const children = heading.children
210
+ if (children.length === 0) return ""
211
+ const first = children[0]!
212
+ const last = children[children.length - 1]!
213
+ if (!first.position || !last.position) return ""
214
+ const synthetic = {
215
+ type: "heading",
216
+ children,
217
+ position: { start: first.position.start, end: last.position.end },
218
+ }
219
+ return strip(sourceText(content, synthetic)).replace(/\s+/g, " ").trim()
220
+ }
221
+
222
+ /**
223
+ * Every depth-`depth` (default 2, `## `) heading's own text, in document
224
+ * order — the section list a dynamic-count `judge:` template (one noul per
225
+ * section/finding) counts over. Top-level only (`tree.children`, not nested
226
+ * inside a list or blockquote), matching how a package/feedback file's own
227
+ * `## ` headings are always written at the document's own top level.
228
+ */
229
+ export interface HeadingSection {
230
+ readonly title: string
231
+ /** The section's source, heading line included, up to the next heading of the same or a higher level. */
232
+ readonly body: string
233
+ }
234
+
235
+ /** Every top-level depth-`depth` section of `content`, with its source text, in document order. */
236
+ export const headingSectionBodies = (content: string, depth = 2): readonly HeadingSection[] => {
237
+ const lineStart = (offset: number): number => content.lastIndexOf("\n", offset - 1) + 1
238
+ const tree = parseMarkdown(content)
239
+ const boundaries = tree.children.filter(
240
+ (node): node is Heading => node.type === "heading" && node.depth <= depth,
241
+ )
242
+ return boundaries.flatMap((heading, i) => {
243
+ if (heading.depth !== depth || heading.position === undefined) return []
244
+ const next = boundaries[i + 1]?.position?.start.offset
245
+ const end = next === undefined ? content.length : lineStart(next)
246
+ return [
247
+ {
248
+ title: headingText(content, heading),
249
+ body: content.slice(lineStart(heading.position.start.offset ?? 0), end).trimEnd(),
250
+ },
251
+ ]
252
+ })
253
+ }
254
+
255
+ export const headingSections = (content: string, depth = 2): readonly string[] => {
256
+ const tree = parseMarkdown(content)
257
+ return tree.children
258
+ .filter((node): node is Heading => node.type === "heading" && node.depth === depth)
259
+ .map((heading) => headingText(content, heading))
260
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * One node of a format's outline tree. `leaf: true` is the only distinction
3
+ * a node carries — whatever icon or symbol kind an editor shows is the LSP
4
+ * translation's call, not this module's.
5
+ */
6
+ export interface SteeringOutlineNode {
7
+ readonly name: string
8
+ readonly detail?: string
9
+ readonly range: {
10
+ readonly start: { readonly line: number; readonly character: number }
11
+ readonly end: { readonly line: number; readonly character: number }
12
+ }
13
+ readonly selectionRange: {
14
+ readonly start: { readonly line: number; readonly character: number }
15
+ readonly end: { readonly line: number; readonly character: number }
16
+ }
17
+ readonly leaf?: true
18
+ readonly children?: readonly SteeringOutlineNode[]
19
+ }
@@ -0,0 +1,91 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { steeringFormatFor } from "./index.js"
3
+ import type { SteeringFinding, SteeringFormat } from "./SteeringFormat.js"
4
+
5
+ const QA_FORMAT = steeringFormatFor("qa")!
6
+ const REVIEW_FORMAT = steeringFormatFor("review")!
7
+
8
+ /**
9
+ * `SteeringFinding`'s two range invariants ("a range is meaningless without a
10
+ * `line`"; "a range's start line equals `line`") are declared in the type's
11
+ * own doc comment, not enforced by the type itself — a new finding site (or a
12
+ * new format entirely) can violate either with the whole suite green unless
13
+ * something actually asserts it. This drives every built-in format's
14
+ * `validate` over malformed content and checks the invariant on every finding
15
+ * returned, so it catches a future violation regardless of which site
16
+ * introduces it.
17
+ */
18
+ const assertRangeInvariants = (findings: readonly SteeringFinding[]): void => {
19
+ for (const finding of findings) {
20
+ if (finding.range === undefined) continue
21
+ expect(finding.line, `range without a line: ${JSON.stringify(finding)}`).not.toBeUndefined()
22
+ expect(finding.range.start.line, `range.start.line !== line: ${JSON.stringify(finding)}`).toBe(
23
+ finding.line,
24
+ )
25
+ }
26
+ }
27
+
28
+ const MALFORMED_SAMPLES: readonly (readonly [string, SteeringFormat, string])[] = [
29
+ [
30
+ "qa: bare heading, misordered sections, indented dropped heading/option",
31
+ QA_FORMAT,
32
+ [
33
+ "## Answered Questions",
34
+ "",
35
+ "### Answered already",
36
+ "",
37
+ "Use tRPC.",
38
+ "",
39
+ "## Open Questions",
40
+ "",
41
+ "###",
42
+ "",
43
+ " ### dropped heading",
44
+ "",
45
+ " - [ ] dropped option",
46
+ "",
47
+ ].join("\n"),
48
+ ],
49
+ ["review: missing header/base, no chunks", REVIEW_FORMAT, "Just some text\n"],
50
+ [
51
+ "review: chunk with no pointers, wrong first block",
52
+ REVIEW_FORMAT,
53
+ [
54
+ "Not a heading first.",
55
+ "",
56
+ "# Review: abc1234",
57
+ "<!-- base: abc1234def5678901234567890123456789abcd -->",
58
+ "",
59
+ "## Untouched chunk",
60
+ "",
61
+ "no pointers here.",
62
+ "",
63
+ ].join("\n"),
64
+ ],
65
+ [
66
+ "qa: an orphan and duplicate footnote marker/definition",
67
+ QA_FORMAT,
68
+ [
69
+ "## Open Questions",
70
+ "",
71
+ "### Which option?",
72
+ "",
73
+ "- [ ] Option A[^missing]",
74
+ "- [ ] Option B[^dup]",
75
+ "- [ ] _your answer_",
76
+ "",
77
+ "[^dup]: first",
78
+ "",
79
+ "[^dup]: second",
80
+ "",
81
+ ].join("\n"),
82
+ ],
83
+ ]
84
+
85
+ describe("SteeringFinding range invariants", () => {
86
+ it.each(MALFORMED_SAMPLES)("%s", (_label, format, content) => {
87
+ const findings = format.validate(content)
88
+ expect(findings.length).toBeGreaterThan(0)
89
+ assertRangeInvariants(findings)
90
+ })
91
+ })
@@ -0,0 +1,267 @@
1
+ // `InlineNode` aside, pure vocabulary: shared by the registry and the LSP's
2
+ // translation layer without either pulling in the other's dependencies.
3
+ import type { InlineNode } from "./Inline.js"
4
+ import type { SteeringOutlineNode } from "./Outline.js"
5
+
6
+ export type { SteeringOutlineNode } from "./Outline.js"
7
+
8
+ /** The same shape `vscode-languageserver`'s `TextEdit` carries, kept format-side so this module stays protocol-independent. */
9
+ export interface SteeringEdit {
10
+ readonly range: {
11
+ readonly start: { readonly line: number; readonly character: number }
12
+ readonly end: { readonly line: number; readonly character: number }
13
+ }
14
+ readonly newText: string
15
+ }
16
+
17
+ export interface SteeringAction {
18
+ readonly title: string
19
+ readonly edits: readonly SteeringEdit[]
20
+ }
21
+
22
+ /** A go-to-definition target. `path` absent means "this same document" (a footnote jump); `character` defaults to 0. */
23
+ export interface SteeringPointer {
24
+ readonly path?: string
25
+ readonly line: number
26
+ readonly character?: number
27
+ }
28
+
29
+ /**
30
+ * One validation finding. `line` is 0-based, absent for a document-level
31
+ * finding. `range` is independently optional, never a discriminated union:
32
+ * a shell `validate:` command's output is a bare `{ message }` with no
33
+ * position at all. That a `range` implies a `line` is pinned by tests.
34
+ */
35
+ export interface SteeringFinding {
36
+ readonly message: string
37
+ readonly line?: number
38
+ readonly range?: {
39
+ readonly start: { readonly line: number; readonly character: number }
40
+ readonly end: { readonly line: number; readonly character: number }
41
+ }
42
+ }
43
+
44
+ /** A hunk pointer's document-link target: `range` covers the pointer token itself; `line` is 0-based, so a `#42` suffix resolves down by one and a bare `./path` lands at 0. */
45
+ export interface SteeringLink {
46
+ readonly range: {
47
+ readonly start: { readonly line: number; readonly character: number }
48
+ readonly end: { readonly line: number; readonly character: number }
49
+ }
50
+ readonly path: string
51
+ readonly line: number
52
+ }
53
+
54
+ /**
55
+ * Where an `annotate` call attaches a note. A client reads one off `view` and
56
+ * hands it straight back, never inventing indices. Deliberately closed and
57
+ * format-agnostic: `review`'s chunk/hunk and `qa`'s question/option are the
58
+ * same two things here (a container and one of its numbered children), plus
59
+ * `paragraph`, which a client resolves from its cursor rather than the view.
60
+ */
61
+ export type SteeringAnchor =
62
+ | { readonly kind: "chunk"; readonly index: number }
63
+ | { readonly kind: "hunk"; readonly chunkIndex: number; readonly index: number }
64
+ | { readonly kind: "question"; readonly index: number }
65
+ | { readonly kind: "option"; readonly questionIndex: number; readonly index: number }
66
+ | { readonly kind: "paragraph"; readonly line: number }
67
+
68
+ /** `annotate`'s result: the edits to splice in, or a typed refusal — a stale/unresolvable `anchor`, or a derived note id that already names a definition. */
69
+ export type SteeringAnnotateResult =
70
+ | { readonly ok: true; readonly edits: readonly SteeringEdit[] }
71
+ | { readonly ok: false; readonly reason: "anchor-not-found" | "id-collision" }
72
+
73
+ /** One item of a `list` block's `items` tree — recursive to whatever depth the source markdown nests. */
74
+ export interface BlockListItem {
75
+ /** This item's own text, EXCLUDING any nested list under it (that's `items`, below). */
76
+ readonly text: string
77
+ /** Set only for a task-list item (`- [ ]`/`- [x]`) — absent for a plain list item. */
78
+ readonly checked?: boolean
79
+ /** A nested list directly under this item, recursively. Absent when this item has none. */
80
+ readonly items?: readonly BlockListItem[]
81
+ /** `text`'s own inline structure — the shared renderer walks this instead of `text` when present. Populated at every nesting depth. */
82
+ readonly inline?: readonly InlineNode[]
83
+ }
84
+
85
+ export interface SteeringViewThread {
86
+ readonly name: string
87
+ readonly entries: readonly { readonly author: "me" | "agent"; readonly text: string }[]
88
+ readonly waitingOn: "human" | "agent"
89
+ }
90
+
91
+ /**
92
+ * One node of a format's `view`: a generic container/item tree, the SAME
93
+ * shape for every format. Deliberately never a closed per-format union — a
94
+ * third format would have to pretend to be one of the two, or this file would
95
+ * grow a member, and a user-declared mode is supposed to light up the phone
96
+ * UI for free. Every field beyond `title`/`anchor` is optional because each
97
+ * format populates a different subset.
98
+ */
99
+ export interface SteeringViewNode {
100
+ /** This node's own display name — a chunk's/question's title, an option's/hunk's own label. */
101
+ readonly title: string
102
+ /** A longer description or summary, when the format has one (a chunk's description, a question's first body line). */
103
+ readonly detail?: string
104
+ /** A free-form status label, when the format has one (`qa`'s `"open"`/`"answered"`). */
105
+ readonly status?: string
106
+ /** `true` when this node's own condition is fully satisfied (`qa`'s answered-question flag) — distinct from `checked`, which is a per-item tick. */
107
+ readonly answered?: boolean
108
+ /** This node's own checkbox state, when it has one (a hunk's tick, an option's tick). */
109
+ readonly checked?: boolean
110
+ /** An attached one-shot note's text, when this node carries one. A thread fills `thread` instead, never this. */
111
+ readonly note?: string
112
+ /** An attached footnote thread — a conversation, not a flat string. `waitingOn: "human"` means the agent spoke last. */
113
+ readonly thread?: SteeringViewThread
114
+ /**
115
+ * `detail`'s own inline counterpart, for the three compact rows (a chunk
116
+ * card, a hunk's description row, a plan row): built server-side by
117
+ * concatenating each contributing block's own inline run with a single
118
+ * space `text` separator, mirroring exactly how `detail` itself joins.
119
+ * Block structure stays flattened — a list contributes its items' runs
120
+ * joined by spaces, a fence contributes its plain text — and an image
121
+ * collapses to its alt text unconditionally, so a compact row can never
122
+ * fire a remote fetch. Set by `review.ts` (chunk, hunk) and `qa.ts`
123
+ * (question); absent wherever `detail` itself is absent.
124
+ */
125
+ readonly detailInline?: readonly InlineNode[]
126
+ /** A file path this node points at, when it has one (a `review` hunk). */
127
+ readonly path?: string
128
+ /** A 1-based line in `path` this node points at, when it has one. */
129
+ readonly line?: number
130
+ /** A 1-based END line in `path`, alongside `line`, for a `review` hunk pointer carrying a range (`#<start>-<end>`). Absent whenever `line` is absent, or for a bare `#<line>` pointer with no range. */
131
+ readonly endLine?: number
132
+ /** Where `annotate` attaches a NEW note to this node. */
133
+ readonly anchor: SteeringAnchor
134
+ readonly children?: readonly SteeringViewNode[]
135
+ /**
136
+ * A node's body, a SIBLING of `children` and never a reuse of it: a `qa`
137
+ * question's `children` are its options, so a body block riding in that
138
+ * array would render as a phantom option.
139
+ *
140
+ * Set by `qa` questions, `review` chunks, and a `qa` OPTION node too, where
141
+ * it is that option's own impacts — read-only, since the write-back span an
142
+ * answer commits through never reaches past the option's label. `[]` — not
143
+ * absent — for a question/chunk with no body, so `body !== undefined` is
144
+ * never "this is a question"; absent entirely on the free-text slot, which
145
+ * never carries impacts.
146
+ */
147
+ readonly body?: readonly SteeringViewNode[]
148
+ /**
149
+ * The document structure a prose block carries. NO new anchor kind: whatever
150
+ * `kind` it names, the block still anchors as `{kind: "paragraph", line}`. A
151
+ * client that ignores this field entirely still has `title` to render.
152
+ */
153
+ readonly block?: {
154
+ readonly kind: "paragraph" | "heading" | "list" | "code" | "blockquote"
155
+ /** Heading level, 1–6. Set only for `kind: "heading"`. */
156
+ readonly depth?: number
157
+ /** Set only for `kind: "list"`. */
158
+ readonly ordered?: boolean
159
+ /** This list's own top-level items, recursive. Set only for `kind: "list"`. */
160
+ readonly items?: readonly BlockListItem[]
161
+ /** The fenced code block's info-string language, when it has one. Set only for `kind: "code"`. */
162
+ readonly language?: string
163
+ /**
164
+ * This block's own text, verbatim. `qa`/`review` set it only for
165
+ * `kind: "code"`/`"blockquote"` (the code block's body / the
166
+ * blockquote's own text); the free-form format (`freeform.ts`) sets it
167
+ * for EVERY kind — the block's own raw source bytes, unmodified — since
168
+ * it has no structure of its own to fall back on and an edit there
169
+ * replaces a block's whole source span with this same shape of text.
170
+ */
171
+ readonly text?: string
172
+ /**
173
+ * This block's own inline structure — set only for `kind: "paragraph"`,
174
+ * `"heading"` and `"blockquote"` (a `code` block's `text` has none; a
175
+ * `list` block's own items each carry their own `inline` instead, on
176
+ * `BlockListItem`). The shared client renderer walks this instead of
177
+ * `title`/`text` when present, so `**bold**` and friends survive as real
178
+ * structure rather than literal markers.
179
+ */
180
+ readonly inline?: readonly InlineNode[]
181
+ }
182
+ }
183
+
184
+ /** A format's whole domain projection of `content`. The server serializes this without importing a format module or switching on the mode name. */
185
+ export interface SteeringView {
186
+ readonly header?: string
187
+ readonly nodes: readonly SteeringViewNode[]
188
+ }
189
+
190
+ /**
191
+ * One steering-file FORMAT's whole behavior. `validate` returns the same
192
+ * `findings` shape `gtd validate` and the capture gate consume (empty =
193
+ * valid); `pointerAt` is absent for a format with nothing to jump to.
194
+ */
195
+ export interface SteeringFormat {
196
+ /**
197
+ * The clearest minimal document that satisfies this format's own `validate`.
198
+ * `ModeContradiction.ts` round-trips this exact string through a mode's
199
+ * `format:` command to catch a formatter that breaks its own validator, so
200
+ * it is deliberately NOT authored to survive any particular formatter — a
201
+ * reflow that invalidates it IS the contradiction that check exists to find.
202
+ */
203
+ readonly sample: string
204
+ readonly validate: (content: string) => readonly SteeringFinding[]
205
+ readonly outline: (content: string) => readonly SteeringOutlineNode[]
206
+ readonly actions: (
207
+ content: string,
208
+ range: {
209
+ readonly start: { readonly line: number; readonly character: number }
210
+ readonly end: { readonly line: number; readonly character: number }
211
+ },
212
+ ) => readonly SteeringAction[]
213
+ readonly pointerAt?: (
214
+ content: string,
215
+ position: { readonly line: number; readonly character: number },
216
+ ) => SteeringPointer | undefined
217
+ /** Every hunk-pointer document link in `content`. Walks the parsed hunks directly: `pointerAt` is one call per line and cannot yield the token's own range. */
218
+ readonly documentLinks?: (content: string) => readonly SteeringLink[]
219
+ /** MANDATORY, so a user-declared mode lights up the phone UI the moment it registers one — the server reads a `view` rather than importing a format module. */
220
+ readonly view: (content: string) => SteeringView
221
+ /**
222
+ * Attaches a note at `anchor`. `text` is never a placeholder — unlike the
223
+ * LSP's "add a footnote" action, which seeds an empty definition for a human
224
+ * to fill in, a server-attached note already has its real body, so the
225
+ * document it produces validates clean immediately.
226
+ */
227
+ readonly annotate: (
228
+ content: string,
229
+ anchor: SteeringAnchor,
230
+ text: string,
231
+ ) => SteeringAnnotateResult
232
+ /**
233
+ * The checkbox-writing counterpart to `annotate`, sharing its refusal shape.
234
+ * `qa`'s `option` anchor is RADIO: ticking one unticks every sibling, and
235
+ * ticking the free-text slot with `text` set also replaces its label, in ONE
236
+ * edit set. `review`'s `chunk` anchor sets every hunk beneath it to the
237
+ * caller's stated target state — never a majority-flip heuristic.
238
+ */
239
+ readonly apply: (
240
+ content: string,
241
+ anchor: SteeringAnchor,
242
+ opts: { readonly checked?: boolean; readonly text?: string },
243
+ ) => SteeringAnnotateResult
244
+ /**
245
+ * Resets only THIS format's own read-progress ticks. `qa`'s checkbox ticks
246
+ * ARE its answers — never auto-cleared; `review`'s hunk ticks are
247
+ * read-progress, cleared on every land. Mandatory so a format can never
248
+ * accidentally inherit another one's tick-clearing rule.
249
+ */
250
+ readonly clearTicks: (content: string) => string
251
+ /**
252
+ * `qa`-only: every OPEN question not yet answered — distinct from
253
+ * `validate`'s structural findings, since a well-formed document can still
254
+ * have open questions nobody answered. Absent on a format with no such
255
+ * concept.
256
+ */
257
+ readonly unansweredQuestions?: (content: string) => readonly {
258
+ readonly question: string
259
+ readonly headingLine: number
260
+ }[]
261
+ /** Every OPEN thread (last entry is the agent's), 0-based `line` — mirrors `unansweredQuestions`. */
262
+ readonly openThreads?: (content: string) => readonly {
263
+ readonly name: string
264
+ readonly line: number
265
+ readonly firstMe: string
266
+ }[]
267
+ }