@pmelab/gtd 24.0.0 → 24.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (203) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +42 -13
  3. package/claude/hooks/access.ts +26 -1
  4. package/claude/hooks/assist.ts +135 -0
  5. package/claude/hooks/drive.ts +15 -7
  6. package/claude/hooks/judge.ts +27 -0
  7. package/claude/hooks/register.tsx +294 -135
  8. package/claude/hooks/tools.ts +121 -0
  9. package/claude/hooks/wording.ts +2 -35
  10. package/claude/types/index.d.ts +9 -0
  11. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  12. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  13. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  14. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  15. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  16. package/dist/flows.mjs +525 -0
  17. package/dist/gtd.bundle.mjs +43339 -55575
  18. package/dist/steering.mjs +2 -0
  19. package/dist/testkit.mjs +13006 -0
  20. package/dist/wire.mjs +2 -0
  21. package/dist/workflow-kit.mjs +352 -0
  22. package/dist/workflows/feature.mjs +315 -0
  23. package/dist/workflows/fix.mjs +40 -0
  24. package/dist/workflows/review.mjs +25 -0
  25. package/dist/workflows.mjs +8 -6
  26. package/package.json +69 -8
  27. package/src/cli/Cli.test.ts +1329 -0
  28. package/src/cli/Cli.ts +1348 -0
  29. package/src/cli/Program.test.ts +2262 -0
  30. package/src/cli/Program.ts +1331 -0
  31. package/src/cli/ToolCommands.ts +43 -0
  32. package/src/cli/index.ts +14 -0
  33. package/src/edge/Edge.test.ts +1016 -0
  34. package/src/edge/Edge.ts +1225 -0
  35. package/src/edge/index.ts +16 -0
  36. package/src/emit/Emit.test.ts +425 -0
  37. package/src/emit/Emit.ts +139 -0
  38. package/src/emit/GitScript.test.ts +260 -0
  39. package/src/emit/GitScript.ts +131 -0
  40. package/src/emit/ModeContradiction.test.ts +120 -0
  41. package/src/emit/ModeContradiction.ts +66 -0
  42. package/src/emit/OutcomeScript.test.ts +238 -0
  43. package/src/emit/OutcomeScript.ts +73 -0
  44. package/src/emit/SteeringMode.test.ts +274 -0
  45. package/src/emit/SteeringMode.ts +256 -0
  46. package/src/emit/index.ts +40 -0
  47. package/src/install/ConfigScaffold.ts +19 -0
  48. package/src/install/Install.test.ts +426 -0
  49. package/src/install/Install.ts +634 -0
  50. package/src/install/Version.ts +27 -0
  51. package/src/install/index.ts +3 -0
  52. package/src/platform/CommandRunner.ts +74 -0
  53. package/src/platform/Commentary.test.ts +126 -0
  54. package/src/platform/Commentary.ts +92 -0
  55. package/src/platform/Git.test.ts +50 -0
  56. package/src/platform/Git.ts +552 -0
  57. package/src/platform/Host.ts +42 -0
  58. package/src/platform/RetainedHistory.test.ts +65 -0
  59. package/src/platform/RetainedHistory.ts +34 -0
  60. package/src/platform/Workspace.test.ts +669 -0
  61. package/src/platform/Workspace.ts +631 -0
  62. package/src/platform/WorktreeState.test.ts +162 -0
  63. package/src/platform/WorktreeState.ts +50 -0
  64. package/src/platform/index.ts +22 -0
  65. package/src/replay/Access.test.ts +53 -0
  66. package/src/replay/Access.ts +46 -0
  67. package/src/replay/Format.test.ts +20 -0
  68. package/src/replay/Format.ts +14 -0
  69. package/src/replay/Replay.test.ts +887 -0
  70. package/src/replay/Replay.ts +669 -0
  71. package/src/replay/Trailers.test.ts +117 -0
  72. package/src/replay/Trailers.ts +213 -0
  73. package/src/replay/Tree.test.ts +25 -0
  74. package/src/replay/Tree.ts +45 -0
  75. package/src/replay/index.ts +11 -0
  76. package/src/steering/Blocks.fixture.ts +6 -0
  77. package/src/steering/Blocks.test.ts +167 -0
  78. package/src/steering/Blocks.ts +321 -0
  79. package/src/steering/CodeThreads.test.ts +127 -0
  80. package/src/steering/CodeThreads.ts +197 -0
  81. package/src/steering/Edits.test.ts +19 -0
  82. package/src/steering/Edits.ts +20 -0
  83. package/src/steering/Eol.test.ts +16 -0
  84. package/src/steering/Eol.ts +2 -0
  85. package/src/steering/FORMATS.md +58 -0
  86. package/src/steering/Footnotes.test.ts +977 -0
  87. package/src/steering/Footnotes.ts +661 -0
  88. package/src/steering/Inline.fixture.ts +7 -0
  89. package/src/steering/Inline.test.ts +227 -0
  90. package/src/steering/Inline.ts +195 -0
  91. package/src/steering/MarkdownTree.test.ts +247 -0
  92. package/src/steering/MarkdownTree.ts +260 -0
  93. package/src/steering/Outline.ts +19 -0
  94. package/src/steering/SteeringFormat.test.ts +91 -0
  95. package/src/steering/SteeringFormat.ts +267 -0
  96. package/src/steering/SteeringFormats.test.ts +163 -0
  97. package/src/steering/Threads.ts +163 -0
  98. package/src/steering/contract.test.ts +264 -0
  99. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  100. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  101. package/src/steering/fixtures/free-form/notes.md +12 -0
  102. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  103. package/src/steering/fixtures/qa/answered.md +23 -0
  104. package/src/steering/fixtures/qa/answered.read.json +119 -0
  105. package/src/steering/fixtures/qa/requirements.md +160 -0
  106. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  107. package/src/steering/fixtures/review/review.md +76 -0
  108. package/src/steering/fixtures/review/review.read.json +237 -0
  109. package/src/steering/fixtures/review/ticked.md +23 -0
  110. package/src/steering/fixtures/review/ticked.read.json +99 -0
  111. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  112. package/src/steering/fixtures/review-notes/noted.md +19 -0
  113. package/src/steering/freeform.test.ts +311 -0
  114. package/src/steering/freeform.ts +148 -0
  115. package/src/steering/index.test.ts +62 -0
  116. package/src/steering/index.ts +140 -0
  117. package/src/steering/qa.test.ts +2160 -0
  118. package/src/steering/qa.ts +1104 -0
  119. package/src/steering/review.test.ts +1829 -0
  120. package/src/steering/review.ts +1180 -0
  121. package/src/step/LandStep.ts +31 -0
  122. package/src/step/RepoSnapshot.ts +29 -0
  123. package/src/step/accessRefusal.test.ts +58 -0
  124. package/src/step/accessRefusal.ts +26 -0
  125. package/src/step/index.ts +5 -0
  126. package/src/step/planStart.test.ts +202 -0
  127. package/src/step/planStart.ts +167 -0
  128. package/src/step/planStep.test.ts +104 -0
  129. package/src/step/planStep.ts +89 -0
  130. package/src/step/snapshot.fixture.ts +28 -0
  131. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  132. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  133. package/src/testkit/FakeGitOperations.ts +95 -0
  134. package/src/testkit/GitTiers.test.ts +11 -0
  135. package/src/testkit/GitTiers.ts +1052 -0
  136. package/src/testkit/InMemRepo.ts +467 -0
  137. package/src/testkit/Layers.test.ts +58 -0
  138. package/src/testkit/Layers.ts +241 -0
  139. package/src/testkit/StrictGitOperations.ts +31 -0
  140. package/src/testkit/TestRepo.test.ts +119 -0
  141. package/src/testkit/TestRepo.ts +274 -0
  142. package/src/testkit/cliIo.ts +36 -0
  143. package/src/testkit/index.ts +9 -0
  144. package/src/testkit/steps.ts +177 -0
  145. package/src/testkit/testkit.feature +65 -0
  146. package/src/wire/BeatDocument.test.ts +419 -0
  147. package/src/wire/BeatDocument.ts +114 -0
  148. package/src/wire/BeatPlain.test.ts +113 -0
  149. package/src/wire/BeatPlain.ts +127 -0
  150. package/src/wire/BeatStatus.test.ts +59 -0
  151. package/src/wire/BeatStatus.ts +81 -0
  152. package/src/wire/Demand.test.ts +142 -0
  153. package/src/wire/Demand.ts +108 -0
  154. package/src/wire/ExitCodes.test.ts +21 -0
  155. package/src/wire/ExitCodes.ts +16 -0
  156. package/src/wire/Glob.ts +29 -0
  157. package/src/wire/Judge.ts +22 -0
  158. package/src/wire/Land.test.ts +120 -0
  159. package/src/wire/Land.ts +75 -0
  160. package/src/wire/Select.test.ts +148 -0
  161. package/src/wire/Select.ts +78 -0
  162. package/src/wire/Sessions.test.ts +59 -0
  163. package/src/wire/Sessions.ts +39 -0
  164. package/src/wire/WireSchema.test.ts +162 -0
  165. package/src/wire/constants.ts +22 -0
  166. package/src/wire/golden/1/beat-capture.json +34 -0
  167. package/src/wire/golden/1/beat-message.json +34 -0
  168. package/src/wire/golden/1/beat-prompt.json +44 -0
  169. package/src/wire/golden/1/beat-script.json +34 -0
  170. package/src/wire/golden/1/beat-stalled.json +34 -0
  171. package/src/wire/golden/1/judge.json +14 -0
  172. package/src/wire/golden/1/land-noop.json +10 -0
  173. package/src/wire/golden/1/land.json +10 -0
  174. package/src/wire/index.ts +12 -0
  175. package/src/wire/types.ts +46 -0
  176. package/src/workflow/Diagnostic.test.ts +93 -0
  177. package/src/workflow/Diagnostic.ts +54 -0
  178. package/src/workflow/SteeringFormats.test.ts +27 -0
  179. package/src/workflow/SteeringFormats.ts +6 -0
  180. package/src/workflow/Workflow.ts +77 -0
  181. package/src/workflow/compile.test.ts +53 -0
  182. package/src/workflow/compile.ts +56 -0
  183. package/src/workflow/discovery.ts +122 -0
  184. package/src/workflow/fingerprint.test.ts +52 -0
  185. package/src/workflow/fingerprint.ts +53 -0
  186. package/src/workflow/index.ts +33 -0
  187. package/src/workflow/load.test.ts +1017 -0
  188. package/src/workflow/load.ts +836 -0
  189. package/src/workflow/skills.test.ts +109 -0
  190. package/src/workflow/skills.ts +90 -0
  191. package/src/workflow/vars.ts +33 -0
  192. package/src/workflows/feature/feature.test.ts +27 -33
  193. package/src/workflows/feature/index.ts +10 -0
  194. package/src/workflows/fix/fix.feature +80 -0
  195. package/src/workflows/fix/fix.test.ts +58 -78
  196. package/src/workflows/fix/index.ts +6 -0
  197. package/src/workflows/index.ts +2 -10
  198. package/src/workflows/review/index.ts +6 -0
  199. package/src/workflows/review/review.test.ts +85 -29
  200. package/src/workflows/review/review.ts +3 -1
  201. package/src/workflows/shared/config.fixture.ts +12 -0
  202. package/src/workflows/shared/reserved.ts +11 -0
  203. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,1104 @@
