@pmelab/gtd 23.2.0 → 24.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +44 -11
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43830 -56044
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +1953 -1724
  19. package/package.json +70 -8
  20. package/skills/authoring/SKILL.md +5 -4
  21. package/src/cli/Cli.test.ts +1329 -0
  22. package/src/cli/Cli.ts +1348 -0
  23. package/src/cli/Program.test.ts +2262 -0
  24. package/src/cli/Program.ts +1331 -0
  25. package/src/cli/ToolCommands.ts +43 -0
  26. package/src/cli/index.ts +14 -0
  27. package/src/edge/Edge.test.ts +1016 -0
  28. package/src/edge/Edge.ts +1225 -0
  29. package/src/edge/index.ts +16 -0
  30. package/src/emit/Emit.test.ts +425 -0
  31. package/src/emit/Emit.ts +139 -0
  32. package/src/emit/GitScript.test.ts +260 -0
  33. package/src/emit/GitScript.ts +131 -0
  34. package/src/emit/ModeContradiction.test.ts +120 -0
  35. package/src/emit/ModeContradiction.ts +66 -0
  36. package/src/emit/OutcomeScript.test.ts +238 -0
  37. package/src/emit/OutcomeScript.ts +73 -0
  38. package/src/emit/SteeringMode.test.ts +274 -0
  39. package/src/emit/SteeringMode.ts +256 -0
  40. package/src/emit/index.ts +40 -0
  41. package/src/flows/factory.ts +5 -1
  42. package/src/flows/runtime.ts +3 -0
  43. package/src/install/ConfigScaffold.ts +19 -0
  44. package/src/install/Install.test.ts +426 -0
  45. package/src/install/Install.ts +634 -0
  46. package/src/install/Version.ts +27 -0
  47. package/src/install/index.ts +3 -0
  48. package/src/platform/CommandRunner.ts +74 -0
  49. package/src/platform/Commentary.test.ts +126 -0
  50. package/src/platform/Commentary.ts +92 -0
  51. package/src/platform/Git.test.ts +50 -0
  52. package/src/platform/Git.ts +552 -0
  53. package/src/platform/Host.ts +42 -0
  54. package/src/platform/RetainedHistory.test.ts +65 -0
  55. package/src/platform/RetainedHistory.ts +34 -0
  56. package/src/platform/Workspace.test.ts +669 -0
  57. package/src/platform/Workspace.ts +631 -0
  58. package/src/platform/WorktreeState.test.ts +162 -0
  59. package/src/platform/WorktreeState.ts +50 -0
  60. package/src/platform/index.ts +22 -0
  61. package/src/replay/Access.test.ts +53 -0
  62. package/src/replay/Access.ts +46 -0
  63. package/src/replay/Format.test.ts +20 -0
  64. package/src/replay/Format.ts +14 -0
  65. package/src/replay/Replay.test.ts +887 -0
  66. package/src/replay/Replay.ts +669 -0
  67. package/src/replay/Trailers.test.ts +117 -0
  68. package/src/replay/Trailers.ts +213 -0
  69. package/src/replay/Tree.test.ts +25 -0
  70. package/src/replay/Tree.ts +45 -0
  71. package/src/replay/index.ts +11 -0
  72. package/src/steering/Blocks.fixture.ts +6 -0
  73. package/src/steering/Blocks.test.ts +167 -0
  74. package/src/steering/Blocks.ts +321 -0
  75. package/src/steering/CodeThreads.test.ts +127 -0
  76. package/src/steering/CodeThreads.ts +197 -0
  77. package/src/steering/Edits.test.ts +19 -0
  78. package/src/steering/Edits.ts +20 -0
  79. package/src/steering/Eol.test.ts +16 -0
  80. package/src/steering/Eol.ts +2 -0
  81. package/src/steering/FORMATS.md +58 -0
  82. package/src/steering/Footnotes.test.ts +977 -0
  83. package/src/steering/Footnotes.ts +661 -0
  84. package/src/steering/Inline.fixture.ts +7 -0
  85. package/src/steering/Inline.test.ts +227 -0
  86. package/src/steering/Inline.ts +195 -0
  87. package/src/steering/MarkdownTree.test.ts +247 -0
  88. package/src/steering/MarkdownTree.ts +260 -0
  89. package/src/steering/Outline.ts +19 -0
  90. package/src/steering/SteeringFormat.test.ts +91 -0
  91. package/src/steering/SteeringFormat.ts +267 -0
  92. package/src/steering/SteeringFormats.test.ts +163 -0
  93. package/src/steering/Threads.ts +163 -0
  94. package/src/steering/contract.test.ts +264 -0
  95. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  96. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  97. package/src/steering/fixtures/free-form/notes.md +12 -0
  98. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  99. package/src/steering/fixtures/qa/answered.md +23 -0
  100. package/src/steering/fixtures/qa/answered.read.json +119 -0
  101. package/src/steering/fixtures/qa/requirements.md +160 -0
  102. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  103. package/src/steering/fixtures/review/review.md +76 -0
  104. package/src/steering/fixtures/review/review.read.json +237 -0
  105. package/src/steering/fixtures/review/ticked.md +23 -0
  106. package/src/steering/fixtures/review/ticked.read.json +99 -0
  107. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  108. package/src/steering/fixtures/review-notes/noted.md +19 -0
  109. package/src/steering/freeform.test.ts +311 -0
  110. package/src/steering/freeform.ts +148 -0
  111. package/src/steering/index.test.ts +62 -0
  112. package/src/steering/index.ts +140 -0
  113. package/src/steering/qa.test.ts +2160 -0
  114. package/src/steering/qa.ts +1104 -0
  115. package/src/steering/review.test.ts +1829 -0
  116. package/src/steering/review.ts +1180 -0
  117. package/src/step/LandStep.ts +31 -0
  118. package/src/step/RepoSnapshot.ts +29 -0
  119. package/src/step/accessRefusal.test.ts +58 -0
  120. package/src/step/accessRefusal.ts +26 -0
  121. package/src/step/index.ts +5 -0
  122. package/src/step/planStart.test.ts +202 -0
  123. package/src/step/planStart.ts +167 -0
  124. package/src/step/planStep.test.ts +104 -0
  125. package/src/step/planStep.ts +89 -0
  126. package/src/step/snapshot.fixture.ts +28 -0
  127. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  128. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  129. package/src/testkit/FakeGitOperations.ts +95 -0
  130. package/src/testkit/GitTiers.test.ts +11 -0
  131. package/src/testkit/GitTiers.ts +1052 -0
  132. package/src/testkit/InMemRepo.ts +467 -0
  133. package/src/testkit/Layers.test.ts +58 -0
  134. package/src/testkit/Layers.ts +241 -0
  135. package/src/testkit/StrictGitOperations.ts +31 -0
  136. package/src/testkit/TestRepo.test.ts +119 -0
  137. package/src/testkit/TestRepo.ts +274 -0
  138. package/src/testkit/cliIo.ts +36 -0
  139. package/src/testkit/index.ts +9 -0
  140. package/src/testkit/steps.ts +177 -0
  141. package/src/testkit/testkit.feature +65 -0
  142. package/src/wire/BeatDocument.test.ts +419 -0
  143. package/src/wire/BeatDocument.ts +114 -0
  144. package/src/wire/BeatPlain.test.ts +113 -0
  145. package/src/wire/BeatPlain.ts +127 -0
  146. package/src/wire/BeatStatus.test.ts +59 -0
  147. package/src/wire/BeatStatus.ts +81 -0
  148. package/src/wire/Demand.test.ts +142 -0
  149. package/src/wire/Demand.ts +108 -0
  150. package/src/wire/ExitCodes.test.ts +21 -0
  151. package/src/wire/ExitCodes.ts +16 -0
  152. package/src/wire/Glob.ts +29 -0
  153. package/src/wire/Judge.ts +22 -0
  154. package/src/wire/Land.test.ts +120 -0
  155. package/src/wire/Land.ts +75 -0
  156. package/src/wire/Select.test.ts +148 -0
  157. package/src/wire/Select.ts +78 -0
  158. package/src/wire/Sessions.test.ts +59 -0
  159. package/src/wire/Sessions.ts +39 -0
  160. package/src/wire/WireSchema.test.ts +162 -0
  161. package/src/wire/constants.ts +22 -0
  162. package/src/wire/golden/1/beat-capture.json +34 -0
  163. package/src/wire/golden/1/beat-message.json +34 -0
  164. package/src/wire/golden/1/beat-prompt.json +44 -0
  165. package/src/wire/golden/1/beat-script.json +34 -0
  166. package/src/wire/golden/1/beat-stalled.json +34 -0
  167. package/src/wire/golden/1/judge.json +14 -0
  168. package/src/wire/golden/1/land-noop.json +10 -0
  169. package/src/wire/golden/1/land.json +10 -0
  170. package/src/wire/index.ts +12 -0
  171. package/src/wire/types.ts +46 -0
  172. package/src/workflow/Diagnostic.test.ts +93 -0
  173. package/src/workflow/Diagnostic.ts +54 -0
  174. package/src/workflow/SteeringFormats.test.ts +27 -0
  175. package/src/workflow/SteeringFormats.ts +6 -0
  176. package/src/workflow/Workflow.ts +77 -0
  177. package/src/workflow/compile.test.ts +53 -0
  178. package/src/workflow/compile.ts +56 -0
  179. package/src/workflow/discovery.ts +122 -0
  180. package/src/workflow/fingerprint.test.ts +52 -0
  181. package/src/workflow/fingerprint.ts +53 -0
  182. package/src/workflow/index.ts +33 -0
  183. package/src/workflow/load.test.ts +1017 -0
  184. package/src/workflow/load.ts +836 -0
  185. package/src/workflow/skills.test.ts +109 -0
  186. package/src/workflow/skills.ts +90 -0
  187. package/src/workflow/vars.ts +33 -0
  188. package/src/workflow-kit/health.test.ts +30 -0
  189. package/src/workflow-kit/health.ts +86 -0
  190. package/src/workflow-kit/index.ts +7 -0
  191. package/src/workflow-kit/quality.test.ts +17 -0
  192. package/src/workflow-kit/quality.ts +45 -0
  193. package/src/workflow-kit/review.ts +324 -0
  194. package/src/{workflows → workflow-kit}/scenarios.ts +21 -28
  195. package/src/workflows/feature/feature.test.ts +32 -0
  196. package/src/workflows/{bundled.ts → feature/feature.ts} +11 -63
  197. package/src/workflows/feature/index.ts +10 -0
  198. package/src/workflows/{packages.test.ts → feature/packages.test.ts} +2 -2
  199. package/src/workflows/{packages.ts → feature/packages.ts} +3 -3
  200. package/src/workflows/{planning.ts → feature/planning.ts} +2 -3
  201. package/src/workflows/{review.test.ts → feature/review.test.ts} +9 -44
  202. package/src/workflows/feature/review.ts +106 -0
  203. package/src/workflows/feature/steps.test.ts +82 -0
  204. package/src/workflows/feature/steps.ts +92 -0
  205. package/src/workflows/fix/fix.feature +80 -0
  206. package/src/workflows/fix/fix.test.ts +74 -0
  207. package/src/workflows/fix/fix.ts +52 -0
  208. package/src/workflows/fix/index.ts +6 -0
  209. package/src/workflows/index.ts +36 -2
  210. package/src/workflows/review/index.ts +6 -0
  211. package/src/workflows/review/review.test.ts +99 -0
  212. package/src/workflows/review/review.ts +33 -0
  213. package/src/workflows/{access.test.ts → shared/access.test.ts} +1 -1
  214. package/src/workflows/{access.ts → shared/access.ts} +3 -5
  215. package/src/workflows/shared/config.fixture.ts +12 -0
  216. package/src/workflows/{health.test.ts → shared/health.test.ts} +1 -1
  217. package/src/workflows/{health.ts → shared/health.ts} +38 -55
  218. package/src/workflows/{options.test.ts → shared/options.test.ts} +2 -2
  219. package/src/workflows/{options.ts → shared/options.ts} +14 -2
  220. package/src/workflows/shared/reserved.ts +11 -0
  221. package/src/workflows/{scenarios.test.ts → shared/scenarios.test.ts} +1 -1
  222. package/src/workflows/shared/scenarios.ts +35 -0
  223. package/src/workflows/{skills.test.ts → shared/skills.test.ts} +3 -1
  224. package/src/workflows/{skills.ts → shared/skills.ts} +7 -6
  225. package/src/workflows/{steps.test.ts → shared/steps.test.ts} +1 -66
  226. package/src/workflows/{steps.ts → shared/steps.ts} +8 -98
  227. package/src/workflows/shared/tail.test.ts +205 -0
  228. package/src/workflows/shared/tail.ts +135 -0
  229. package/src/workflows/{text.fixture.ts → shared/text.fixture.ts} +2 -2
  230. package/src/workflows/{text.ts → shared/text.ts} +48 -7
  231. package/src/workflows/{threads.test.ts → shared/threads.test.ts} +1 -1
  232. package/src/workflows/shared/workflows.steps.ts +7 -0
  233. package/src/workflows/review.ts +0 -362
  234. /package/src/workflows/{prose.ts → shared/prose.ts} +0 -0
  235. /package/src/workflows/{text.test.ts → shared/text.test.ts} +0 -0
  236. /package/src/workflows/{vars.ts → shared/vars.ts} +0 -0
@@ -0,0 +1,1180 @@
1
+ import type { ListItem, Root, RootContent } from "mdast"
2
+ import { blockNodesOfRun, blockRunInline } from "./Blocks.js"
3
+ import type { FootnoteAnchor } from "./Footnotes.js"
4
+ import {
5
+ collapseImages,
6
+ definitionsOf,
7
+ joinInlineRuns,
8
+ projectInline,
9
+ type InlineNode,
10
+ } from "./Inline.js"
11
+ import {
12
+ FOOTNOTE_ACTION_TITLE,
13
+ footnoteAdditionEdits,
14
+ footnoteAttachEdits,
15
+ footnotePointerAt,
16
+ isOnExistingFootnote,
17
+ openThreadsOf,
18
+ threadOutlineNodes,
19
+ threadReplyActions,
20
+ noteLookup,
21
+ parseFootnotes,
22
+ parseThreads,
23
+ } from "./Footnotes.js"
24
+ import {
25
+ blockEndLine,
26
+ blockNodeAt,
27
+ footnoteLeaf,
28
+ headingText,
29
+ lineRange,
30
+ parseMarkdown,
31
+ sourceText,
32
+ spanRange,
33
+ taskItems,
34
+ toLspPosition,
35
+ toLspPositionFromOffset,
36
+ type HeadingBlock,
37
+ } from "./MarkdownTree.js"
38
+ import type {
39
+ SteeringAnchor,
40
+ SteeringAnnotateResult,
41
+ SteeringEdit,
42
+ SteeringFinding,
43
+ SteeringFormat,
44
+ SteeringLink,
45
+ SteeringOutlineNode,
46
+ SteeringView,
47
+ SteeringViewNode,
48
+ } from "./SteeringFormat.js"
49
+
50
+ interface ReviewFile {
51
+ readonly path: string
52
+ /** 1-based post-image line — the range's own start when the pointer carries one, absent entirely for a bare `./path` with no `#` at all. */
53
+ readonly line?: number
54
+ /** 1-based post-image end line of a `#<start>-<end>` range — `line` holds the start. Absent for a bare `#<line>` pointer or a `#`-less one. */
55
+ readonly rangeEnd?: number
56
+ readonly checked: boolean
57
+ /** The pointer's explanation: same-line text (if any) first, then the lines gathered from BELOW it, joined with " ". */
58
+ readonly note?: string
59
+ /** `note`'s own inline structure, image-collapsed (R2) — feeds the hunk node's `detailInline`. Absent exactly where `note` is. */
60
+ readonly noteInline?: readonly InlineNode[]
61
+ /** 0-based line index of this file pointer's own `- [ ]`/`- [x]` line in REVIEW.md, for editor tooling. */
62
+ readonly sourceLine: number
63
+ /** 0-based index of the last line of this pointer's OWN span (excluding any nested hunk's span); equals `sourceLine` when it has no explanation. */
64
+ readonly endLine: number
65
+ }
66
+
67
+ interface Changeset {
68
+ readonly title: string
69
+ readonly description: string
70
+ /** The same leading run collapsed into `description`, projected as block nodes (`Blocks.ts#blockNodesOfRun`) instead of flattened text — `Review.tsx`'s chunk screen renders these through the shared prose-block rendering. */
71
+ readonly descriptionNodes: readonly SteeringViewNode[]
72
+ /** `description`'s own inline structure, image-collapsed (R2) — feeds the chunk node's `detailInline`. */
73
+ readonly descriptionInline: readonly InlineNode[]
74
+ readonly files: readonly ReviewFile[]
75
+ /** 0-based index of this chunk's `##` heading. */
76
+ readonly headingLine: number
77
+ }
78
+
79
+ interface ReviewDoc {
80
+ readonly shortHash?: string
81
+ readonly fullHash?: string
82
+ readonly changesets: readonly Changeset[]
83
+ readonly findings: readonly SteeringFinding[]
84
+ }
85
+
86
+ /**
87
+ * The canonical sample. Every detail here is load-bearing for the formatter
88
+ * round-trip: both footnote bodies exceed 80 characters so a reflow actually
89
+ * happens, the chunk's is `na`-prefixed (server-attached, not hand-authored)
90
+ * and carries a multi-word inline code span, and the whole document is
91
+ * already an oxfmt fixed point.
92
+ */
93
+ const REVIEW_SAMPLE = `# Review: sample123
94
+
95
+ <!-- base: 0000000000000000000000000000000000000000 -->
96
+
97
+ ## Sample chunk[^naduiqc4]
98
+
99
+ - [ ] ./sample.ts#1-1 what this hunk does[^fn1]
100
+
101
+ [^fn1]:
102
+ This note explains why the hunk exists in more detail than fits on one line
103
+ for a reviewer.
104
+
105
+ [^naduiqc4]:
106
+ Attached via the phone UI, this note demonstrates a chunk-level comment with
107
+ a \`multi word code span\` that exceeds eighty characters in total length
108
+ here.
109
+ `
110
+
111
+ /** The `# Review: <hash>` header, once a depth-1 heading's own inline text has been extracted. */
112
+ const HEADER_TEXT_RE = /^Review:\s*(\S+)$/
113
+ /** The `<!-- base: <hash> -->` comment, matched against an `html` node's own raw `value`. */
114
+ const BASE_COMMENT_RE = /^<!--\s*base:\s*(\S+)\s*-->$/
115
+ /** A pointer token's trailing `#<start>-<end>` range (greedy, so a `#` inside the path stays in it, and the LAST `#` wins). */
116
+ const POINTER_RANGE_RE = /^(.*)#(\d+)-(\d+)$/
117
+ /** A pointer token's trailing bare `#<line>` (greedy, same rules as `POINTER_RANGE_RE`) — tried only once the range form has failed to match, so a range token is never misread as a bare line whose path happens to end in digits after a dash. */
118
+ const POINTER_LINE_RE = /^(.*)#(\d+)$/
119
+ /** The optional dash that may lead the inline note segment or a continuation block — em dash, en dash, or hyphens. */
120
+ const NOTE_SEPARATOR_RE = /^[—–-]+\s*/
121
+
122
+ /** A node's own span as a `SteeringFinding.range` — the NODE a heading- or block-shaped finding is about. */
123
+ const nodeRange = (node: RootContent) => ({
124
+ start: toLspPosition(node.position!.start),
125
+ end: toLspPosition(node.position!.end),
126
+ })
127
+
128
+ /** The `# Review: <hash>` header, required as the document's first top-level node — never a heading found merely somewhere inside a fenced example. */
129
+ const parseHeader = (tree: Root, content: string): string | undefined => {
130
+ const first = tree.children[0]
131
+ if (!first || first.type !== "heading" || first.depth !== 1) return undefined
132
+ return HEADER_TEXT_RE.exec(headingText(content, first))?.[1]
133
+ }
134
+
135
+ /** A node shape wide enough to walk for an `html` node's raw text, at any depth. */
136
+ interface WalkableNode {
137
+ readonly type: string
138
+ readonly value?: string
139
+ readonly children?: readonly WalkableNode[]
140
+ }
141
+
142
+ /** The `<!-- base: <hash> -->` comment, wherever in the tree it appears — a real `html` node, never a string search over raw lines (which would also match one quoted inside a fence, since that content never becomes an `html` node at all). */
143
+ const parseBaseComment = (tree: Root): string | undefined => {
144
+ let found: string | undefined
145
+ const walk = (node: WalkableNode): void => {
146
+ if (found !== undefined) return
147
+ if (node.type === "html") {
148
+ const match = BASE_COMMENT_RE.exec((node.value ?? "").trim())
149
+ if (match) {
150
+ found = match[1]
151
+ return
152
+ }
153
+ }
154
+ node.children?.forEach(walk)
155
+ }
156
+ walk(tree as unknown as WalkableNode)
157
+ return found
158
+ }
159
+
160
+ /**
161
+ * The single source of truth for splitting a pointer token, so no caller can
162
+ * parse the same token two different ways. Range is tried FIRST — the
163
+ * bare-line regex could not match `#42-70` anyway, but the explicit order
164
+ * beats relying on that accident.
165
+ */
166
+ const parsePointerToken = (
167
+ token: string,
168
+ ): {
169
+ readonly path: string
170
+ readonly rangeEnd?: number
171
+ readonly line?: number
172
+ } => {
173
+ const rangeMatch = POINTER_RANGE_RE.exec(token)
174
+ if (rangeMatch) {
175
+ return {
176
+ path: rangeMatch[1]!,
177
+ line: Number(rangeMatch[2]),
178
+ rangeEnd: Number(rangeMatch[3]),
179
+ }
180
+ }
181
+ const lineMatch = POINTER_LINE_RE.exec(token)
182
+ if (lineMatch) return { path: lineMatch[1]!, line: Number(lineMatch[2]) }
183
+ return { path: token }
184
+ }
185
+
186
+ /** The pointer's own display form — `path#start-end` when it carries a range, `path#line` for a bare line, else the bare path. Shared by the second-pointer finding's target and `reviewView`'s hunk title so the two can never render a range pointer differently. */
187
+ const pointerDisplay = (file: {
188
+ readonly path: string
189
+ readonly rangeEnd?: number
190
+ readonly line?: number
191
+ }): string => {
192
+ if (file.line !== undefined && file.rangeEnd !== undefined) {
193
+ return `${file.path}#${file.line}-${file.rangeEnd}`
194
+ }
195
+ return file.line !== undefined ? `${file.path}#${file.line}` : file.path
196
+ }
197
+
198
+ /** True when `token` parses as a file pointer's path token — leading `./`, and a path longer than two characters once an optional `#<line>`/`#<start>-<end>` suffix is stripped. The single source of truth for "is this a pointer token", shared by `parseHunk`, the second-pointer finding below, and `reviewClearTicks` so all three can never drift apart. */
199
+ const isPointerToken = (token: string): boolean => {
200
+ if (!token.startsWith("./")) return false
201
+ return parsePointerToken(token).path.length > 2 // `./` with nothing after it is not a path
202
+ }
203
+
204
+ /**
205
+ * The second-pointer validation error: a note whose first token is itself a
206
+ * pointer token. The relaxed same-line form would otherwise silently read
207
+ * `- [ ] ./a.ts#1 ./b.ts#2` (or with a separator, `- [ ] ./a.ts#1 — ./b.ts#2`)
208
+ * as ONE pointer whose note is `./b.ts#2`, dropping the second hunk entirely —
209
+ * this is the one case the relaxed parser must still refuse, and it points at
210
+ * the pointer's own `sourceLine` so it renders as a positioned finding.
211
+ */
212
+ const secondPointerError = (
213
+ range: SteeringFinding["range"],
214
+ title: string,
215
+ file: ReviewFile,
216
+ secondToken: string,
217
+ ): SteeringFinding => {
218
+ const target = pointerDisplay(file)
219
+ return {
220
+ message: `Chunk "${title}" hunk ${target}'s note starts with a second pointer (${secondToken}) — give it its own "- [ ]" line`,
221
+ line: file.sourceLine,
222
+ ...(range ? { range } : {}),
223
+ }
224
+ }
225
+
226
+ /** The source OFFSET right after an item's `- [ ]`/`- [x]` marker — the first inline child of the item's first paragraph, never the paragraph node's own (possibly stale) position. `undefined` when the item has no paragraph, or an empty one. */
227
+ const firstParagraphContentOffset = (item: ListItem): number | undefined => {
228
+ const paragraph = item.children.find((c) => c.type === "paragraph")
229
+ return paragraph?.children[0]?.position?.start.offset
230
+ }
231
+
232
+ /**
233
+ * The text trailing the pointer token on the item's OWN source line — never
234
+ * a later, merely wrapped-onto-the-item's-first-paragraph line. This is what
235
+ * the second-pointer check must scope itself to: a below-pointer explanation
236
+ * that happens to open with a path (`./src/foo.ts is the caller`, on the
237
+ * NEXT physical line) is not the same thing as `- [ ] ./a.ts#1 ./b.ts#2` (two
238
+ * pointers crammed onto ONE line), even though both collapse to the same
239
+ * joined `restOfParagraph` string once whitespace is normalized.
240
+ */
241
+ const hunkInlineSegment = (
242
+ content: string,
243
+ lines: readonly string[],
244
+ item: ListItem,
245
+ token: string,
246
+ sourceLine: number,
247
+ ): string => {
248
+ const contentOffset = firstParagraphContentOffset(item)
249
+ const contentPos =
250
+ contentOffset !== undefined ? toLspPositionFromOffset(content, contentOffset) : undefined
251
+ const sameLineRaw =
252
+ contentPos && contentPos.line === sourceLine
253
+ ? (lines[sourceLine] ?? "").slice(contentPos.character).trim()
254
+ : ""
255
+ return sameLineRaw.startsWith(token)
256
+ ? sameLineRaw.slice(token.length).trim().replace(NOTE_SEPARATOR_RE, "")
257
+ : ""
258
+ }
259
+
260
+ /** A hunk's composed note: the paragraph's own trailing text (same line and/or lazily wrapped, already joined and whitespace-collapsed by `sourceText`) first, then every further sibling block of the item — EXCLUDING a nested `list` (a nested hunk's own span) and a `footnoteDefinition` — each with its own leading dash separator stripped, joined by a single space. */
261
+ const hunkNote = (
262
+ content: string,
263
+ item: ListItem,
264
+ paragraph: RootContent,
265
+ restOfParagraph: string,
266
+ ): string => {
267
+ const otherChildren = item.children.filter(
268
+ (c) => c !== paragraph && c.type !== "list" && c.type !== "footnoteDefinition",
269
+ )
270
+ const segments = [
271
+ restOfParagraph.replace(NOTE_SEPARATOR_RE, ""),
272
+ ...otherChildren.map((c) => sourceText(content, c).replace(NOTE_SEPARATOR_RE, "")),
273
+ ].filter((s) => s.length > 0)
274
+ return segments.join(" ").trim()
275
+ }
276
+
277
+ /** `nodes` with `NOTE_SEPARATOR_RE` stripped off its own FIRST text node's value, mirroring `hunkNote`'s identical string-side stripping — a no-op when `nodes` doesn't open with a `text` node. */
278
+ const stripLeadingSeparatorInline = (nodes: readonly InlineNode[]): readonly InlineNode[] => {
279
+ const first = nodes[0]
280
+ if (first === undefined || first.kind !== "text") return nodes
281
+ return [
282
+ { kind: "text" as const, value: first.value.replace(NOTE_SEPARATOR_RE, "") },
283
+ ...nodes.slice(1),
284
+ ]
285
+ }
286
+
287
+ /** `paragraph`'s own inline content strictly AFTER raw source offset `afterOffset` — the structural counterpart of `restOfParagraph`'s string slice: keeps every child entirely past the cut, trims the one child that straddles it (only when it's plain `text`; a straddling non-text child is dropped, the one edge case a token followed immediately by styled text hits), and trims the result's own leading whitespace. `undefined`/`[]` for anything that isn't a real `paragraph`. */
288
+ const trailingParagraphInline = (
289
+ content: string,
290
+ paragraph: RootContent,
291
+ afterOffset: number,
292
+ ): readonly InlineNode[] => {
293
+ if (paragraph.type !== "paragraph") return []
294
+ const kept = paragraph.children.flatMap((child) => {
295
+ const start = child.position?.start.offset
296
+ const end = child.position?.end.offset
297
+ if (start === undefined || end === undefined || end <= afterOffset) return []
298
+ if (start >= afterOffset) return [child]
299
+ if (child.type !== "text") return []
300
+ return [{ ...child, value: child.value.slice(afterOffset - start) }]
301
+ })
302
+ const projected = projectInline(kept, definitionsOf(content))
303
+ const first = projected[0]
304
+ if (first === undefined || first.kind !== "text") return projected
305
+ return [{ kind: "text" as const, value: first.value.trimStart() }, ...projected.slice(1)]
306
+ }
307
+
308
+ /** `note`'s own inline counterpart, mirroring `hunkNote` structurally: `paragraph`'s own trailing inline content (past the pointer token) first, then every further sibling block's own `nodeInline`-equivalent projection, joined and image-collapsed (R2). `tokenEndOffset` is the raw source offset right after the pointer token itself. */
309
+ const hunkNoteInline = (
310
+ content: string,
311
+ item: ListItem,
312
+ paragraph: RootContent,
313
+ tokenEndOffset: number,
314
+ ): readonly InlineNode[] => {
315
+ const otherChildren = item.children.filter(
316
+ (c) => c !== paragraph && c.type !== "list" && c.type !== "footnoteDefinition",
317
+ )
318
+ const trailing = stripLeadingSeparatorInline(
319
+ trailingParagraphInline(content, paragraph, tokenEndOffset),
320
+ )
321
+ const runs = [
322
+ trailing,
323
+ ...otherChildren.map((c) => stripLeadingSeparatorInline(blockRunInline(content, [c]))),
324
+ ]
325
+ return collapseImages(joinInlineRuns(runs))
326
+ }
327
+
328
+ /** The last line of a hunk's OWN span — every non-`list` child's own end line, at most — excluding a nested hunk's span entirely, so a parent's span never swallows it (for "add a footnote" placement, or for matching which hunk a cursor sits on). */
329
+ const hunkOwnEndLine = (item: ListItem, sourceLine: number): number => {
330
+ const nonListChildren = item.children.filter((c) => c.type !== "list")
331
+ if (nonListChildren.length === 0) return sourceLine
332
+ return Math.max(...nonListChildren.map((c) => toLspPosition(c.position!.end).line))
333
+ }
334
+
335
+ /** Builds one hunk's `ReviewFile` from its already-located pointer `token` and first `paragraph`. */
336
+ const buildHunkFile = (
337
+ content: string,
338
+ item: ListItem,
339
+ paragraph: RootContent,
340
+ token: string,
341
+ sourceLine: number,
342
+ ): ReviewFile => {
343
+ const parsed = parsePointerToken(token)
344
+ const restOfParagraph = sourceText(content, paragraph).slice(token.length).trim()
345
+ const note = hunkNote(content, item, paragraph, restOfParagraph)
346
+ const contentOffset = firstParagraphContentOffset(item)
347
+ const noteInline =
348
+ contentOffset !== undefined
349
+ ? hunkNoteInline(content, item, paragraph, contentOffset + token.length)
350
+ : []
351
+ return {
352
+ path: parsed.path,
353
+ ...(parsed.line !== undefined ? { line: parsed.line } : {}),
354
+ ...(parsed.rangeEnd !== undefined ? { rangeEnd: parsed.rangeEnd } : {}),
355
+ checked: item.checked === true,
356
+ sourceLine,
357
+ endLine: hunkOwnEndLine(item, sourceLine),
358
+ ...(note.length > 0 ? { note, noteInline } : {}),
359
+ }
360
+ }
361
+
362
+ /** The pointer token's own `[start, end)` range on `sourceLine` — the token's first paragraph content offset, extended by its own length. `undefined` only when the item has no resolvable paragraph offset (kept total; never happens for a real hunk item). Shared by `hunkLinkFor`'s document-link range and the line-without-a-range finding's range so the two can never disagree about what the token spans. */
363
+ const pointerTokenRange = (
364
+ content: string,
365
+ item: ListItem,
366
+ token: string,
367
+ ): SteeringFinding["range"] => {
368
+ const offset = firstParagraphContentOffset(item)
369
+ if (offset === undefined) return undefined
370
+ const start = toLspPositionFromOffset(content, offset)
371
+ const end = toLspPositionFromOffset(content, offset + token.length)
372
+ return { start, end }
373
+ }
374
+
375
+ /**
376
+ * The "line but no range" finding: a pointer whose token carries a bare
377
+ * `#<line>` (no `#<start>-<end>`) is a validation finding, never a silent
378
+ * whole-file pointer — the human chose enforcement over instruction. Absent
379
+ * for a range pointer (`file.rangeEnd` set) and for a `#`-less pointer
380
+ * (`file.line` absent entirely).
381
+ */
382
+ const hunkLineWithoutRangeFinding = (
383
+ content: string,
384
+ item: ListItem,
385
+ token: string,
386
+ sourceLine: number,
387
+ title: string,
388
+ file: ReviewFile,
389
+ ): SteeringFinding | undefined => {
390
+ if (file.rangeEnd !== undefined || file.line === undefined) return undefined
391
+ const target = pointerDisplay(file)
392
+ const range = pointerTokenRange(content, item, token)
393
+ return {
394
+ message: `Chunk "${title}" hunk ${target} carries a line but no range — write "#${file.line}-<end>"`,
395
+ line: sourceLine,
396
+ ...(range ? { range } : {}),
397
+ }
398
+ }
399
+
400
+ /** The second pointer token's own `[start, end)` character range on `sourceLine` — found by searching forward from right after the FIRST token, so an earlier, coincidentally-matching substring can never be picked up. `undefined` only if the token somehow isn't found (never happens for a real match; kept total). */
401
+ const secondTokenRange = (
402
+ lines: readonly string[],
403
+ sourceLine: number,
404
+ firstTokenEnd: number,
405
+ secondToken: string,
406
+ ): SteeringFinding["range"] => {
407
+ const raw = lines[sourceLine] ?? ""
408
+ const start = raw.indexOf(secondToken, firstTokenEnd)
409
+ if (start === -1) return undefined
410
+ return {
411
+ start: { line: sourceLine, character: start },
412
+ end: { line: sourceLine, character: start + secondToken.length },
413
+ }
414
+ }
415
+
416
+ /** The second-pointer finding for one hunk, or `undefined` when its inline (same-line) segment doesn't itself open with a pointer token. */
417
+ const hunkSecondPointerFinding = (
418
+ content: string,
419
+ lines: readonly string[],
420
+ item: ListItem,
421
+ token: string,
422
+ sourceLine: number,
423
+ title: string,
424
+ file: ReviewFile,
425
+ ): SteeringFinding | undefined => {
426
+ const inlineSegment = hunkInlineSegment(content, lines, item, token, sourceLine)
427
+ const secondToken = inlineSegment.split(/\s+/)[0] ?? ""
428
+ if (!isPointerToken(secondToken)) return undefined
429
+ const contentOffset = firstParagraphContentOffset(item)
430
+ const contentPos =
431
+ contentOffset !== undefined ? toLspPositionFromOffset(content, contentOffset) : undefined
432
+ const firstTokenEnd = (contentPos?.character ?? 0) + token.length
433
+ const range = secondTokenRange(lines, sourceLine, firstTokenEnd, secondToken)
434
+ return secondPointerError(range, title, file, secondToken)
435
+ }
436
+
437
+ /** The first word of `item`'s first paragraph, whichever comes first. `undefined` when `item` has no paragraph, or an empty one. */
438
+ const firstParagraphToken = (content: string, item: ListItem): string | undefined => {
439
+ const paragraph = item.children.find((c) => c.type === "paragraph")
440
+ if (!paragraph) return undefined
441
+ return sourceText(content, paragraph)
442
+ .split(/\s+/)
443
+ .find((w) => w.length > 0)
444
+ }
445
+
446
+ /** True when `item`'s first paragraph's first word is a pointer token — the single "is this list item a hunk pointer" test, shared by `parseHunk` and `parseChunkBody` so the parse and the description can never disagree about what counts as a pointer. */
447
+ const hasPointerToken = (content: string, item: ListItem): boolean => {
448
+ const token = firstParagraphToken(content, item)
449
+ return token !== undefined && isPointerToken(token)
450
+ }
451
+
452
+ /** One pointer's parse result: its `ReviewFile`, plus the second-pointer finding when its inline (same-line) segment itself opens with a pointer token. `undefined` when `item`'s first paragraph's first word isn't a pointer token at all — a real task-list item whose content isn't a hunk pointer. */
453
+ const parseHunk = (
454
+ content: string,
455
+ lines: readonly string[],
456
+ title: string,
457
+ item: ListItem,
458
+ ): { readonly file: ReviewFile; readonly errors: readonly SteeringFinding[] } | undefined => {
459
+ const paragraph = item.children.find((c) => c.type === "paragraph")
460
+ if (!paragraph || !item.position) return undefined
461
+ if (!hasPointerToken(content, item)) return undefined
462
+
463
+ const token = firstParagraphToken(content, item)!
464
+ const sourceLine = toLspPosition(item.position.start).line
465
+ const file = buildHunkFile(content, item, paragraph, token, sourceLine)
466
+ const errors = [
467
+ hunkSecondPointerFinding(content, lines, item, token, sourceLine, title, file),
468
+ hunkLineWithoutRangeFinding(content, item, token, sourceLine, title, file),
469
+ ].filter((e): e is SteeringFinding => e !== undefined)
470
+ return { file, errors }
471
+ }
472
+
473
+ /** Splits one chunk's body nodes into its file pointers (hunk pointers are task items, collected recursively at ANY nesting depth via `taskItems` — a nested hunk is the same kind of hunk as a top-level one) and description prose. The description is the chunk's own leading run and never a node that contains a hunk pointer: it stops at the first node whose `taskItems` include a real pointer (at any depth — a blockquote wrapping a pointer list stops it just as a top-level list would). Every node kind in what's left before that point contributes — a `footnoteDefinition` is the one exception, excluded by `blockNodesOfRun` itself (it's a note, never document content). */
474
+ const parseChunkBody = (
475
+ content: string,
476
+ lines: readonly string[],
477
+ title: string,
478
+ body: readonly RootContent[],
479
+ ): {
480
+ readonly description: string
481
+ readonly descriptionNodes: readonly SteeringViewNode[]
482
+ readonly descriptionInline: readonly InlineNode[]
483
+ readonly files: readonly ReviewFile[]
484
+ readonly errors: readonly SteeringFinding[]
485
+ } => {
486
+ const firstPointerIndex = body.findIndex((n) =>
487
+ taskItems(n).some((item) => hasPointerToken(content, item)),
488
+ )
489
+ const leadingRun = firstPointerIndex === -1 ? body : body.slice(0, firstPointerIndex)
490
+ const descriptionNodes = blockNodesOfRun(content, leadingRun)
491
+ const descriptionInline = collapseImages(blockRunInline(content, leadingRun))
492
+ const description = leadingRun
493
+ .filter((n) => n.type !== "footnoteDefinition")
494
+ .map((n) => sourceText(content, n))
495
+ .join(" ")
496
+ .trim()
497
+
498
+ const files: ReviewFile[] = []
499
+ const errors: SteeringFinding[] = []
500
+ for (const item of body.flatMap((n) => taskItems(n))) {
501
+ const parsed = parseHunk(content, lines, title, item)
502
+ if (!parsed) continue
503
+ files.push(parsed.file)
504
+ errors.push(...parsed.errors)
505
+ }
506
+ return { description, descriptionNodes, descriptionInline, files, errors }
507
+ }
508
+
509
+ /** One `##` chunk heading node with its raw body block nodes (up to the next `##`-or-shallower heading). */
510
+ interface ChunkBlock extends HeadingBlock {
511
+ readonly headingLine: number
512
+ }
513
+
514
+ /** Splits the tree's top-level nodes into consecutive `##` chunk blocks — a `## ` line found only inside a fence never becomes a heading node at all, so it's never mistaken for a chunk. */
515
+ const splitChunks = (tree: Root): readonly ChunkBlock[] => {
516
+ const blocks: ChunkBlock[] = []
517
+ let i = 0
518
+ while (i < tree.children.length) {
519
+ const node = tree.children[i]!
520
+ if (node.type !== "heading" || node.depth !== 2) {
521
+ i += 1
522
+ continue
523
+ }
524
+ const heading = node
525
+ const headingLine = toLspPosition(heading.position!.start).line
526
+ i += 1
527
+ const body: RootContent[] = []
528
+ while (i < tree.children.length) {
529
+ const next = tree.children[i]!
530
+ if (next.type === "heading" && next.depth <= 2) break
531
+ body.push(next)
532
+ i += 1
533
+ }
534
+ blocks.push({ heading, headingLine, body })
535
+ }
536
+ return blocks
537
+ }
538
+
539
+ const parseChangesets = (
540
+ tree: Root,
541
+ content: string,
542
+ lines: readonly string[],
543
+ ): { readonly changesets: readonly Changeset[]; readonly errors: readonly SteeringFinding[] } => {
544
+ const changesets: Changeset[] = []
545
+ const errors: SteeringFinding[] = []
546
+ for (const { heading, headingLine, body } of splitChunks(tree)) {
547
+ const title = headingText(content, heading)
548
+ const {
549
+ description,
550
+ descriptionNodes,
551
+ descriptionInline,
552
+ files,
553
+ errors: bodyErrors,
554
+ } = parseChunkBody(content, lines, title, body)
555
+ errors.push(...bodyErrors)
556
+ if (files.length === 0) {
557
+ errors.push({
558
+ message: `Chunk "${title}" has no file pointers`,
559
+ line: headingLine,
560
+ range: nodeRange(heading),
561
+ })
562
+ }
563
+ changesets.push({ title, description, descriptionNodes, descriptionInline, files, headingLine })
564
+ }
565
+ if (changesets.length === 0) errors.push({ message: "REVIEW.md has no '##' chunks" })
566
+ return { changesets, errors }
567
+ }
568
+
569
+ /**
570
+ * The missing-header finding: "the wrong first block node" — `tree.children[0]`
571
+ * — is the node this finding is ABOUT, so its range spans that node. An empty
572
+ * document (no first node at all) has no token to point at, so the finding
573
+ * stays positionless in that one case.
574
+ */
575
+ const missingHeaderFinding = (tree: Root): SteeringFinding => {
576
+ const first = tree.children[0]
577
+ const message = "Missing or malformed '# Review: <hash>' header as the document's first line"
578
+ if (first === undefined || !first.position) return { message }
579
+ return { message, line: toLspPosition(first.position.start).line, range: nodeRange(first) }
580
+ }
581
+
582
+ /**
583
+ * Parses the review structure out of `content` (the raw text of
584
+ * `.gtd/REVIEW.md`). Total and side-effect-free: always returns a result,
585
+ * never throws — `parseMarkdown`'s `fromMarkdown` never does either.
586
+ * `findings` is non-empty exactly when the document violates the required
587
+ * structure — the caller decides what to do with that (`gtd validate` exits
588
+ * non-zero with them; the LSP publishes them as diagnostics). The one parse
589
+ * function: there is no second one routing around this return type.
590
+ */
591
+ export const parseReviewDoc = (content: string): ReviewDoc => {
592
+ const tree = parseMarkdown(content)
593
+ const lines = content.split(/\r?\n/)
594
+ const shortHash = parseHeader(tree, content)
595
+ const fullHash = parseBaseComment(tree)
596
+ const { changesets, errors: chunkFindings } = parseChangesets(tree, content, lines)
597
+
598
+ const findings: SteeringFinding[] = [
599
+ ...(shortHash ? [] : [missingHeaderFinding(tree)]),
600
+ // A missing base comment has no offending token to point at — stays positionless.
601
+ ...(fullHash ? [] : [{ message: "Missing '<!-- base: <hash> -->' comment" }]),
602
+ ...chunkFindings,
603
+ ]
604
+
605
+ return {
606
+ ...(shortHash ? { shortHash } : {}),
607
+ ...(fullHash ? { fullHash } : {}),
608
+ changesets,
609
+ findings,
610
+ }
611
+ }
612
+
613
+ /** The character offset of a checked task item's own box character (`x`/`X`) — the first `[` at or after the item's own start offset, bounded by (before) its content's start offset, mirroring how a checkbox toggle resolves the same offset. `undefined` when the item carries no resolvable box (never happens for a real GFM task item, but kept total). */
614
+ const checkboxOffset = (content: string, item: ListItem): number | undefined => {
615
+ if (!item.position) return undefined
616
+ const startOffset = item.position.start.offset
617
+ if (startOffset === undefined) return undefined
618
+ const boundOffset = firstParagraphContentOffset(item) ?? item.position.end.offset!
619
+ const bracketOffset = content.indexOf("[", startOffset)
620
+ if (bracketOffset === -1 || bracketOffset >= boundOffset) return undefined
621
+ return bracketOffset + 1
622
+ }
623
+
624
+ /** True when `item`'s first paragraph's first word is itself a hunk pointer token — the scope `reviewClearTicks` clears, so a `qa`-shaped checked option (never a pointer token) is untouched even if content in the wrong shape is fed to it. */
625
+ const isHunkPointerItem = (content: string, item: ListItem): boolean => {
626
+ const paragraph = item.children.find((c) => c.type === "paragraph")
627
+ if (!paragraph) return false
628
+ const token = sourceText(content, paragraph)
629
+ .split(/\s+/)
630
+ .find((w) => w.length > 0)
631
+ return token !== undefined && isPointerToken(token)
632
+ }
633
+
634
+ /**
635
+ * Resets every checked HUNK POINTER back to `- [ ]` — ticks are read-progress,
636
+ * never sign-off. Scoped to real hunk pointers, never "every checked task
637
+ * item": that looser scope would strip a `qa` answer's tick if this ever ran
638
+ * against the wrong file.
639
+ *
640
+ * A byte-preserving OFFSET SPLICE, never a reserialization — `split`/`join`
641
+ * normalizes CRLF and would turn a tick-only round into a whole-file diff.
642
+ * Total and idempotent, and returns `content` ITSELF when there is nothing to
643
+ * clear, so a caller can skip the write and leave the mtime untouched.
644
+ */
645
+ const reviewClearTicks = (content: string): string => {
646
+ const tree = parseMarkdown(content)
647
+ const offsets = taskItems(tree)
648
+ .filter((item) => item.checked === true && isHunkPointerItem(content, item))
649
+ .map((item) => checkboxOffset(content, item))
650
+ .filter((o): o is number => o !== undefined)
651
+ .sort((a, b) => a - b)
652
+ if (offsets.length === 0) return content
653
+
654
+ let result = ""
655
+ let cursor = 0
656
+ for (const offset of offsets) {
657
+ result += content.slice(cursor, offset) + " "
658
+ cursor = offset + 1
659
+ }
660
+ result += content.slice(cursor)
661
+ return result
662
+ }
663
+
664
+ /** Flips the `[ ]`/`[x]` box of the hunk pointer task item starting at `line`, preserving path/note text exactly. `undefined` when `line` isn't a real task item's own start line, or its content isn't a valid hunk pointer. */
665
+ const toggleFilePointer = (content: string, line: number): SteeringEdit | undefined => {
666
+ const tree = parseMarkdown(content)
667
+ const item = taskItems(tree).find((it) => toLspPosition(it.position!.start).line === line)
668
+ if (!item?.position) return undefined
669
+
670
+ const paragraph = item.children.find((c) => c.type === "paragraph")
671
+ const firstWord = paragraph ? sourceText(content, paragraph).split(/\s+/)[0] : undefined
672
+ if (!firstWord || !isPointerToken(firstWord)) return undefined
673
+
674
+ const offset = checkboxOffset(content, item)
675
+ if (offset === undefined) return undefined
676
+ const position = toLspPositionFromOffset(content, offset)
677
+ return {
678
+ range: { start: position, end: { line: position.line, character: position.character + 1 } },
679
+ newText: item.checked === true ? " " : "x",
680
+ }
681
+ }
682
+
683
+ /** Every chunk heading's own line → its outline range's real end line (`blockEndLine`) — the node-boundary replacement for the old "next chunk's heading minus one" guess. */
684
+ const chunkEndLines = (content: string): ReadonlyMap<number, number> => {
685
+ const tree = parseMarkdown(content)
686
+ const map = new Map<number, number>()
687
+ for (const block of splitChunks(tree)) map.set(block.headingLine, blockEndLine(block))
688
+ return map
689
+ }
690
+
691
+ /**
692
+ * Document outline for `.gtd/REVIEW.md`: the headlines of chunks (the
693
+ * user-facing "work packages") that still carry at least one unchecked hunk
694
+ * — the outline is the list of packages left to review — PLUS any chunk
695
+ * that carries at least one footnote, even when every hunk in it is ticked:
696
+ * `reviewOutline` otherwise drops a fully-checked chunk, which would make a
697
+ * human's own comment vanish the moment they tick the last box. A chunk's
698
+ * footnotes are `leaf: true` children; a chunk with none has no children.
699
+ */
700
+ const reviewOutline = (content: string): readonly SteeringOutlineNode[] => {
701
+ const { changesets } = parseReviewDoc(content)
702
+ const { markers, definitions } = parseFootnotes(content)
703
+ const definitionByName = new Map(definitions.map((d) => [d.name, d.body]))
704
+ const lines = content.split(/\r?\n/)
705
+ const endLines = chunkEndLines(content)
706
+ const threads = threadOutlineNodes(content)
707
+ const nested = new Set<SteeringOutlineNode>()
708
+ const nodes = changesets
709
+ .map((chunk) => {
710
+ const start = chunk.headingLine
711
+ const end = Math.max(start, endLines.get(start) ?? start)
712
+ const checkedCount = chunk.files.filter((file) => file.checked).length
713
+ const chunkMarkers = markers.filter((m) => m.line >= start && m.line <= end)
714
+ const chunkThreads = threads
715
+ .filter((t) => t.markerLine !== undefined && t.markerLine >= start && t.markerLine <= end)
716
+ .map((t) => t.node)
717
+ chunkThreads.forEach((n) => nested.add(n))
718
+ const children = [
719
+ ...chunkMarkers.map((m) => footnoteLeaf(lines, definitionByName, m)),
720
+ ...chunkThreads,
721
+ ]
722
+ return {
723
+ name: `${chunk.title} (${checkedCount}/${chunk.files.length})`,
724
+ range: spanRange(lines, start, end),
725
+ selectionRange: lineRange(lines, start),
726
+ ...(children.length > 0 ? { children } : {}),
727
+ unchecked: checkedCount < chunk.files.length || chunkMarkers.length > 0,
728
+ }
729
+ })
730
+ .filter((node) => node.unchecked)
731
+ .map(({ unchecked: _unchecked, ...node }) => node)
732
+ return [...nodes, ...threads.map((t) => t.node).filter((n) => !nested.has(n))]
733
+ }
734
+
735
+ /**
736
+ * The target state a whole-chunk toggle drives every hunk to: `true` (check
737
+ * all) unless a strict majority are already checked, in which case `false`
738
+ * (uncheck all) — a chunk with no strict majority either way (including an
739
+ * even split) defaults to checking, so it's never a meaningless no-op on a
740
+ * chunk that's already uniform.
741
+ */
742
+ const chunkToggleTarget = (checkedCount: number, total: number): boolean =>
743
+ checkedCount * 2 <= total
744
+
745
+ /** Toggles every hunk in the chunk headed at `headingLine` to a single target state (`chunkToggleTarget`) — one edit per hunk not already at the target state, none when the chunk is already uniform. */
746
+ const toggleChunkEdits = (content: string, headingLine: number): SteeringEdit[] => {
747
+ const { changesets } = parseReviewDoc(content)
748
+ const chunk = changesets.find((c) => c.headingLine === headingLine)
749
+ if (!chunk || chunk.files.length === 0) return []
750
+ const checkedCount = chunk.files.filter((file) => file.checked).length
751
+ const target = chunkToggleTarget(checkedCount, chunk.files.length)
752
+ const edits: SteeringEdit[] = []
753
+ for (const file of chunk.files) {
754
+ if (file.checked === target) continue
755
+ const edit = toggleFilePointer(content, file.sourceLine)
756
+ if (edit) edits.push(edit)
757
+ }
758
+ return edits
759
+ }
760
+
761
+ /**
762
+ * The block a footnote lands after when "add a footnote" fires with the
763
+ * cursor at `cursorLine`: the containing hunk's own span (`ReviewFile.endLine`
764
+ * — never a nested hunk's own span, since a parent hunk's `endLine` excludes
765
+ * it) when the cursor sits in one, otherwise the surrounding top-level block
766
+ * node's own end line (`blockNodeAt`, from `MarkdownTree.ts`).
767
+ */
768
+ const footnoteBlockEnd = (content: string, tree: Root, cursorLine: number): number => {
769
+ const { changesets } = parseReviewDoc(content)
770
+ const hunk = changesets
771
+ .flatMap((chunk) => chunk.files)
772
+ .find((file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine)
773
+ if (hunk) return hunk.endLine
774
+ const block = blockNodeAt(tree, cursorLine)
775
+ return block?.position ? toLspPosition(block.position.end).line : cursorLine
776
+ }
777
+
778
+ /**
779
+ * Actions for `.gtd/REVIEW.md`: "check/uncheck this hunk" when `range` sits
780
+ * on a hunk line, "check/uncheck all hunks" when `range` sits anywhere in a
781
+ * chunk (heading or body), and "add a footnote" LAST, after every hunk/chunk
782
+ * action, everywhere EXCEPT inside an existing marker's span or on an
783
+ * existing definition's own line — planting a new marker/definition there
784
+ * would corrupt the footnote already written.
785
+ */
786
+ const reviewActions: SteeringFormat["actions"] = (content, range) => {
787
+ const { changesets } = parseReviewDoc(content)
788
+ const tree = parseMarkdown(content)
789
+ const lines = content.split(/\r?\n/)
790
+ const cursorLine = range.start.line
791
+ const actions: Array<{ readonly title: string; readonly edits: readonly SteeringEdit[] }> = [
792
+ ...threadReplyActions(content, range.start),
793
+ ]
794
+
795
+ // fallow-ignore-next-line complexity
796
+ changesets.forEach((chunk, i) => {
797
+ const hunk = chunk.files.find(
798
+ (file) => cursorLine >= file.sourceLine && cursorLine <= file.endLine,
799
+ )
800
+ if (hunk) {
801
+ const edit = toggleFilePointer(content, hunk.sourceLine)
802
+ if (edit) {
803
+ actions.push({
804
+ title: hunk.checked ? "gtd: uncheck this hunk" : "gtd: check this hunk",
805
+ edits: [edit],
806
+ })
807
+ }
808
+ }
809
+
810
+ const chunkEnd = Math.max(
811
+ chunk.headingLine,
812
+ (changesets[i + 1]?.headingLine ?? lines.length) - 1,
813
+ )
814
+ if (chunk.files.length > 0 && cursorLine >= chunk.headingLine && cursorLine <= chunkEnd) {
815
+ const edits = toggleChunkEdits(content, chunk.headingLine)
816
+ if (edits.length > 0) {
817
+ const checkedCount = chunk.files.filter((file) => file.checked).length
818
+ const willCheck = chunkToggleTarget(checkedCount, chunk.files.length)
819
+ actions.push({
820
+ title: willCheck
821
+ ? `gtd: check all hunks in "${chunk.title}"`
822
+ : `gtd: uncheck all hunks in "${chunk.title}"`,
823
+ edits,
824
+ })
825
+ }
826
+ }
827
+ })
828
+
829
+ if (!isOnExistingFootnote(content, range.start)) {
830
+ actions.push({
831
+ title: FOOTNOTE_ACTION_TITLE,
832
+ edits: footnoteAdditionEdits(
833
+ content,
834
+ range.start,
835
+ footnoteBlockEnd(content, tree, cursorLine),
836
+ ),
837
+ })
838
+ }
839
+
840
+ return actions
841
+ }
842
+
843
+ /** The file a hunk pointer points into. A pointer's `#line` is 1-based and maps to 0-based here; a bare `./path` lands at 0. Path RESOLUTION against a repo root is the LSP's concern — this returns the raw `./`-relative text. */
844
+ const hunkPointerAt = (
845
+ content: string,
846
+ line: number,
847
+ ): { path: string; line: number } | undefined => {
848
+ const { changesets } = parseReviewDoc(content)
849
+ const hunk = changesets.flatMap((chunk) => chunk.files).find((file) => file.sourceLine === line)
850
+ if (!hunk) return undefined
851
+ return { path: hunk.path, line: hunk.line !== undefined ? hunk.line - 1 : 0 }
852
+ }
853
+
854
+ /**
855
+ * `.gtd/REVIEW.md`'s `pointerAt`: footnotes resolve FIRST (they're
856
+ * column-scoped, the hunk jump is line-scoped — a marker sitting in a hunk's
857
+ * inline note would otherwise be shadowed by it), the hunk-pointer jump to
858
+ * another file second. An orphan marker/definition resolves (via
859
+ * `footnotePointerAt`) to "handled, but no pointer" — so it returns
860
+ * `undefined` here too, rather than falling through to the hunk jump.
861
+ */
862
+ const reviewPointerAt: SteeringFormat["pointerAt"] = (content, position) => {
863
+ const footnote = footnotePointerAt(content, position)
864
+ if (footnote) return footnote.pointer
865
+ return hunkPointerAt(content, position.line)
866
+ }
867
+
868
+ /**
869
+ * One hunk's document link, or `undefined` when `item`'s first paragraph's
870
+ * first word isn't a pointer token — mirrors `parseHunk`'s own recognition so
871
+ * the two can never disagree about what counts as a hunk. The token's range
872
+ * is derived directly from its own source offset (`firstParagraphContentOffset`),
873
+ * never by calling `pointerAt` per line — that path is one call per line and
874
+ * cannot yield the token's own range in the first place.
875
+ */
876
+ const hunkLinkFor = (content: string, item: ListItem): SteeringLink | undefined => {
877
+ const paragraph = item.children.find((c) => c.type === "paragraph")
878
+ if (!paragraph || !item.position) return undefined
879
+ const token = sourceText(content, paragraph)
880
+ .split(/\s+/)
881
+ .find((w) => w.length > 0)
882
+ if (!token || !isPointerToken(token)) return undefined
883
+ const range = pointerTokenRange(content, item, token)
884
+ if (!range) return undefined
885
+ const parsed = parsePointerToken(token)
886
+ return {
887
+ range,
888
+ path: parsed.path,
889
+ line: parsed.line !== undefined ? parsed.line - 1 : 0,
890
+ }
891
+ }
892
+
893
+ /** Every hunk-pointer document link in `content`, at any nesting depth. */
894
+ const reviewDocumentLinks = (content: string): readonly SteeringLink[] => {
895
+ const tree = parseMarkdown(content)
896
+ const links: SteeringLink[] = []
897
+ for (const block of splitChunks(tree)) {
898
+ for (const item of block.body.flatMap((n) => taskItems(n))) {
899
+ const link = hunkLinkFor(content, item)
900
+ if (link) links.push(link)
901
+ }
902
+ }
903
+ return links
904
+ }
905
+
906
+ /** Every chunk as a container with its file pointers as children, from ONE parse — never one parse per chunk. Pointers nested at any depth are already flattened into `chunk.files`. A chunk's footnote sits at the end of its heading line; a hunk's at its source line. */
907
+ const reviewView = (content: string): SteeringView => {
908
+ const { shortHash, changesets } = parseReviewDoc(content)
909
+ const noteAt = noteLookup(content)
910
+ return {
911
+ ...(shortHash ? { header: shortHash } : {}),
912
+ nodes: changesets.map((chunk, chunkIndex) => ({
913
+ title: chunk.title,
914
+ detail: chunk.description,
915
+ detailInline: chunk.descriptionInline,
916
+ body: chunk.descriptionNodes,
917
+ anchor: { kind: "chunk", index: chunkIndex },
918
+ ...noteAt(chunk.headingLine),
919
+ children: chunk.files.map((file, index) => ({
920
+ title: pointerDisplay(file),
921
+ path: file.path,
922
+ ...(file.line !== undefined ? { line: file.line } : {}),
923
+ ...(file.rangeEnd !== undefined ? { endLine: file.rangeEnd } : {}),
924
+ checked: file.checked,
925
+ ...(file.note !== undefined
926
+ ? { detail: file.note, detailInline: file.noteInline ?? [] }
927
+ : {}),
928
+ ...noteAt(file.sourceLine),
929
+ anchor: { kind: "hunk", chunkIndex, index },
930
+ })),
931
+ })),
932
+ }
933
+ }
934
+
935
+ /** Resolves a `chunk` anchor: attaches at the end of the chunk's own heading line, the definition landing after the chunk's whole block (`chunkEndLines`). `undefined` for a stale `index`. */
936
+ const resolveChunkAnchor = (
937
+ content: string,
938
+ lines: readonly string[],
939
+ changesets: readonly Changeset[],
940
+ index: number,
941
+ ): FootnoteAnchor | undefined => {
942
+ const chunk = changesets[index]
943
+ if (!chunk) return undefined
944
+ const end = Math.max(
945
+ chunk.headingLine,
946
+ chunkEndLines(content).get(chunk.headingLine) ?? chunk.headingLine,
947
+ )
948
+ return {
949
+ line: chunk.headingLine,
950
+ endCharacter: (lines[chunk.headingLine] ?? "").length,
951
+ blockEndLine: end,
952
+ key: `chunk:${chunk.headingLine}`,
953
+ }
954
+ }
955
+
956
+ /** Resolves a `hunk` anchor: attaches at the end of the hunk's own source line, the definition landing after the hunk's whole span (`file.endLine`). `undefined` for a stale `chunkIndex`/`index`. */
957
+ const resolveHunkAnchor = (
958
+ lines: readonly string[],
959
+ changesets: readonly Changeset[],
960
+ chunkIndex: number,
961
+ index: number,
962
+ ): FootnoteAnchor | undefined => {
963
+ const file = changesets[chunkIndex]?.files[index]
964
+ if (!file) return undefined
965
+ return {
966
+ line: file.sourceLine,
967
+ endCharacter: (lines[file.sourceLine] ?? "").length,
968
+ blockEndLine: file.endLine,
969
+ key: `hunk:${file.sourceLine}`,
970
+ }
971
+ }
972
+
973
+ /** 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. */
974
+ const resolveParagraphAnchor = (
975
+ tree: Root,
976
+ lines: readonly string[],
977
+ line: number,
978
+ ): FootnoteAnchor | undefined => {
979
+ const block = blockNodeAt(tree, line)
980
+ if (!block?.position) return undefined
981
+ return {
982
+ line,
983
+ endCharacter: (lines[line] ?? "").length,
984
+ blockEndLine: toLspPosition(block.position.end).line,
985
+ key: `paragraph:${line}`,
986
+ }
987
+ }
988
+
989
+ /**
990
+ * Resolves a `chunk`/`hunk`/`paragraph` `SteeringAnchor` against `content`
991
+ * into the low-level `FootnoteAnchor` `Footnotes.ts#footnoteAttachEdits`
992
+ * wants — `undefined` for a `question`/`option` anchor (not this format's own
993
+ * kind) or an index/line that no longer resolves, so a stale anchor is
994
+ * REJECTED rather than silently attaching to the wrong node.
995
+ */
996
+ const resolveReviewAnchor = (
997
+ content: string,
998
+ tree: Root,
999
+ lines: readonly string[],
1000
+ changesets: readonly Changeset[],
1001
+ anchor: SteeringAnchor,
1002
+ ): FootnoteAnchor | undefined => {
1003
+ switch (anchor.kind) {
1004
+ case "chunk":
1005
+ return resolveChunkAnchor(content, lines, changesets, anchor.index)
1006
+ case "hunk":
1007
+ return resolveHunkAnchor(lines, changesets, anchor.chunkIndex, anchor.index)
1008
+ case "paragraph":
1009
+ return resolveParagraphAnchor(tree, lines, anchor.line)
1010
+ default:
1011
+ return undefined
1012
+ }
1013
+ }
1014
+
1015
+ /** `review`-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`. */
1016
+ const reviewAnnotate = (
1017
+ content: string,
1018
+ anchor: SteeringAnchor,
1019
+ text: string,
1020
+ ): SteeringAnnotateResult => {
1021
+ const { changesets } = parseReviewDoc(content)
1022
+ const tree = parseMarkdown(content)
1023
+ const lines = content.split(/\r?\n/)
1024
+ const resolved = resolveReviewAnchor(content, tree, lines, changesets, anchor)
1025
+ if (!resolved) return { ok: false, reason: "anchor-not-found" }
1026
+ const result = footnoteAttachEdits(content, resolved, text)
1027
+ if (!result.ok) return result
1028
+ return { ok: true, edits: result.edits }
1029
+ }
1030
+
1031
+ /** Edits that set every one of `files`' ticks to `checked` — one edit per file not already at that state, none when the set is already uniform. Shared by `reviewApply`'s `hunk` (single-element) and `chunk` (every hunk beneath it) cases, so the two can never diverge on how a tick is actually set. */
1032
+ const setFileTickEdits = (
1033
+ content: string,
1034
+ files: readonly ReviewFile[],
1035
+ checked: boolean,
1036
+ ): SteeringEdit[] => {
1037
+ const edits: SteeringEdit[] = []
1038
+ for (const file of files) {
1039
+ if (file.checked === checked) continue
1040
+ const edit = toggleFilePointer(content, file.sourceLine)
1041
+ if (edit) edits.push(edit)
1042
+ }
1043
+ return edits
1044
+ }
1045
+
1046
+ /** A `hunk` anchor sets one tick; a `chunk` anchor sets every hunk beneath it to the SAME target state — never `toggleChunkEdits`'s majority-flip heuristic, since the caller already knows the state it wants. */
1047
+ const reviewApply: SteeringFormat["apply"] = (content, anchor, opts) => {
1048
+ const { changesets } = parseReviewDoc(content)
1049
+ const checked = opts.checked ?? true
1050
+ if (anchor.kind === "hunk") {
1051
+ const file = changesets[anchor.chunkIndex]?.files[anchor.index]
1052
+ if (!file) return { ok: false, reason: "anchor-not-found" }
1053
+ return { ok: true, edits: setFileTickEdits(content, [file], checked) }
1054
+ }
1055
+ if (anchor.kind === "chunk") {
1056
+ const chunk = changesets[anchor.index]
1057
+ if (!chunk) return { ok: false, reason: "anchor-not-found" }
1058
+ return { ok: true, edits: setFileTickEdits(content, chunk.files, checked) }
1059
+ }
1060
+ return { ok: false, reason: "anchor-not-found" }
1061
+ }
1062
+
1063
+ /** The `review` format: `.gtd/REVIEW.md`'s in-process format — validation, outline, code actions, footnote + hunk `pointerAt`/`documentLinks`, and a hunk-pointer-scoped `clearTicks` (never "every checked task item" — see `reviewClearTicks`). */
1064
+ export const reviewDescriptor: SteeringFormat = {
1065
+ sample: REVIEW_SAMPLE,
1066
+ validate: (content) => [...parseReviewDoc(content).findings, ...parseFootnotes(content).findings],
1067
+ outline: reviewOutline,
1068
+ actions: reviewActions,
1069
+ pointerAt: reviewPointerAt,
1070
+ documentLinks: reviewDocumentLinks,
1071
+ view: reviewView,
1072
+ annotate: reviewAnnotate,
1073
+ apply: reviewApply,
1074
+ clearTicks: reviewClearTicks,
1075
+ openThreads: openThreadsOf,
1076
+ }
1077
+
1078
+ /** One thing the human added to a review document since the baseline. */
1079
+ export interface ReviewNote {
1080
+ readonly id: string
1081
+ readonly kind: "pointer" | "footnote" | "chunk"
1082
+ /** The chunk title plus the pointer (path + range), or the footnote's marker line. */
1083
+ readonly anchor: string
1084
+ /** The reviewer's text at this spot; empty when the note is new. */
1085
+ readonly before: string
1086
+ /** The human's text. */
1087
+ readonly text: string
1088
+ }
1089
+
1090
+ const pointerLabel = (f: ReviewFile): string =>
1091
+ `${f.path}${f.line === undefined ? "" : `#${f.line}${f.rangeEnd === undefined ? "" : `-${f.rangeEnd}`}`}`
1092
+
1093
+ type Found = { line: number; note: Omit<ReviewNote, "id"> }
1094
+
1095
+ const chunkNote = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
1096
+ chunk.description === (prior?.description ?? "")
1097
+ ? []
1098
+ : [
1099
+ {
1100
+ line: chunk.headingLine,
1101
+ note: {
1102
+ kind: "chunk",
1103
+ anchor: chunk.title,
1104
+ before: prior?.description ?? "",
1105
+ text: chunk.description,
1106
+ },
1107
+ },
1108
+ ]
1109
+
1110
+ const pointerNotes = (chunk: Changeset, prior: Changeset | undefined): Found[] =>
1111
+ chunk.files.flatMap((file): Found[] => {
1112
+ const label = pointerLabel(file)
1113
+ const was = prior?.files.find((f) => pointerLabel(f) === label)
1114
+ if (file.note === undefined || file.note === (was?.note ?? "")) return []
1115
+ return [
1116
+ {
1117
+ line: file.sourceLine,
1118
+ note: {
1119
+ kind: "pointer",
1120
+ anchor: `${chunk.title} ${label}`,
1121
+ before: was?.note ?? "",
1122
+ text: file.note,
1123
+ },
1124
+ },
1125
+ ]
1126
+ })
1127
+
1128
+ const footnoteNotes = (before: string, after: string): Found[] => {
1129
+ const lines = after.split(/\r?\n/)
1130
+ const threadNames = new Set(parseThreads(after).map((t) => t.name))
1131
+ const knownNames = new Set(parseFootnotes(before).definitions.map((d) => d.name))
1132
+ const parsed = parseFootnotes(after)
1133
+ const markerOf = new Map(parsed.markers.map((m) => [m.name, m.line]))
1134
+ return parsed.definitions
1135
+ .filter((def) => !threadNames.has(def.name) && !knownNames.has(def.name))
1136
+ .map((def) => {
1137
+ const at = markerOf.get(def.name)
1138
+ return {
1139
+ line: def.line,
1140
+ note: {
1141
+ kind: "footnote",
1142
+ anchor: at === undefined ? `[^${def.name}]` : (lines[at] ?? "").trim(),
1143
+ before: "",
1144
+ text: def.body,
1145
+ },
1146
+ }
1147
+ })
1148
+ }
1149
+
1150
+ /**
1151
+ * Every note the human added to `after` relative to `before`, in document
1152
+ * order: pointer notes, one-shot footnotes (never `H:`/`A:` threads) and
1153
+ * chunk prose.
1154
+ */
1155
+ export const reviewNotes = (before: string, after: string): readonly ReviewNote[] => {
1156
+ if (before === after) return []
1157
+ const oldChunks = new Map(parseReviewDoc(before).changesets.map((c) => [c.title, c]))
1158
+ const found = [
1159
+ ...parseReviewDoc(after).changesets.flatMap((chunk) => {
1160
+ const prior = oldChunks.get(chunk.title)
1161
+ return [...chunkNote(chunk, prior), ...pointerNotes(chunk, prior)]
1162
+ }),
1163
+ ...footnoteNotes(before, after),
1164
+ ]
1165
+ return found.sort((a, b) => a.line - b.line).map((f, i) => ({ id: `note-${i + 1}`, ...f.note }))
1166
+ }
1167
+
1168
+ /** Pointer notes the reviewer opened with `Risk:`, in document order — the ones the workflow fixes before the human gate. */
1169
+ export const reviewRisks = (
1170
+ content: string,
1171
+ ): readonly { id: string; anchor: string; text: string }[] =>
1172
+ parseReviewDoc(content)
1173
+ .changesets.flatMap((chunk) =>
1174
+ chunk.files.flatMap((file) =>
1175
+ file.note?.startsWith("Risk:")
1176
+ ? [{ anchor: `${chunk.title} ${pointerLabel(file)}`, text: file.note }]
1177
+ : [],
1178
+ ),
1179
+ )
1180
+ .map((r, i) => ({ id: `risk-${i + 1}`, ...r }))