1
+ import type { Code, Heading, List, ListItem, Root, RootContent } from "mdast"
2
+ import { blockNodesOf, blockNodesOfRun, blockRunInline } from "./Blocks.js"
3
+ import type { Footnotes, FootnoteAnchor, FootnoteMarker } from "./Footnotes.js"
4
+ import { collapseImages, type InlineNode } from "./Inline.js"
5
+ import {
6
+ FOOTNOTE_ACTION_TITLE,
7
+ footnoteAdditionEdits,
8
+ footnoteAttachEdits,
9
+ footnotePointerAt,
10
+ isOnExistingFootnote,
11
+ openThreadsOf,
12
+ threadOutlineNodes,
13
+ threadReplyActions,
14
+ noteLookup,
15
+ parseFootnotes,
16
+ } from "./Footnotes.js"
17
+ import {
18
+ blockEndLine,
19
+ blockNodeAt,
20
+ footnoteLeaf,
21
+ headingText as sharedHeadingText,
22
+ lineRange,
23
+ parseMarkdown,
24
+ spanRange,
25
+ taskItems,
26
+ toLspPosition,
27
+ toLspPositionFromOffset,
28
+ type HeadingBlock,
29
+ } from "./MarkdownTree.js"
30
+ import type {
31
+ SteeringAnchor,
32
+ SteeringAnnotateResult,
33
+ SteeringEdit,
34
+ SteeringFinding,
35
+ SteeringFormat,
36
+ SteeringOutlineNode,
37
+ SteeringView,
38
+ SteeringViewNode,
39
+ } from "./SteeringFormat.js"
40
+
41
+ type OpenQuestionStatus = "open" | "answered"
42
+
43
+ /**
44
+ * A marker as plain text. Local to this module because `headingText`/
45
+ * `optionText` extract text that is NOT a full node's tree span, which
46
+ * `sourceText`'s own reference exclusion does not cover.
47
+ */
48
+ const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
49
+
50
+ /** Strips every `[^name]`-shaped marker out of already-extracted `text` — see `MARKER_TEXT_RE`. */
51
+ const stripMarkerText = (text: string): string => text.replace(MARKER_TEXT_RE, "")
52
+
53
+ /**
54
+ * The sentinel an UNFILLED free-text option carries — the human answers by
55
+ * REPLACING it with their own text and ticking that line. The parser
56
+ * normalizes a last-option text equal to this to `""`, so a ticked-but-
57
+ * unfilled free-text option reads as unanswered (see `OpenQuestion.answered`).
58
+ */
59
+ export const FREE_TEXT_PLACEHOLDER = "_your answer_"
60
+
61
+ /**
62
+ * The canonical sample. Every detail is load-bearing: the three real options
63
+ * carry nested impact lists of DIFFERENT bullet counts, so the parser,
64
+ * outline and view tests cover more than one shape at once; both footnote
65
+ * bodies exceed 80 characters so the formatter round-trip actually reflows
66
+ * one; Option B's is `na`-prefixed (server-attached, not hand-authored) and
67
+ * carries a multi-word inline code span; and the whole document is already an
68
+ * oxfmt fixed point.
69
+ */
70
+ const QA_SAMPLE = `Sample plan. Add a thing.
71
+
72
+ ## Open Questions
73
+
74
+ ### Which option?
75
+
76
+ - [ ] Option A[^fn1]
77
+ - Keeps today's behavior exactly as it is
78
+ - [ ] Option B[^na17v2bjb]
79
+ - Costs a migration script
80
+ - Buys a smaller runtime footprint
81
+ - [ ] Option C
82
+ - Splits the difference between A and B
83
+ - Costs the most review time
84
+ - Buys the easiest rollback
85
+
86
+ [^na17v2bjb]:
87
+ Attached via the phone UI on Option B, this note carries a
88
+ \`multi word code span\` and exceeds eighty characters in length so it gets
89
+ wrapped.
90
+
91
+ - [ ] ${FREE_TEXT_PLACEHOLDER}
92
+
93
+ [^fn1]:
94
+ This option keeps the current behavior exactly as it is today, which is the
95
+ safer default for most reviewers.
96
+ `
97
+
98
+ /** One checkbox option under an OPEN question: its ticked state, its text (the free-text placeholder normalized to `""`), and its source line span for editor tooling. */
99
+ interface QuestionOption {
100
+ readonly checked: boolean
101
+ /** Text after the `- [ ]`/`- [x]` marker, trimmed. The unfilled free-text placeholder (`FREE_TEXT_PLACEHOLDER`) normalizes to `""`. */
102
+ readonly text: string
103
+ /** `true` for the LAST option of the block — the free-text "your answer" slot (identified positionally, not by label). */
104
+ readonly freeText: boolean
105
+ readonly sourceLine: number
106
+ /** 0-based line index of the LAST line of this option's list item — equal to `sourceLine` unless the item's text wraps onto continuation lines. */
107
+ readonly endLine: number
108
+ /**
109
+ * This option's own nested blocks — a nested plain bullet list or a second
110
+ * paragraph under it, projected through the shared block walk
111
+ * (`optionBodyNodes`). `[]` when the option carries none. Never part of
112
+ * `text`, and never reached by the write-back span (`optionTextSpan`) —
113
+ * see that span's own doc comment for why it stays narrow.
114
+ */
115
+ readonly body: readonly SteeringViewNode[]
116
+ }
117
+
118
+ interface OpenQuestion {
119
+ readonly question: string
120
+ readonly status: OpenQuestionStatus
121
+ /** The whole body's collapsed text (every `bodyNodes` block's own title, joined), or `""` for a question with no body — feeds the phone view's `detail`, the question card, and the LSP outline's own `detail` alike. */
122
+ readonly text: string
123
+ /** The question's own body run, projected through the shared block walk (`questionBodyNodes`) — every body block EXCEPT the option list itself, in document order. `[]` for a question with no body. */
124
+ readonly bodyNodes: readonly SteeringViewNode[]
125
+ /** `text`'s own inline counterpart, image-collapsed (R2) — feeds the question node's `detailInline`. Built over the SAME body run and `skipListItem` option as `bodyNodes`/`text`. */
126
+ readonly bodyInline: readonly InlineNode[]
127
+ readonly headingLine: number
128
+ /** Checkbox options in document order. `[]` for an ANSWERED question (prose, no checkboxes). The LAST option is the free-text slot. */
129
+ readonly options: readonly QuestionOption[]
130
+ /**
131
+ * `true` when this OPEN question is fully answered: EXACTLY ONE option is
132
+ * ticked, and if that's the free-text slot its text is non-empty. Always
133
+ * `false` for a question with no options and for an answered question.
134
+ */
135
+ readonly answered: boolean
136
+ }
137
+
138
+ interface OpenQuestionsDoc {
139
+ readonly questions: readonly OpenQuestion[]
140
+ readonly findings: readonly SteeringFinding[]
141
+ }
142
+
143
+ /**
144
+ * `qa`'s own `headingText`: the shared mdast helper (`MarkdownTree.ts`), with
145
+ * `qa`'s extra pass of orphan `[^name]`-marker stripping — the one thing
146
+ * `qa` needs beyond the shared footnote-reference exclusion every format
147
+ * gets for free.
148
+ */
149
+ const headingText = (content: string, heading: Heading): string =>
150
+ sharedHeadingText(content, heading, stripMarkerText)
151
+
152
+ /** One `###` heading node under a questions section, with its raw body block nodes (up to the next heading of any level). */
153
+ interface QuestionBlock extends HeadingBlock {}
154
+
155
+ /** Splits the nodes after a `## ... Questions` heading into consecutive `###` blocks. A bare `###` with no children is still collected — `parseQuestionBlock` is the one place that turns it into a finding. */
156
+ const splitQuestionBlocks = (tree: Root, sectionHeadingIndex: number): readonly QuestionBlock[] => {
157
+ const blocks: QuestionBlock[] = []
158
+ let i = sectionHeadingIndex + 1
159
+
160
+ while (i < tree.children.length) {
161
+ const node = tree.children[i]!
162
+ if (node.type === "heading" && node.depth <= 2) break
163
+
164
+ if (node.type !== "heading" || node.depth !== 3) {
165
+ i += 1
166
+ continue
167
+ }
168
+
169
+ const heading = node
170
+ i += 1
171
+ const body: RootContent[] = []
172
+ while (i < tree.children.length && tree.children[i]!.type !== "heading") {
173
+ body.push(tree.children[i]!)
174
+ i += 1
175
+ }
176
+ blocks.push({ heading, body })
177
+ }
178
+
179
+ return blocks
180
+ }
181
+
182
+ /**
183
+ * Every task-list `listItem` in the TOP-LEVEL lists of a question's body, in
184
+ * document order. A NESTED task-list item is deliberately not an option —
185
+ * this format has no sub-option — and `recognizedStructureLines` does NOT
186
+ * shield it from `strictReadingFindings`, so a 4+-space nested option gets a
187
+ * positioned finding rather than silently vanishing.
188
+ */
189
+ const optionListItems = (body: readonly RootContent[]): readonly ListItem[] => {
190
+ const items: ListItem[] = []
191
+ for (const node of body) {
192
+ if (node.type !== "list") continue
193
+ for (const child of (node as List).children) {
194
+ if (child.checked !== null) items.push(child)
195
+ }
196
+ }
197
+ return items
198
+ }
199
+
200
+ /**
201
+ * A question's body run through the shared block walk, with the question's
202
+ * own option ITEMS excluded by node identity — never a whole list swept out
203
+ * because some of its children are options, so a plain bullet sharing a list
204
+ * with real options still surfaces in `body`. `footnotes` is
205
+ * `parseOpenQuestions`'s SINGLE `parseFootnotes` call, threaded in: a
206
+ * re-parse here would be a whole-document walk per question and per option.
207
+ */
208
+ const questionBodyNodes = (
209
+ content: string,
210
+ body: readonly RootContent[],
211
+ footnotes: Footnotes,
212
+ ): readonly SteeringViewNode[] => {
213
+ const optionItems = new Set(optionListItems(body))
214
+ return blockNodesOfRun(content, body, {
215
+ skipListItem: (item) => optionItems.has(item),
216
+ footnotes,
217
+ })
218
+ }
219
+
220
+ /** `questionBodyNodes`'s own inline counterpart — the SAME body run and `skipListItem` option, walked by `Blocks.ts#blockRunInline` instead, then image-collapsed (R2) for the question node's `detailInline`. */
221
+ const questionBodyInline = (
222
+ content: string,
223
+ body: readonly RootContent[],
224
+ ): readonly InlineNode[] => {
225
+ const optionItems = new Set(optionListItems(body))
226
+ return collapseImages(
227
+ blockRunInline(content, body, { skipListItem: (item) => optionItems.has(item) }),
228
+ )
229
+ }
230
+
231
+ /** The whole body's collapsed text — every `bodyNodes` block's own title, joined with a single space, or `""` for an empty body. Feeds `OpenQuestion.text`. */
232
+ const bodyText = (bodyNodes: readonly SteeringViewNode[]): string =>
233
+ bodyNodes
234
+ .map((node) => node.title)
235
+ .filter((title) => title.length > 0)
236
+ .join(" ")
237
+
238
+ /**
239
+ * The source offset right after an option's `- [ ]`/`- [x]` marker, read off
240
+ * the first inline CHILD rather than the paragraph node:
241
+ * `mdast-util-gfm-task-list-item` leaves a checked item's paragraph with a
242
+ * stale `position.start` when what remains starts with a non-text inline node.
243
+ */
244
+ const optionContentOffset = (item: ListItem): number | undefined => {
245
+ const paragraph = item.children.find((c) => c.type === "paragraph")
246
+ return paragraph?.children[0]?.position?.start.offset
247
+ }
248
+
249
+ /**
250
+ * The ONE span an option's text is read from and written back over: after the
251
+ * marker through the end of the item's FIRST PARAGRAPH, never the list item's
252
+ * end. A wrapped option is one paragraph, so the wrap is covered; a nested
253
+ * list, second paragraph or footnote definition sits outside it and a
254
+ * free-text save can never delete it. Both the reader and the writer call
255
+ * this, so the two can never drift into that corruption.
256
+ */
257
+ const optionTextSpan = (
258
+ item: ListItem,
259
+ ): { readonly start: number; readonly end: number } | undefined => {
260
+ const start = optionContentOffset(item)
261
+ const paragraph = item.children.find((c) => c.type === "paragraph")
262
+ const end = paragraph?.position?.end.offset
263
+ if (start === undefined || end === undefined) return undefined
264
+ return { start, end }
265
+ }
266
+
267
+ /**
268
+ * An option's own text: the WHOLE `optionTextSpan`, marker-stripped and
269
+ * whitespace-collapsed — a wrapped option's continuation lines are part of
270
+ * this, unlike the old per-line-only capture. `""` when the option has no
271
+ * paragraph at all (a bare `- [ ]`).
272
+ */
273
+ const optionText = (content: string, item: ListItem): string => {
274
+ const span = optionTextSpan(item)
275
+ if (!span) return ""
276
+ return stripMarkerText(content.slice(span.start, span.end)).replace(/\s+/g, " ").trim()
277
+ }
278
+
279
+ /**
280
+ * An option's own nested blocks — the list item's children with its label
281
+ * paragraph (the one `optionTextSpan`/`optionText` already read) excluded,
282
+ * run through the shared block walk (`Blocks.ts#blockNodesOfRun`). A nested
283
+ * plain bullet list under an option is real per-option content (`checked` is
284
+ * `null` on a plain bullet, exactly what `optionListItems` already filters
285
+ * on, so it can never be mistaken for a sub-option); a second paragraph under
286
+ * the option reads the same way. `[]` for an option with nothing nested under
287
+ * it. `footnotes` is `parseOpenQuestions`'s own SINGLE `parseFootnotes` call,
288
+ * threaded in rather than re-parsed here: `blockNodesOfRun`'s own footnote
289
+ * pass walks the WHOLE document, so a question with five options re-parsing
290
+ * it five times over (on top of the per-question pass `questionBodyNodes`
291
+ * already pays) would cost work proportional to the file, not to the
292
+ * option's own nested nodes.
293
+ */
294
+ const optionBodyNodes = (
295
+ content: string,
296
+ item: ListItem,
297
+ footnotes: Footnotes,
298
+ ): readonly SteeringViewNode[] => {
299
+ const labelParagraph = item.children.find((c) => c.type === "paragraph")
300
+ const rest = item.children.filter((c) => c !== labelParagraph)
301
+ return blockNodesOfRun(content, rest, { footnotes })
302
+ }
303
+
304
+ /** Extracts the checkbox options from a question block's body, in document order. */
305
+ const parseOptions = (
306
+ content: string,
307
+ body: readonly RootContent[],
308
+ footnotes: Footnotes,
309
+ ): QuestionOption[] => {
310
+ const items = optionListItems(body)
311
+ const lastIndex = items.length - 1
312
+ return items.map((item, i) => {
313
+ const freeText = i === lastIndex
314
+ const rawText = optionText(content, item)
315
+ // `.toLowerCase()` on BOTH sides — never assume `FREE_TEXT_PLACEHOLDER`
316
+ // itself is already lowercase, mirroring `Question.tsx`'s identical
317
+ // client-side comparison exactly, so the two can never silently diverge
318
+ // if the constant's own casing ever changes.
319
+ const text =
320
+ freeText && rawText.toLowerCase() === FREE_TEXT_PLACEHOLDER.toLowerCase() ? "" : rawText
321
+ return {
322
+ checked: item.checked === true,
323
+ text,
324
+ freeText,
325
+ sourceLine: toLspPosition(item.position!.start).line,
326
+ endLine: toLspPosition(item.position!.end).line,
327
+ body: optionBodyNodes(content, item, footnotes),
328
+ }
329
+ })
330
+ }
331
+
332
+ /** The three fields `isAnswered` reads, narrower than `QuestionOption` so a CLIENT can build this from its own radio state and call the identical predicate. */
333
+ export interface AnsweredOption {
334
+ readonly checked: boolean
335
+ readonly text: string
336
+ readonly freeText: boolean
337
+ }
338
+
339
+ /**
340
+ * An OPEN question is answered iff EXACTLY ONE option is ticked and, for the
341
+ * free-text slot, its normalized text is non-empty. Zero ticks, two+ ticks and
342
+ * a ticked-but-empty slot all read as not answered. The phone client calls
343
+ * this same function rather than recomputing the rule.
344
+ */
345
+ export const isAnswered = (options: readonly AnsweredOption[]): boolean => {
346
+ const ticked = options.filter((o) => o.checked)
347
+ if (ticked.length !== 1) return false
348
+ const chosen = ticked[0]!
349
+ return !(chosen.freeText && chosen.text.length === 0)
350
+ }
351
+
352
+ /** A heading node's own span (the `#` run through its own line's end) as a `SteeringFinding.range` — the NODE a heading-shaped finding is about. */
353
+ const headingRange = (heading: Heading) => ({
354
+ start: toLspPosition(heading.position!.start),
355
+ end: toLspPosition(heading.position!.end),
356
+ })
357
+
358
+ const parseQuestionBlock = (
359
+ content: string,
360
+ block: QuestionBlock,
361
+ status: OpenQuestionStatus,
362
+ footnotes: Footnotes,
363
+ ): OpenQuestion | { readonly error: SteeringFinding } => {
364
+ const question = headingText(content, block.heading)
365
+ if (question.length === 0) {
366
+ return {
367
+ error: {
368
+ message:
369
+ "An '### ' question heading under '## Open Questions' or '## Answered Questions' has no question text",
370
+ line: toLspPosition(block.heading.position!.start).line,
371
+ range: headingRange(block.heading),
372
+ },
373
+ }
374
+ }
375
+
376
+ const headingLine = toLspPosition(block.heading.position!.start).line
377
+ const bodyNodes = questionBodyNodes(content, block.body, footnotes)
378
+ const bodyInline = questionBodyInline(content, block.body)
379
+ const options = status === "open" ? parseOptions(content, block.body, footnotes) : []
380
+
381
+ return {
382
+ question,
383
+ status,
384
+ text: bodyText(bodyNodes),
385
+ bodyNodes,
386
+ bodyInline,
387
+ headingLine,
388
+ options,
389
+ answered: status === "open" && options.length > 0 && isAnswered(options),
390
+ }
391
+ }
392
+
393
+ /**
394
+ * `## Open Questions` first, `## Answered Questions` last, so a reader always
395
+ * finds open questions before resolved ones. At most one finding per rule.
396
+ * Walks `heading` NODES, never a string search, so the line quoted inside a
397
+ * fenced code block never counts as the section.
398
+ *
399
+ * Known gap: a `## Open Questions` heading itself indented 4+ spaces parses as
400
+ * indented code, so the whole section goes unrecognized with no finding —
401
+ * `strictReadingFindings` covers the `### ` and `- [ ]` shapes, not this one.
402
+ */
403
+ const checkSectionOrder = (tree: Root, content: string): readonly SteeringFinding[] => {
404
+ const h2 = tree.children.filter((n): n is Heading => n.type === "heading" && n.depth === 2)
405
+ const openIndex = h2.findIndex((h) => headingText(content, h) === "Open Questions")
406
+ const answeredIndex = h2.findIndex((h) => headingText(content, h) === "Answered Questions")
407
+
408
+ const findings: SteeringFinding[] = []
409
+ // `openIndex > 0` — a section exists BEFORE it — is the whole condition;
410
+ // `i !== openIndex` was always true whenever `i < openIndex` already held,
411
+ // so it added nothing (a guaranteed-surviving mutant on the dead clause).
412
+ if (openIndex > 0) {
413
+ const offender = h2[0]!
414
+ findings.push({
415
+ message: "A '##' section appears before '## Open Questions', which must come first",
416
+ line: toLspPosition(offender.position!.start).line,
417
+ range: headingRange(offender),
418
+ })
419
+ }
420
+ // Same simplification: `answeredIndex < h2.length - 1` — a section exists
421
+ // AFTER it — is the whole condition.
422
+ if (answeredIndex !== -1 && answeredIndex < h2.length - 1) {
423
+ const offender = h2[h2.length - 1]!
424
+ findings.push({
425
+ message: "A '##' section appears after '## Answered Questions', which must come last",
426
+ line: toLspPosition(offender.position!.start).line,
427
+ range: headingRange(offender),
428
+ })
429
+ }
430
+ return findings
431
+ }
432
+
433
+ /** A line shaped like a '### ' question heading, indented past what CommonMark still parses as a real heading — used only by `strictReadingFindings` to recognize what fell through. */
434
+ const HEADING_SHAPE_RE = /^#{3}(?:\s|$)/
435
+
436
+ /** A line shaped like a '- [ ]'/'- [x]'/'* [X]' task-list option, indented past what CommonMark still parses as a real list item — used only by `strictReadingFindings` to recognize what fell through. */
437
+ const OPTION_SHAPE_RE = /^[-*]\s*\[[ xX]\]/
438
+
439
+ /** FENCED, never indented — both parse to the same `code` node, so telling them apart means reading the opening delimiter back out of the source. A fenced block quoting `### ` is an example, not a dropped heading. */
440
+ const isFencedCode = (content: string, node: Code): boolean => {
441
+ if (!node.position) return false
442
+ const lines = content.split(/\r?\n/)
443
+ const raw = (lines[toLspPosition(node.position.start).line] ?? "").trim()
444
+ return raw.startsWith("```") || raw.startsWith("~~~")
445
+ }
446
+
447
+ /**
448
+ * Every 0-based line inside a NODE matching `predicate`, at any nesting
449
+ * depth — used by `fencedCodeLines` (a fenced block is a legitimate quoted
450
+ * example regardless of how deep it's nested).
451
+ */
452
+ const linesWhere = (tree: Root, predicate: (node: RootContent) => boolean): Set<number> => {
453
+ const lines = new Set<number>()
454
+ const walk = (node: RootContent | Root): void => {
455
+ if (node.type !== "root" && predicate(node) && node.position) {
456
+ const start = toLspPosition(node.position.start).line
457
+ const end = toLspPosition(node.position.end).line
458
+ for (let l = start; l <= end; l += 1) lines.add(l)
459
+ }
460
+ const children = (node as { children?: readonly RootContent[] }).children
461
+ if (children) children.forEach(walk)
462
+ }
463
+ walk(tree)
464
+ return lines
465
+ }
466
+
467
+ const markRange = (lines: Set<number>, node: RootContent): void => {
468
+ if (!node.position) return
469
+ const start = toLspPosition(node.position.start).line
470
+ const end = toLspPosition(node.position.end).line
471
+ for (let l = start; l <= end; l += 1) lines.add(l)
472
+ }
473
+
474
+ /** Lines inside a NESTED `heading`/`list` under a top-level item. Real tree structure, but lost from this format's OUTPUT, so `recognizedStructureLines` must not excuse it just for sitting inside a real item's span. */
475
+ const nestedBlockLines = (item: ListItem): Set<number> => {
476
+ const lines = new Set<number>()
477
+ const walk = (node: RootContent): void => {
478
+ if (node.type === "heading" || node.type === "list") {
479
+ markRange(lines, node)
480
+ return
481
+ }
482
+ const children = (node as { children?: readonly RootContent[] }).children
483
+ if (children) children.forEach(walk)
484
+ }
485
+ item.children.forEach(walk)
486
+ return lines
487
+ }
488
+
489
+ /** Marks a top-level `list` node's own TOP-LEVEL task-list items into `lines`, each minus its own `nestedBlockLines` — split out of `recognizedStructureLines` to keep that function's own complexity down. */
490
+ const markTopLevelListItems = (lines: Set<number>, list: List): void => {
491
+ for (const item of list.children) {
492
+ if (item.checked === null) continue
493
+ markRange(lines, item)
494
+ for (const nested of nestedBlockLines(item)) lines.delete(nested)
495
+ }
496
+ }
497
+
498
+ /**
499
+ * The lines `strictReadingFindings` never flags: a TOP-LEVEL depth-2/3
500
+ * heading, a TOP-LEVEL task-list item's span MINUS any `heading`/`list`
501
+ * nested inside it, and a `footnoteDefinition`'s whole span.
502
+ *
503
+ * Deeper nesting is deliberately NOT excluded, however validly CommonMark
504
+ * parses it: `optionListItems` never looks past the top level, so that
505
+ * content is as lost from this format's output as an unindented line.
506
+ *
507
+ * A footnote definition differs in kind, not degree — the human's free-text
508
+ * channel, never a candidate heading or option under any reading — so its
509
+ * whole span is excluded at any nesting. This repo's own footnote style
510
+ * indents continuation lines four spaces, exactly the misfire threshold.
511
+ */
512
+ const recognizedStructureLines = (tree: Root): Set<number> => {
513
+ const lines = new Set<number>()
514
+ for (const node of tree.children) {
515
+ if (node.type === "heading" && (node.depth === 2 || node.depth === 3)) markRange(lines, node)
516
+ if (node.type === "list") markTopLevelListItems(lines, node)
517
+ if (node.type === "footnoteDefinition") markRange(lines, node)
518
+ }
519
+ return lines
520
+ }
521
+
522
+ /** Every 0-based line inside a FENCED code block — a legitimate quoted example, never a strict-reading violation (unlike an indented code block, or an indented lazy paragraph continuation, which the refusal exists to catch). */
523
+ const fencedCodeLines = (tree: Root, content: string): Set<number> =>
524
+ linesWhere(tree, (n) => n.type === "code" && isFencedCode(content, n))
525
+
526
+ /** A depth-2 section's body line range. Line-based, not node-based: the point is to catch source that never became a distinct node, so there is no node boundary to walk. */
527
+ const sectionLineRange = (
528
+ tree: Root,
529
+ content: string,
530
+ heading: Heading,
531
+ ): { readonly start: number; readonly end: number } => {
532
+ const lines = content.split(/\r?\n/)
533
+ const index = tree.children.indexOf(heading)
534
+ const start = toLspPosition(heading.position!.end).line + 1
535
+ let end = lines.length - 1
536
+ for (let i = index + 1; i < tree.children.length; i += 1) {
537
+ const node = tree.children[i]!
538
+ if (node.type === "heading" && node.depth <= 2 && node.position) {
539
+ end = toLspPosition(node.position.start).line - 1
540
+ break
541
+ }
542
+ }
543
+ return { start, end }
544
+ }
545
+
546
+ /** The strict-reading message for one dropped `trimmed` line, or `undefined` when it matches neither shape — split out of `strictReadingFindings` to keep that function's own complexity down. */
547
+ const strictReadingMessage = (trimmed: string): string | undefined => {
548
+ if (HEADING_SHAPE_RE.test(trimmed)) {
549
+ return `An indented (4+ space) "${trimmed}" is markdown indented code (or a lazy paragraph continuation), not a question heading — it is silently dropped otherwise`
550
+ }
551
+ if (OPTION_SHAPE_RE.test(trimmed)) {
552
+ return `An indented (4+ space) "${trimmed}" is markdown indented code (or a lazy paragraph continuation), not an option — it is silently dropped otherwise`
553
+ }
554
+ return undefined
555
+ }
556
+
557
+ /** Every strict-reading finding in ONE section's own `[start, end]` line range — split out of `strictReadingFindings` to keep that function's own complexity down (one section's scan, not the loop over both sections). */
558
+ const strictReadingFindingsInRange = (
559
+ lines: readonly string[],
560
+ excluded: ReadonlySet<number>,
561
+ start: number,
562
+ end: number,
563
+ ): SteeringFinding[] => {
564
+ const findings: SteeringFinding[] = []
565
+ for (let line = start; line <= end; line += 1) {
566
+ if (excluded.has(line)) continue
567
+ const raw = lines[line] ?? ""
568
+ if (raw.length - raw.trimStart().length < 4) continue
569
+ const message = strictReadingMessage(raw.trim())
570
+ // No real node exists for this line (that's the whole violation — the
571
+ // content never became one) so its range is the raw line itself, start
572
+ // to end, rather than a node span.
573
+ if (message) {
574
+ findings.push({
575
+ message,
576
+ line,
577
+ range: { start: { line, character: 0 }, end: { line, character: raw.length } },
578
+ })
579
+ }
580
+ }
581
+ return findings
582
+ }
583
+
584
+ /**
585
+ * A `### `- or `- [ ]`-shaped line indented 4+ spaces is never a real heading
586
+ * or list item — it is indented code, or a lazy paragraph continuation — so
587
+ * without this it vanishes with no signal: a whole question silently dropped,
588
+ * or one left with zero options and read as merely unanswered. Reported at the
589
+ * exact source line, whichever of the two swallowed it.
590
+ */
591
+ const strictReadingFindings = (tree: Root, content: string): SteeringFinding[] => {
592
+ const lines = content.split(/\r?\n/)
593
+ const excluded = new Set([...recognizedStructureLines(tree), ...fencedCodeLines(tree, content)])
594
+
595
+ return ["Open Questions", "Answered Questions"].flatMap((sectionName) => {
596
+ const heading = tree.children.find(
597
+ (n): n is Heading =>
598
+ n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
599
+ )
600
+ if (!heading?.position) return []
601
+ const { start, end } = sectionLineRange(tree, content, heading)
602
+ return strictReadingFindingsInRange(lines, excluded, start, end)
603
+ })
604
+ }
605
+
606
+ /**
607
+ * The structure plus every finding `validate` reports. Each finding carries a
608
+ * `line` AND a `range` spanning its node — except `strictReadingFindings`',
609
+ * which never had a node, so their range spans the raw offending line.
610
+ *
611
+ * `parseFootnotes(content)` runs exactly ONCE here and is threaded through
612
+ * every question/option body walk below: re-running it per question or per
613
+ * option would multiply a document-sized walk by every one in the file.
614
+ */
615
+ export const parseOpenQuestions = (content: string): OpenQuestionsDoc => {
616
+ const tree = parseMarkdown(content)
617
+ const footnotes = parseFootnotes(content)
618
+
619
+ const questions: OpenQuestion[] = []
620
+ const findings: SteeringFinding[] = [
621
+ ...checkSectionOrder(tree, content),
622
+ ...strictReadingFindings(tree, content),
623
+ ]
624
+
625
+ const sections: readonly (readonly [string, OpenQuestionStatus])[] = [
626
+ ["Open Questions", "open"],
627
+ ["Answered Questions", "answered"],
628
+ ]
629
+
630
+ for (const [sectionName, status] of sections) {
631
+ const headingNode = tree.children.find(
632
+ (n): n is Heading =>
633
+ n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
634
+ )
635
+ if (!headingNode) continue
636
+ const index = tree.children.indexOf(headingNode)
637
+ for (const block of splitQuestionBlocks(tree, index)) {
638
+ const result = parseQuestionBlock(content, block, status, footnotes)
639
+ if ("error" in result) {
640
+ findings.push(result.error)
641
+ } else {
642
+ questions.push(result)
643
+ }
644
+ }
645
+ }
646
+
647
+ questions.sort((a, b) => a.headingLine - b.headingLine)
648
+ return { questions, findings }
649
+ }
650
+
651
+ /** Every OPEN question that is not answered — what a flow's `openQuestions()` reads. */
652
+ const unansweredQuestions = (content: string): readonly OpenQuestion[] =>
653
+ parseOpenQuestions(content).questions.filter((q) => q.status === "open" && !q.answered)
654
+
655
+ /**
656
+ * Flips the checkbox on the task-list item at `line`, preserving the rest
657
+ * exactly. The box is found in the window between the item's start offset and
658
+ * its content offset — which holds only the list marker and the box — rather
659
+ * than by `indexOf("[")`, which text containing its own `[` would mislead.
660
+ */
661
+ const toggleCheckbox = (content: string, line: number): SteeringEdit | undefined => {
662
+ const tree = parseMarkdown(content)
663
+ const item = taskItems(tree).find((it) => toLspPosition(it.position!.start).line === line)
664
+ if (!item?.position) return undefined
665
+
666
+ const startOffset = item.position.start.offset!
667
+ const boundOffset = optionContentOffset(item) ?? item.position.end.offset!
668
+ const bracketOffset = content.indexOf("[", startOffset)
669
+ if (bracketOffset === -1 || bracketOffset >= boundOffset) return undefined
670
+
671
+ const position = toLspPositionFromOffset(content, bracketOffset)
672
+ const character = position.character + 1
673
+ return {
674
+ range: {
675
+ start: { line: position.line, character },
676
+ end: { line: position.line, character: character + 1 },
677
+ },
678
+ newText: item.checked === true ? " " : "x",
679
+ }
680
+ }
681
+
682
+ /** The outline marker for one question — `[unanswered]` entries are exactly the questions still blocking the answer-completeness gate. */
683
+ const statusMarker = (question: OpenQuestion): string => {
684
+ if (question.status === "answered") return "[answered]"
685
+ return question.answered ? "[answered]" : "[unanswered]"
686
+ }
687
+
688
+ /** Every question/answered heading's own line → its outline range's real end line (`blockEndLine`), across both sections — the node-boundary replacement for the old "next sibling's heading minus one" guess. */
689
+ const questionEndLines = (content: string): ReadonlyMap<number, number> => {
690
+ const tree = parseMarkdown(content)
691
+ const map = new Map<number, number>()
692
+ for (const sectionName of ["Open Questions", "Answered Questions"]) {
693
+ const headingNode = tree.children.find(
694
+ (n): n is Heading =>
695
+ n.type === "heading" && n.depth === 2 && headingText(content, n) === sectionName,
696
+ )
697
+ if (!headingNode) continue
698
+ const index = tree.children.indexOf(headingNode)
699
+ for (const block of splitQuestionBlocks(tree, index)) {
700
+ map.set(toLspPosition(block.heading.position!.start).line, blockEndLine(block))
701
+ }
702
+ }
703
+ return map
704
+ }
705
+
706
+ /** The outline tree. An option is a `leaf` unless it carries a footnote, which makes it a container instead — `leaf` and `children` are never both set. */
707
+ const questionsOutline = (content: string): readonly SteeringOutlineNode[] => {
708
+ const { questions } = parseOpenQuestions(content)
709
+ const { markers, definitions } = parseFootnotes(content)
710
+ const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
711
+ const lines = content.split(/\r?\n/)
712
+ const endLines = questionEndLines(content)
713
+ const threads = threadOutlineNodes(content)
714
+ const nested = new Set<SteeringOutlineNode>()
715
+ const threadsIn = (from: number, to: number): SteeringOutlineNode[] =>
716
+ threads
717
+ .filter((t) => t.markerLine !== undefined && t.markerLine >= from && t.markerLine <= to)
718
+ .map((t) => t.node)
719
+ const nodes = questions.map((question) => {
720
+ const start = question.headingLine
721
+ const end = Math.max(start, endLines.get(start) ?? start)
722
+ const questionMarkers = markers.filter((m) => m.line >= start && m.line <= end)
723
+ const assigned = new Set<FootnoteMarker>()
724
+
725
+ const optionChildren: SteeringOutlineNode[] = question.options.map((option) => {
726
+ const optionMarkers = questionMarkers.filter(
727
+ (m) => m.line >= option.sourceLine && m.line <= option.endLine,
728
+ )
729
+ optionMarkers.forEach((m) => assigned.add(m))
730
+ const optionThreads = threadsIn(option.sourceLine, option.endLine)
731
+ optionThreads.forEach((n) => nested.add(n))
732
+ const footnotes = [
733
+ ...optionMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
734
+ ...optionThreads,
735
+ ]
736
+ const impactsDetail = bodyText(option.body)
737
+ return {
738
+ name: `${option.checked ? "[x]" : "[ ]"} ${option.text || "your answer"}`,
739
+ ...(impactsDetail.length > 0 ? { detail: impactsDetail } : {}),
740
+ range: spanRange(lines, option.sourceLine, option.endLine),
741
+ selectionRange: lineRange(lines, option.sourceLine),
742
+ // `leaf: true` means "no children of its own" (SteeringFormat.ts) —
743
+ // an option with a footnote child is no longer one, matching
744
+ // review.ts's chunk nodes (children, no `leaf`).
745
+ ...(footnotes.length > 0 ? { children: footnotes } : { leaf: true }),
746
+ }
747
+ })
748
+
749
+ const questionFootnotes = questionMarkers
750
+ .filter((m) => !assigned.has(m))
751
+ .map((m) => footnoteLeaf(lines, definitionByName, m))
752
+ const questionThreads = threadsIn(start, end).filter((n) => !nested.has(n))
753
+ questionThreads.forEach((n) => nested.add(n))
754
+ const children = [...optionChildren, ...questionFootnotes, ...questionThreads]
755
+
756
+ return {
757
+ name: `${statusMarker(question)} ${question.question}`,
758
+ detail: question.text,
759
+ range: spanRange(lines, start, end),
760
+ selectionRange: lineRange(lines, start),
761
+ ...(children.length > 0 ? { children } : {}),
762
+ }
763
+ })
764
+ return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
765
+ }
766
+
767
+ /** The edits that make `option` the sole ticked option in `question` (radio semantics): check it, and uncheck any already-ticked sibling — so the question ends with exactly one tick (what the completeness gate wants). */
768
+ const pickOptionEdits = (
769
+ content: string,
770
+ question: OpenQuestion,
771
+ option: QuestionOption,
772
+ ): SteeringEdit[] => {
773
+ const edits: SteeringEdit[] = []
774
+ for (const sibling of question.options) {
775
+ if (sibling.sourceLine !== option.sourceLine && !sibling.checked) continue
776
+ const edit = toggleCheckbox(content, sibling.sourceLine)
777
+ if (edit) edits.push(edit)
778
+ }
779
+ return edits
780
+ }
781
+
782
+ /** Sets an explicit `checked` STATE, unlike `pickOptionEdits`, which assumes the target is not already ticked. `true` is radio (unticks every sibling); `false` only unticks the target. */
783
+ const setOptionCheckedEdits = (
784
+ content: string,
785
+ question: OpenQuestion,
786
+ option: QuestionOption,
787
+ checked: boolean,
788
+ ): SteeringEdit[] => {
789
+ if (!checked) {
790
+ if (!option.checked) return []
791
+ const edit = toggleCheckbox(content, option.sourceLine)
792
+ return edit ? [edit] : []
793
+ }
794
+ const edits: SteeringEdit[] = []
795
+ for (const sibling of question.options) {
796
+ const isTarget = sibling.sourceLine === option.sourceLine
797
+ if (isTarget ? sibling.checked : !sibling.checked) continue
798
+ const edit = toggleCheckbox(content, sibling.sourceLine)
799
+ if (edit) edits.push(edit)
800
+ }
801
+ return edits
802
+ }
803
+
804
+ /** Replaces the WHOLE `optionTextSpan`, wrap and all — used only for the free-text slot, whose placeholder or prior answer the typed answer replaces in place. */
805
+ const replaceOptionTextEdit = (
806
+ content: string,
807
+ item: ListItem,
808
+ text: string,
809
+ ): SteeringEdit | undefined => {
810
+ const span = optionTextSpan(item)
811
+ if (!span) return undefined
812
+ return {
813
+ range: {
814
+ start: toLspPositionFromOffset(content, span.start),
815
+ end: toLspPositionFromOffset(content, span.end),
816
+ },
817
+ newText: text,
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Collapses a typed answer to the single-line shape a list-item label must
823
+ * be. A raw textarea value's interior newlines would break `.gtd/`'s oxfmt
824
+ * fixed point the moment they are spliced onto a `- [x] ` line, and every
825
+ * steering file — server-written ones included — is covered by `format:check`.
826
+ */
827
+ /**
828
+ * Empty normalizes to `FREE_TEXT_PLACEHOLDER`, never `""`: an empty label
829
+ * leaves nothing after the marker, which `optionContentOffset` cannot find an
830
+ * offset for, breaking this anchor's `apply` forever. The placeholder reads
831
+ * back as `""`, so an erase still reads as unanswered.
832
+ */
833
+ const normalizeFreeTextAnswer = (text: string): string => {
834
+ const normalized = text.replace(/\s+/g, " ").trim()
835
+ return normalized.length === 0 ? FREE_TEXT_PLACEHOLDER : normalized
836
+ }
837
+
838
+ /** Only the `option` anchor resolves here. `checked` defaults to `true`, and `text` is normalized and replaces the label in the SAME edit set as the tick — never a second call, never the raw textarea value. */
839
+ const questionsApply: SteeringFormat["apply"] = (content, anchor, opts) => {
840
+ if (anchor.kind !== "option") return { ok: false, reason: "anchor-not-found" }
841
+ const { questions } = parseOpenQuestions(content)
842
+ const question = questions[anchor.questionIndex]
843
+ const option = question?.options[anchor.index]
844
+ if (!question || !option) return { ok: false, reason: "anchor-not-found" }
845
+
846
+ const checked = opts.checked ?? true
847
+ const edits = setOptionCheckedEdits(content, question, option, checked)
848
+
849
+ if (opts.text !== undefined) {
850
+ const tree = parseMarkdown(content)
851
+ const item = taskItems(tree).find(
852
+ (it) => toLspPosition(it.position!.start).line === option.sourceLine,
853
+ )
854
+ const textEdit = item
855
+ ? replaceOptionTextEdit(content, item, normalizeFreeTextAnswer(opts.text))
856
+ : undefined
857
+ if (textEdit) edits.push(textEdit)
858
+ }
859
+
860
+ return { ok: true, edits }
861
+ }
862
+
863
+ /** The single action for the option line the cursor sits on: uncheck it if ticked, else pick it (radio). `undefined` when there is no edit to make. */
864
+ const optionAction = (
865
+ content: string,
866
+ question: OpenQuestion,
867
+ option: QuestionOption,
868
+ ): { readonly title: string; readonly edits: readonly SteeringEdit[] } | undefined => {
869
+ const edits = option.checked
870
+ ? [toggleCheckbox(content, option.sourceLine)].filter((e): e is SteeringEdit => e !== undefined)
871
+ : pickOptionEdits(content, question, option)
872
+ if (edits.length === 0) return undefined
873
+ return { title: option.checked ? "gtd: uncheck this option" : "gtd: pick this option", edits }
874
+ }
875
+
876
+ /**
877
+ * Where a footnote lands: the containing top-level block node's end line. One
878
+ * rule, not a case per shape — and resolving from the cursor's OWN node means
879
+ * prose between two option lists anchors to that prose, not the second list.
880
+ */
881
+ const footnoteBlockEnd = (tree: Root, cursorLine: number): number => {
882
+ const block = blockNodeAt(tree, cursorLine)
883
+ return block?.position ? toLspPosition(block.position.end).line : cursorLine
884
+ }
885
+
886
+ /** Pick/uncheck on an open question's option; "add a footnote" LAST, after every option action, and everywhere EXCEPT inside an existing marker's span or on a definition's line, where a new marker would corrupt the footnote already there. */
887
+ const questionActions: SteeringFormat["actions"] = (content, range) => {
888
+ const { questions } = parseOpenQuestions(content)
889
+ const tree = parseMarkdown(content)
890
+ const cursorLine = range.start.line
891
+ const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
892
+ ...threadReplyActions(content, range.start),
893
+ ]
894
+ for (const question of questions) {
895
+ if (question.status !== "open") continue
896
+ const option = question.options.find(
897
+ (o) => cursorLine >= o.sourceLine && cursorLine <= o.endLine,
898
+ )
899
+ if (!option) continue
900
+ const action = optionAction(content, question, option)
901
+ if (action) actions.push(action)
902
+ }
903
+ if (!isOnExistingFootnote(content, range.start)) {
904
+ actions.push({
905
+ title: FOOTNOTE_ACTION_TITLE,
906
+ edits: footnoteAdditionEdits(content, range.start, footnoteBlockEnd(tree, cursorLine)),
907
+ })
908
+ }
909
+ return actions
910
+ }
911
+
912
+ /**
913
+ * `qa`'s `pointerAt`: footnote jumps only (an open question's options have
914
+ * nothing else to jump to) — marker → definition, definition → first marker,
915
+ * both within the same document.
916
+ */
917
+ const questionsPointerAt: SteeringFormat["pointerAt"] = (content, position) =>
918
+ footnotePointerAt(content, position)?.pointer
919
+
920
+ /**
921
+ * `true` when top-level node `n` is the `## Open Questions`/`## Answered
922
+ * Questions` heading itself — the client renders those as its own section
923
+ * headers, so `blockNodesOf` never emits a block node for either.
924
+ */
925
+ const isQuestionsSectionHeading = (content: string, n: RootContent): n is Heading =>
926
+ n.type === "heading" &&
927
+ n.depth === 2 &&
928
+ (headingText(content, n) === "Open Questions" || headingText(content, n) === "Answered Questions")
929
+
930
+ /**
931
+ * Every `[headingLine, endLine]` span a real question block owns, across
932
+ * both sections (`questionEndLines`, already computed for the outline) —
933
+ * `blockNodesOf` skips any top-level node whose OWN start line falls inside
934
+ * one of these, since that content is already projected as the question's
935
+ * `question`/`option` node pair, never a second, competing block node.
936
+ */
937
+ const isInsideQuestionSpan = (spans: ReadonlyMap<number, number>, line: number): boolean => {
938
+ for (const [start, end] of spans) {
939
+ if (line >= start && line <= end) return true
940
+ }
941
+ return false
942
+ }
943
+
944
+ /**
945
+ * Every top-level block FIRST — without this a document with any question
946
+ * would drop its intro prose —
947
+ * then every question as a container with its options as children. `title` is
948
+ * the heading TEXT, never the first body line, which rides as `detail`. ONE
949
+ * parse plus one block walk, never one parse per question.
950
+ */
951
+ const questionsView = (content: string): SteeringView => {
952
+ const tree = parseMarkdown(content)
953
+ const spans = questionEndLines(content)
954
+ const blockNodes = blockNodesOf(content, tree, {
955
+ skipNode: isQuestionsSectionHeading,
956
+ skipLine: (line) => isInsideQuestionSpan(spans, line),
957
+ })
958
+ const { questions } = parseOpenQuestions(content)
959
+ const noteAt = noteLookup(content)
960
+ return {
961
+ nodes: [
962
+ ...blockNodes,
963
+ ...questions.map((question, questionIndex) => ({
964
+ title: question.question,
965
+ detail: question.text,
966
+ detailInline: question.bodyInline,
967
+ status: question.status,
968
+ answered: question.answered,
969
+ anchor: { kind: "question" as const, index: questionIndex },
970
+ ...(({ thread }) => (thread ? { thread } : {}))(noteAt(question.headingLine)),
971
+ body: question.bodyNodes,
972
+ children: question.options.map((option, index) => ({
973
+ title: option.text,
974
+ checked: option.checked,
975
+ anchor: { kind: "option" as const, questionIndex, index },
976
+ // The free-text slot never carries impacts of its own — see
977
+ // `QuestionOption.body`'s own doc comment.
978
+ ...(option.freeText ? {} : { body: option.body }),
979
+ })),
980
+ })),
981
+ ],
982
+ }
983
+ }
984
+
985
+ /** Resolves a `question` anchor: attaches at the end of the question's own heading line, the definition landing after the question's whole block (`questionEndLines`). `undefined` for a stale `index`. */
986
+ const resolveQuestionAnchor = (
987
+ content: string,
988
+ lines: readonly string[],
989
+ questions: readonly OpenQuestion[],
990
+ index: number,
991
+ ): FootnoteAnchor | undefined => {
992
+ const question = questions[index]
993
+ if (!question) return undefined
994
+ const end = Math.max(
995
+ question.headingLine,
996
+ questionEndLines(content).get(question.headingLine) ?? question.headingLine,
997
+ )
998
+ return {
999
+ line: question.headingLine,
1000
+ endCharacter: (lines[question.headingLine] ?? "").length,
1001
+ blockEndLine: end,
1002
+ key: `question:${question.headingLine}`,
1003
+ }
1004
+ }
1005
+
1006
+ /** Resolves an `option` anchor: attaches at the end of the option's own source line, the definition landing after the option's whole span (`option.endLine`). `undefined` for a stale `questionIndex`/`index`. */
1007
+ const resolveOptionAnchor = (
1008
+ lines: readonly string[],
1009
+ questions: readonly OpenQuestion[],
1010
+ questionIndex: number,
1011
+ index: number,
1012
+ ): FootnoteAnchor | undefined => {
1013
+ const option = questions[questionIndex]?.options[index]
1014
+ if (!option) return undefined
1015
+ return {
1016
+ line: option.sourceLine,
1017
+ endCharacter: (lines[option.sourceLine] ?? "").length,
1018
+ blockEndLine: option.endLine,
1019
+ key: `option:${option.sourceLine}`,
1020
+ }
1021
+ }
1022
+
1023
+ /** 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. */
1024
+ const resolveQuestionsParagraphAnchor = (
1025
+ tree: Root,
1026
+ lines: readonly string[],
1027
+ line: number,
1028
+ ): FootnoteAnchor | undefined => {
1029
+ const block = blockNodeAt(tree, line)
1030
+ if (!block?.position) return undefined
1031
+ return {
1032
+ line,
1033
+ endCharacter: (lines[line] ?? "").length,
1034
+ blockEndLine: toLspPosition(block.position.end).line,
1035
+ key: `paragraph:${line}`,
1036
+ }
1037
+ }
1038
+
1039
+ /**
1040
+ * Resolves a `question`/`option`/`paragraph` `SteeringAnchor` against
1041
+ * `content` into the low-level `FootnoteAnchor` `Footnotes.ts#footnoteAttachEdits`
1042
+ * wants — `undefined` for a `chunk`/`hunk` anchor (not this format's own
1043
+ * kind) or an index/line that no longer resolves.
1044
+ */
1045
+ const resolveQuestionsAnchor = (
1046
+ content: string,
1047
+ tree: Root,
1048
+ lines: readonly string[],
1049
+ questions: readonly OpenQuestion[],
1050
+ anchor: SteeringAnchor,
1051
+ ): FootnoteAnchor | undefined => {
1052
+ switch (anchor.kind) {
1053
+ case "question":
1054
+ return resolveQuestionAnchor(content, lines, questions, anchor.index)
1055
+ case "option":
1056
+ return resolveOptionAnchor(lines, questions, anchor.questionIndex, anchor.index)
1057
+ case "paragraph":
1058
+ return resolveQuestionsParagraphAnchor(tree, lines, anchor.line)
1059
+ default:
1060
+ return undefined
1061
+ }
1062
+ }
1063
+
1064
+ /** `qa`-mode's `annotate`: resolves `anchor` then delegates the two-edit mechanics (`text` carried through verbatim as the new definition's body) to `Footnotes.ts#footnoteAttachEdits`. */
1065
+ const questionsAnnotate = (
1066
+ content: string,
1067
+ anchor: SteeringAnchor,
1068
+ text: string,
1069
+ ): SteeringAnnotateResult => {
1070
+ const { questions } = parseOpenQuestions(content)
1071
+ const tree = parseMarkdown(content)
1072
+ const lines = content.split(/\r?\n/)
1073
+ const resolved = resolveQuestionsAnchor(content, tree, lines, questions, anchor)
1074
+ if (!resolved) return { ok: false, reason: "anchor-not-found" }
1075
+ const result = footnoteAttachEdits(content, resolved, text)
1076
+ if (!result.ok) return result
1077
+ return { ok: true, edits: result.edits }
1078
+ }
1079
+
1080
+ /**
1081
+ * `qa`'s ticks ARE its answers — an "uncheck" that blindly cleared every
1082
+ * checked box would erase a human's answer, not reset read progress like
1083
+ * `review`'s hunk ticks. So `qa`'s `clearTicks` is a deliberate no-op: there
1084
+ * is nothing here that is ever safe to auto-clear.
1085
+ */
1086
+ const qaClearTicks = (content: string): string => content
1087
+
1088
+ /** The `qa` steering descriptor: gtd's own in-process open-questions checkbox format — validation, outline, code actions, a footnote-only `pointerAt`, a no-op `clearTicks`, and the answer-completeness predicate (`unansweredQuestions`). Every structural finding carries a line and a range spanning the node it's about. */
1089
+ export const qaDescriptor: SteeringFormat = {
1090
+ sample: QA_SAMPLE,
1091
+ validate: (content) => [
1092
+ ...parseOpenQuestions(content).findings,
1093
+ ...parseFootnotes(content).findings,
1094
+ ],
1095
+ outline: questionsOutline,
1096
+ actions: questionActions,
1097
+ pointerAt: questionsPointerAt,
1098
+ view: questionsView,
1099
+ annotate: questionsAnnotate,
1100
+ apply: questionsApply,
1101
+ clearTicks: qaClearTicks,
1102
+ unansweredQuestions,
1103
+ openThreads: openThreadsOf,
1104
+ }