@pmelab/gtd 24.0.0 → 24.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (196) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +31 -5
  3. package/claude/hooks/access.ts +1 -1
  4. package/dist/chunks/Schema-Dh-mkwkq.mjs +25353 -0
  5. package/dist/chunks/reserved-ejn-LVYO.mjs +1388 -0
  6. package/dist/chunks/rolldown-runtime-8H4AJuhK.mjs +14 -0
  7. package/dist/chunks/steering-D-PvRC5W.mjs +16296 -0
  8. package/dist/chunks/wire-DA7q2ikx.mjs +473 -0
  9. package/dist/flows.mjs +525 -0
  10. package/dist/gtd.bundle.mjs +43339 -55575
  11. package/dist/steering.mjs +2 -0
  12. package/dist/testkit.mjs +13006 -0
  13. package/dist/wire.mjs +2 -0
  14. package/dist/workflow-kit.mjs +352 -0
  15. package/dist/workflows/feature.mjs +315 -0
  16. package/dist/workflows/fix.mjs +40 -0
  17. package/dist/workflows/review.mjs +25 -0
  18. package/dist/workflows.mjs +8 -6
  19. package/package.json +69 -8
  20. package/src/cli/Cli.test.ts +1329 -0
  21. package/src/cli/Cli.ts +1348 -0
  22. package/src/cli/Program.test.ts +2262 -0
  23. package/src/cli/Program.ts +1331 -0
  24. package/src/cli/ToolCommands.ts +43 -0
  25. package/src/cli/index.ts +14 -0
  26. package/src/edge/Edge.test.ts +1016 -0
  27. package/src/edge/Edge.ts +1225 -0
  28. package/src/edge/index.ts +16 -0
  29. package/src/emit/Emit.test.ts +425 -0
  30. package/src/emit/Emit.ts +139 -0
  31. package/src/emit/GitScript.test.ts +260 -0
  32. package/src/emit/GitScript.ts +131 -0
  33. package/src/emit/ModeContradiction.test.ts +120 -0
  34. package/src/emit/ModeContradiction.ts +66 -0
  35. package/src/emit/OutcomeScript.test.ts +238 -0
  36. package/src/emit/OutcomeScript.ts +73 -0
  37. package/src/emit/SteeringMode.test.ts +274 -0
  38. package/src/emit/SteeringMode.ts +256 -0
  39. package/src/emit/index.ts +40 -0
  40. package/src/install/ConfigScaffold.ts +19 -0
  41. package/src/install/Install.test.ts +426 -0
  42. package/src/install/Install.ts +634 -0
  43. package/src/install/Version.ts +27 -0
  44. package/src/install/index.ts +3 -0
  45. package/src/platform/CommandRunner.ts +74 -0
  46. package/src/platform/Commentary.test.ts +126 -0
  47. package/src/platform/Commentary.ts +92 -0
  48. package/src/platform/Git.test.ts +50 -0
  49. package/src/platform/Git.ts +552 -0
  50. package/src/platform/Host.ts +42 -0
  51. package/src/platform/RetainedHistory.test.ts +65 -0
  52. package/src/platform/RetainedHistory.ts +34 -0
  53. package/src/platform/Workspace.test.ts +669 -0
  54. package/src/platform/Workspace.ts +631 -0
  55. package/src/platform/WorktreeState.test.ts +162 -0
  56. package/src/platform/WorktreeState.ts +50 -0
  57. package/src/platform/index.ts +22 -0
  58. package/src/replay/Access.test.ts +53 -0
  59. package/src/replay/Access.ts +46 -0
  60. package/src/replay/Format.test.ts +20 -0
  61. package/src/replay/Format.ts +14 -0
  62. package/src/replay/Replay.test.ts +887 -0
  63. package/src/replay/Replay.ts +669 -0
  64. package/src/replay/Trailers.test.ts +117 -0
  65. package/src/replay/Trailers.ts +213 -0
  66. package/src/replay/Tree.test.ts +25 -0
  67. package/src/replay/Tree.ts +45 -0
  68. package/src/replay/index.ts +11 -0
  69. package/src/steering/Blocks.fixture.ts +6 -0
  70. package/src/steering/Blocks.test.ts +167 -0
  71. package/src/steering/Blocks.ts +321 -0
  72. package/src/steering/CodeThreads.test.ts +127 -0
  73. package/src/steering/CodeThreads.ts +197 -0
  74. package/src/steering/Edits.test.ts +19 -0
  75. package/src/steering/Edits.ts +20 -0
  76. package/src/steering/Eol.test.ts +16 -0
  77. package/src/steering/Eol.ts +2 -0
  78. package/src/steering/FORMATS.md +58 -0
  79. package/src/steering/Footnotes.test.ts +977 -0
  80. package/src/steering/Footnotes.ts +661 -0
  81. package/src/steering/Inline.fixture.ts +7 -0
  82. package/src/steering/Inline.test.ts +227 -0
  83. package/src/steering/Inline.ts +195 -0
  84. package/src/steering/MarkdownTree.test.ts +247 -0
  85. package/src/steering/MarkdownTree.ts +260 -0
  86. package/src/steering/Outline.ts +19 -0
  87. package/src/steering/SteeringFormat.test.ts +91 -0
  88. package/src/steering/SteeringFormat.ts +267 -0
  89. package/src/steering/SteeringFormats.test.ts +163 -0
  90. package/src/steering/Threads.ts +163 -0
  91. package/src/steering/contract.test.ts +264 -0
  92. package/src/steering/fixtures/code-threads/cache.stripped.ts.txt +6 -0
  93. package/src/steering/fixtures/code-threads/cache.ts.md +15 -0
  94. package/src/steering/fixtures/free-form/notes.md +12 -0
  95. package/src/steering/fixtures/free-form/notes.read.json +42 -0
  96. package/src/steering/fixtures/qa/answered.md +23 -0
  97. package/src/steering/fixtures/qa/answered.read.json +119 -0
  98. package/src/steering/fixtures/qa/requirements.md +160 -0
  99. package/src/steering/fixtures/qa/requirements.read.json +475 -0
  100. package/src/steering/fixtures/review/review.md +76 -0
  101. package/src/steering/fixtures/review/review.read.json +237 -0
  102. package/src/steering/fixtures/review/ticked.md +23 -0
  103. package/src/steering/fixtures/review/ticked.read.json +99 -0
  104. package/src/steering/fixtures/review-notes/baseline.md +14 -0
  105. package/src/steering/fixtures/review-notes/noted.md +19 -0
  106. package/src/steering/freeform.test.ts +311 -0
  107. package/src/steering/freeform.ts +148 -0
  108. package/src/steering/index.test.ts +62 -0
  109. package/src/steering/index.ts +140 -0
  110. package/src/steering/qa.test.ts +2160 -0
  111. package/src/steering/qa.ts +1104 -0
  112. package/src/steering/review.test.ts +1829 -0
  113. package/src/steering/review.ts +1180 -0
  114. package/src/step/LandStep.ts +31 -0
  115. package/src/step/RepoSnapshot.ts +29 -0
  116. package/src/step/accessRefusal.test.ts +58 -0
  117. package/src/step/accessRefusal.ts +26 -0
  118. package/src/step/index.ts +5 -0
  119. package/src/step/planStart.test.ts +202 -0
  120. package/src/step/planStart.ts +167 -0
  121. package/src/step/planStep.test.ts +104 -0
  122. package/src/step/planStep.ts +89 -0
  123. package/src/step/snapshot.fixture.ts +28 -0
  124. package/src/testkit/EmittedScriptRecognizer.test.ts +639 -0
  125. package/src/testkit/EmittedScriptRecognizer.ts +642 -0
  126. package/src/testkit/FakeGitOperations.ts +95 -0
  127. package/src/testkit/GitTiers.test.ts +11 -0
  128. package/src/testkit/GitTiers.ts +1052 -0
  129. package/src/testkit/InMemRepo.ts +467 -0
  130. package/src/testkit/Layers.test.ts +58 -0
  131. package/src/testkit/Layers.ts +241 -0
  132. package/src/testkit/StrictGitOperations.ts +31 -0
  133. package/src/testkit/TestRepo.test.ts +119 -0
  134. package/src/testkit/TestRepo.ts +274 -0
  135. package/src/testkit/cliIo.ts +36 -0
  136. package/src/testkit/index.ts +9 -0
  137. package/src/testkit/steps.ts +177 -0
  138. package/src/testkit/testkit.feature +65 -0
  139. package/src/wire/BeatDocument.test.ts +419 -0
  140. package/src/wire/BeatDocument.ts +114 -0
  141. package/src/wire/BeatPlain.test.ts +113 -0
  142. package/src/wire/BeatPlain.ts +127 -0
  143. package/src/wire/BeatStatus.test.ts +59 -0
  144. package/src/wire/BeatStatus.ts +81 -0
  145. package/src/wire/Demand.test.ts +142 -0
  146. package/src/wire/Demand.ts +108 -0
  147. package/src/wire/ExitCodes.test.ts +21 -0
  148. package/src/wire/ExitCodes.ts +16 -0
  149. package/src/wire/Glob.ts +29 -0
  150. package/src/wire/Judge.ts +22 -0
  151. package/src/wire/Land.test.ts +120 -0
  152. package/src/wire/Land.ts +75 -0
  153. package/src/wire/Select.test.ts +148 -0
  154. package/src/wire/Select.ts +78 -0
  155. package/src/wire/Sessions.test.ts +59 -0
  156. package/src/wire/Sessions.ts +39 -0
  157. package/src/wire/WireSchema.test.ts +162 -0
  158. package/src/wire/constants.ts +22 -0
  159. package/src/wire/golden/1/beat-capture.json +34 -0
  160. package/src/wire/golden/1/beat-message.json +34 -0
  161. package/src/wire/golden/1/beat-prompt.json +44 -0
  162. package/src/wire/golden/1/beat-script.json +34 -0
  163. package/src/wire/golden/1/beat-stalled.json +34 -0
  164. package/src/wire/golden/1/judge.json +14 -0
  165. package/src/wire/golden/1/land-noop.json +10 -0
  166. package/src/wire/golden/1/land.json +10 -0
  167. package/src/wire/index.ts +12 -0
  168. package/src/wire/types.ts +46 -0
  169. package/src/workflow/Diagnostic.test.ts +93 -0
  170. package/src/workflow/Diagnostic.ts +54 -0
  171. package/src/workflow/SteeringFormats.test.ts +27 -0
  172. package/src/workflow/SteeringFormats.ts +6 -0
  173. package/src/workflow/Workflow.ts +77 -0
  174. package/src/workflow/compile.test.ts +53 -0
  175. package/src/workflow/compile.ts +56 -0
  176. package/src/workflow/discovery.ts +122 -0
  177. package/src/workflow/fingerprint.test.ts +52 -0
  178. package/src/workflow/fingerprint.ts +53 -0
  179. package/src/workflow/index.ts +33 -0
  180. package/src/workflow/load.test.ts +1017 -0
  181. package/src/workflow/load.ts +836 -0
  182. package/src/workflow/skills.test.ts +109 -0
  183. package/src/workflow/skills.ts +90 -0
  184. package/src/workflow/vars.ts +33 -0
  185. package/src/workflows/feature/feature.test.ts +27 -33
  186. package/src/workflows/feature/index.ts +10 -0
  187. package/src/workflows/fix/fix.feature +80 -0
  188. package/src/workflows/fix/fix.test.ts +58 -78
  189. package/src/workflows/fix/index.ts +6 -0
  190. package/src/workflows/index.ts +2 -10
  191. package/src/workflows/review/index.ts +6 -0
  192. package/src/workflows/review/review.test.ts +85 -29
  193. package/src/workflows/review/review.ts +3 -1
  194. package/src/workflows/shared/config.fixture.ts +12 -0
  195. package/src/workflows/shared/reserved.ts +11 -0
  196. package/src/workflows/shared/workflows.steps.ts +7 -0
@@ -0,0 +1,321 @@
1
+ import type { List, ListItem, RootContent, Root } from "mdast"
2
+ import { noteLookup } from "./Footnotes.js"
3
+ import type { Footnotes } from "./Footnotes.js"
4
+ import { definitionsOf, joinInlineRuns, projectInline, type InlineNode } from "./Inline.js"
5
+ import { headingText, sourceText, toLspPosition } from "./MarkdownTree.js"
6
+ import type { BlockListItem, SteeringView, SteeringViewNode } from "./SteeringFormat.js"
7
+
8
+ /** A footnote marker as plain text, stripped out of every extracted title below. */
9
+ const MARKER_TEXT_RE = /\[\^([^\s\]]+)\]/g
10
+
11
+ const stripMarkerText = (text: string): string => text.replace(MARKER_TEXT_RE, "")
12
+
13
+ /** `child`'s raw source slice, UNCOLLAPSED — only `blockquoteChildrenText` needs the newlines intact, to tell a line-leading `> ` continuation marker apart from a literal `>` typed mid-prose. */
14
+ const rawChildText = (content: string, child: RootContent): string => {
15
+ const start = child.position?.start.offset
16
+ const end = child.position?.end.offset
17
+ return start === undefined || end === undefined ? "" : content.slice(start, end)
18
+ }
19
+
20
+ /** Strips a blockquote's `> ` continuation markers. Must run on the RAW slice: the newline is what distinguishes them from a literal `>` typed mid-prose. */
21
+ const stripContinuationMarker = (text: string): string => text.replace(/\n[ \t]*>[ \t]?/g, "\n")
22
+
23
+ /**
24
+ * Flattened text of a run of sibling block nodes. Each child is sliced
25
+ * INDIVIDUALLY, never as one span from the first child's start to the last
26
+ * child's end: a filtered-out nested `list` still sits, raw markdown and all,
27
+ * between its neighbours' offsets.
28
+ */
29
+ const childrenText = (content: string, children: readonly RootContent[]): string =>
30
+ children
31
+ .map((child) => stripMarkerText(sourceText(content, child)))
32
+ .filter((text) => text.length > 0)
33
+ .join(" ")
34
+ .replace(/\s+/g, " ")
35
+ .trim()
36
+
37
+ /**
38
+ * `childrenText` scoped to blockquotes: their `> ` continuation markers sit in
39
+ * the raw bytes BETWEEN two children's positions, so they are stripped before
40
+ * the whitespace collapse. Never reused for `listItemText` — a list item's
41
+ * fenced code block can carry a real line-leading `>` that must survive.
42
+ */
43
+ const blockquoteChildrenText = (content: string, children: readonly RootContent[]): string =>
44
+ children
45
+ .map((child) => stripMarkerText(stripContinuationMarker(rawChildText(content, child))))
46
+ .filter((text) => text.length > 0)
47
+ .join(" ")
48
+ .replace(/\s+/g, " ")
49
+ .trim()
50
+
51
+ /** One list item's text, EXCLUDING any nested `list` child — that's a recursive `items` entry instead. */
52
+ const listItemText = (content: string, item: ListItem): string =>
53
+ childrenText(
54
+ content,
55
+ item.children.filter((c) => c.type !== "list"),
56
+ )
57
+
58
+ /**
59
+ * One list item's own inline structure, EXCLUDING any nested `list` child
60
+ * (mirrors `listItemText`'s identical exclusion) — recurses through each
61
+ * remaining non-list child via `nodeInline`, joined the same way
62
+ * `childrenText` joins its own text. Feeds `BlockListItem.inline` (the FULL
63
+ * block rendering) ONLY — a nested item renders as its own nested `<li>`
64
+ * there (`BlockListItem.items`), so folding its text back into the PARENT's
65
+ * own `inline` would duplicate it. `flattenListItemsInline` (below) is the
66
+ * separate, deliberately non-recursive-exclusion sibling `nodeInline`'s own
67
+ * `list` branch uses instead, for the flattened `detailInline` path.
68
+ */
69
+ const listItemInline = (content: string, item: ListItem): readonly InlineNode[] =>
70
+ joinInlineRuns(item.children.filter((c) => c.type !== "list").map((c) => nodeInline(content, c)))
71
+
72
+ /**
73
+ * Every list item's own inline run, AT ANY NESTING DEPTH, flattened and
74
+ * joined in document order — what `nodeInline`'s own `list` branch needs for
75
+ * `detailInline` (R2: "a list contributes its items' runs joined by
76
+ * spaces", explicitly INCLUDING a nested item, since block structure stays
77
+ * flattened there). Deliberately distinct from `listItemInline`/
78
+ * `BlockListItem.inline`, which keep a nested item's text OUT of its
79
+ * parent's own `inline` — that field feeds the FULL block's own recursive
80
+ * `<li>` tree, where a nested item already renders once, on its own.
81
+ */
82
+ const flattenListItemsInline = (
83
+ content: string,
84
+ items: readonly ListItem[],
85
+ ): readonly InlineNode[] =>
86
+ joinInlineRuns(
87
+ items.flatMap((item) => {
88
+ const ownRun = listItemInline(content, item)
89
+ const nested = item.children.filter((c): c is List => c.type === "list")
90
+ const nestedRuns = nested.map((list) => flattenListItemsInline(content, list.children))
91
+ return [ownRun, ...nestedRuns]
92
+ }),
93
+ )
94
+
95
+ /**
96
+ * One top-level (or list-item-child) node's own inline structure —
97
+ * `heading`/`paragraph` project their own `children` directly; `blockquote`
98
+ * joins each non-footnote-definition child's own inline run; `list` joins
99
+ * each of its visible items' own `listItemInline`; `code` has none of its
100
+ * own beyond its plain `value`. Mirrors `blockTitle`'s per-kind dispatch,
101
+ * structurally rather than as a flattened string.
102
+ */
103
+ const nodeInline = (
104
+ content: string,
105
+ node: RootContent,
106
+ options?: BlockWalkOptions,
107
+ ): readonly InlineNode[] => {
108
+ if (node.type === "heading" || node.type === "paragraph") {
109
+ return projectInline(node.children, definitionsOf(content))
110
+ }
111
+ if (node.type === "blockquote") {
112
+ return joinInlineRuns(
113
+ node.children
114
+ .filter((c) => c.type !== "footnoteDefinition")
115
+ .map((c) => nodeInline(content, c, options)),
116
+ )
117
+ }
118
+ if (node.type === "list") {
119
+ return flattenListItemsInline(content, visibleListItems(node, options))
120
+ }
121
+ if (node.type === "code") return [{ kind: "text", value: node.value }]
122
+ // Every other kind (a block-level `html` node, a `thematicBreak`, …) has
123
+ // no inline structure of its own to project — but its own text must still
124
+ // survive as an inert `text` node (R4: "raw HTML in a description stays
125
+ // inert text", not nothing), mirroring `blockTitle`'s own identical
126
+ // `sourceText` fallback for the same set of kinds.
127
+ const title = blockTitle(content, node, options)
128
+ return title.length > 0 ? [{ kind: "text", value: title }] : []
129
+ }
130
+
131
+ /** One list item as a `BlockListItem`, recursing into a nested `list` child (there is at most one, CommonMark's own shape) as its own `items`. */
132
+ const blockListItemOf = (content: string, item: ListItem): BlockListItem => {
133
+ const nested = item.children.filter((c): c is List => c.type === "list")
134
+ const items = nested.flatMap((list) => blockListItemsOf(content, list.children))
135
+ return {
136
+ text: listItemText(content, item),
137
+ inline: listItemInline(content, item),
138
+ ...(item.checked === true || item.checked === false ? { checked: item.checked } : {}),
139
+ ...(items.length > 0 ? { items } : {}),
140
+ }
141
+ }
142
+
143
+ /** Every item of a `list` node's own `children`, as `BlockListItem`s, in document order. */
144
+ const blockListItemsOf = (content: string, items: readonly ListItem[]): readonly BlockListItem[] =>
145
+ items.map((item) => blockListItemOf(content, item))
146
+
147
+ /** The `title` an empty fenced code block (a `` ``` ``/`` ``` `` pair with nothing between them) falls back to — its real body is `""`, and every node still carries a non-empty title. */
148
+ const EMPTY_CODE_BLOCK_TITLE = "(empty code block)"
149
+
150
+ /** A `list`'s children with `options.skipListItem` removed. Filtering, never dropping the whole node: `qa`'s option items share a list with ordinary body bullets. */
151
+ const visibleListItems = (node: List, options?: BlockWalkOptions): readonly ListItem[] =>
152
+ options?.skipListItem
153
+ ? node.children.filter((item) => !options.skipListItem!(item))
154
+ : node.children
155
+
156
+ /**
157
+ * Every block kind's `title`. Each branch reads the node's CHILDREN rather
158
+ * than its own span, because the span includes syntax the title must not
159
+ * carry: a heading's `#` run, a blockquote's `>`, a code fence, and — for a
160
+ * filtered list — every excluded item's markdown, checkbox syntax included.
161
+ */
162
+ const blockTitle = (content: string, node: RootContent, options?: BlockWalkOptions): string => {
163
+ if (node.type === "heading") return headingText(content, node, stripMarkerText)
164
+ if (node.type === "blockquote") return blockquoteChildrenText(content, node.children)
165
+ if (node.type === "code") {
166
+ const text = stripMarkerText(node.value).replace(/\s+/g, " ").trim()
167
+ return text.length > 0 ? text : EMPTY_CODE_BLOCK_TITLE
168
+ }
169
+ if (node.type === "list") {
170
+ return visibleListItems(node, options)
171
+ .map((item) => listItemText(content, item))
172
+ .filter((text) => text.length > 0)
173
+ .join(" ")
174
+ }
175
+ return stripMarkerText(sourceText(content, node)).replace(/\s+/g, " ").trim()
176
+ }
177
+
178
+ /**
179
+ * `SteeringViewNode.block` for one top-level node — `undefined` for a kind
180
+ * with no projected structure (`thematicBreak`, `html`, …), which still
181
+ * renders via `title` alone. `code`'s `text` is `node.value` VERBATIM,
182
+ * leading whitespace intact, unlike every other kind's collapsed `title`.
183
+ */
184
+ const blockOf = (
185
+ content: string,
186
+ node: RootContent,
187
+ options?: BlockWalkOptions,
188
+ ): SteeringViewNode["block"] | undefined => {
189
+ switch (node.type) {
190
+ case "heading":
191
+ return {
192
+ kind: "heading",
193
+ depth: node.depth,
194
+ inline: projectInline(node.children, definitionsOf(content)),
195
+ }
196
+ case "list":
197
+ return {
198
+ kind: "list",
199
+ ordered: node.ordered === true,
200
+ items: blockListItemsOf(content, visibleListItems(node, options)),
201
+ }
202
+ case "code":
203
+ return {
204
+ kind: "code",
205
+ text: node.value,
206
+ ...(node.lang !== null && node.lang !== undefined ? { language: node.lang } : {}),
207
+ }
208
+ case "blockquote":
209
+ return {
210
+ kind: "blockquote",
211
+ text: blockTitle(content, node),
212
+ inline: nodeInline(content, node),
213
+ }
214
+ case "paragraph":
215
+ return { kind: "paragraph", inline: projectInline(node.children, definitionsOf(content)) }
216
+ default:
217
+ return undefined
218
+ }
219
+ }
220
+
221
+ /**
222
+ * Lets a caller narrow the shared walk to its own document shape, so this
223
+ * module hardcodes no format's rules. `skipListItem` matches by NODE
224
+ * IDENTITY, never line arithmetic: `qa`'s option items share a source list
225
+ * with ordinary body bullets, so a partly-filtered list stays in the walk.
226
+ */
227
+ export interface BlockWalkOptions {
228
+ readonly skipNode?: (content: string, node: RootContent) => boolean
229
+ readonly skipLine?: (line: number) => boolean
230
+ readonly skipListItem?: (item: ListItem) => boolean
231
+ /**
232
+ * Pre-parsed footnotes, for a caller that walks MULTIPLE runs of the SAME
233
+ * document (`qa.ts`'s one call per question, now one per option too) and
234
+ * must not pay `parseFootnotes`'s own whole-document walk again for each
235
+ * one. Absent means "parse it here" — every single-call caller (`review.ts`,
236
+ * `freeform.ts`, this module's own `blockNodesOf`) still gets that for
237
+ * free, unchanged.
238
+ */
239
+ readonly footnotes?: Footnotes
240
+ }
241
+
242
+ /** A `list` that `skipListItem` empties ENTIRELY — the one case excluded wholesale; one surviving item keeps the list in the walk. */
243
+ const isEmptiedList = (node: RootContent, options?: BlockWalkOptions): boolean =>
244
+ node.type === "list" &&
245
+ node.children.length > 0 &&
246
+ options?.skipListItem !== undefined &&
247
+ visibleListItems(node, options).length === 0
248
+
249
+ /**
250
+ * The nodes of a run that `blockNodesOfRun`/`blockRunInline` both walk —
251
+ * a real position (so `position!` below is total), not a footnote
252
+ * definition, not `options`-skipped, not a list emptied down to nothing by
253
+ * `skipListItem`. Shared so the two walks can never drift apart on which
254
+ * nodes they see (see `blockRunInline`'s own doc comment).
255
+ */
256
+ const visibleNodes = (
257
+ content: string,
258
+ nodes: readonly RootContent[],
259
+ options?: BlockWalkOptions,
260
+ ): readonly RootContent[] =>
261
+ nodes
262
+ .filter((node) => node.position !== undefined)
263
+ .filter((node) => node.type !== "footnoteDefinition")
264
+ .filter((node) => !(options?.skipNode?.(content, node) ?? false))
265
+ .filter((node) => !(options?.skipLine?.(toLspPosition(node.position!.start).line) ?? false))
266
+ .filter((node) => !isEmptiedList(node, options))
267
+
268
+ /**
269
+ * Every block of a RUN of sibling nodes as a view node, in document order. A
270
+ * `footnoteDefinition` is skipped unconditionally: it is the note ITSELF,
271
+ * surfaced below as a node's `note`, never document content of its own.
272
+ *
273
+ * A footnote marker on a node's start line becomes that node's `note`, so a
274
+ * block already carrying one offers editing it rather than a second note.
275
+ *
276
+ * `options.footnotes`, when given, replaces this call's own
277
+ * `parseFootnotes(content)` — see its own doc for why a caller walking many
278
+ * runs of one document needs it.
279
+ */
280
+ export const blockNodesOfRun = (
281
+ content: string,
282
+ nodes: readonly RootContent[],
283
+ options?: BlockWalkOptions,
284
+ ): readonly SteeringView["nodes"][number][] => {
285
+ const noteAt = noteLookup(content, options?.footnotes)
286
+ return visibleNodes(content, nodes, options).map((node) => {
287
+ const startLine = toLspPosition(node.position!.start).line
288
+ const block = blockOf(content, node, options)
289
+ return {
290
+ title: blockTitle(content, node, options),
291
+ anchor: { kind: "paragraph" as const, line: startLine },
292
+ ...(block !== undefined ? { block } : {}),
293
+ ...noteAt(startLine),
294
+ }
295
+ })
296
+ }
297
+
298
+ /** Every top-level block of the WHOLE document, in document order — `blockNodesOfRun` over `tree.children`. See that function's own doc comment for the shared per-node logic. */
299
+ export const blockNodesOf = (
300
+ content: string,
301
+ tree: Root,
302
+ options?: BlockWalkOptions,
303
+ ): readonly SteeringView["nodes"][number][] => blockNodesOfRun(content, tree.children, options)
304
+
305
+ /**
306
+ * A run of sibling nodes' own inline structure, flattened and joined exactly
307
+ * the way `blockTitle`/`childrenText` flatten the SAME run into one string —
308
+ * a caller's `detailInline` (`review.ts`'s chunk, `qa.ts`'s question)
309
+ * builds this over the same node run its own `detail` string is built over,
310
+ * with the SAME `options` (so a skipped node/line/list-item is skipped in
311
+ * both). Never itself collapses an image to alt text — a caller wanting the
312
+ * compact-row rule (R2) applies `Inline.ts#collapseImages` to the result.
313
+ */
314
+ export const blockRunInline = (
315
+ content: string,
316
+ nodes: readonly RootContent[],
317
+ options?: BlockWalkOptions,
318
+ ): readonly InlineNode[] => {
319
+ const filtered = visibleNodes(content, nodes, options)
320
+ return joinInlineRuns(filtered.map((node) => nodeInline(content, node, options)))
321
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { parseCodeThreads, stripCodeThreads } from "./CodeThreads.js"
3
+
4
+ // Samples are built from string arrays: a template literal with a line-start
5
+ // `// H:` would be a live thread in this repo's own changed files.
6
+ const doc = (...lines: string[]): string => lines.join("\n")
7
+
8
+ describe("parseCodeThreads — tokens", () => {
9
+ it.each([
10
+ ["a.ts", "//"],
11
+ ["a.py", "#"],
12
+ ["a.sql", "--"],
13
+ ["a.ini", ";"],
14
+ ])("%s accepts %s", (path, token) => {
15
+ const { threads } = parseCodeThreads(path, [token, "H: why?"].join(" "))
16
+ expect(threads).toHaveLength(1)
17
+ })
18
+
19
+ it("recognises a token only in its own extensions", () => {
20
+ expect(parseCodeThreads("a.ts", "# H: why?").threads).toEqual([])
21
+ expect(parseCodeThreads("a.py", ["//", "H: why?"].join(" ")).threads).toEqual([])
22
+ expect(parseCodeThreads("a.sql", "; H: why?").threads).toEqual([])
23
+ })
24
+
25
+ it("scans an unmapped file for all four tokens", () => {
26
+ for (const token of ["//", "#", "--", ";"]) {
27
+ expect(parseCodeThreads("Makefile", [token, "H: why?"].join(" ")).threads).toHaveLength(1)
28
+ }
29
+ })
30
+
31
+ it("never scans markdown or .gtd files", () => {
32
+ const text = doc(["//", "H: why?"].join(" "), "# H: why?")
33
+ for (const path of ["a.md", "b.markdown", ".gtd/x.ts", ".gtd/sub/y.sh"]) {
34
+ expect(parseCodeThreads(path, text)).toEqual({ threads: [], findings: [] })
35
+ }
36
+ })
37
+ })
38
+
39
+ describe("parseCodeThreads — runs", () => {
40
+ it("returns 0-based lines, entries and waitingOn", () => {
41
+ const text = doc("const x = 1", " // H: why?", " // A: because", "const y = 2")
42
+ const [thread] = parseCodeThreads("a.ts", text).threads
43
+ expect(thread).toMatchObject({ path: "a.ts", line: 1, endLine: 2, waitingOn: "human" })
44
+ expect(thread!.entries.map((e) => [e.author, e.text, e.line])).toEqual([
45
+ ["me", "why?", 1],
46
+ ["agent", "because", 2],
47
+ ])
48
+ })
49
+
50
+ it("is waiting on the agent when the last entry is H:", () => {
51
+ expect(parseCodeThreads("a.ts", "// H: why?").threads[0]!.waitingOn).toBe("agent")
52
+ })
53
+
54
+ it("joins unprefixed lines onto the entry above", () => {
55
+ const text = doc("// H: why", "// is this", "// so?", "// A: ok")
56
+ const [thread] = parseCodeThreads("a.ts", text).threads
57
+ expect(thread!.entries[0]!.text).toBe("why is this so?")
58
+ expect(thread!.entries).toHaveLength(2)
59
+ })
60
+
61
+ it("ends at a non-comment line or a token switch", () => {
62
+ const a = parseCodeThreads("Makefile", doc("# H: q", "code", "# A: orphan"))
63
+ expect(a.threads).toHaveLength(2)
64
+ expect(a.threads[0]!.entries).toHaveLength(1)
65
+ const b = parseCodeThreads("Makefile", doc("# H: q", "-- A: other"))
66
+ expect(b.threads[0]!.endLine).toBe(0)
67
+ expect(b.threads[0]!.entries).toHaveLength(1)
68
+ })
69
+
70
+ it("ends at an unprefixed comment below an A: line", () => {
71
+ const text = doc("// H: why?", "// A: because", "// rename this")
72
+ const [thread] = parseCodeThreads("a.ts", text).threads
73
+ expect(thread!.endLine).toBe(1)
74
+ expect(thread!.entries.map((e) => e.text)).toEqual(["why?", "because"])
75
+ })
76
+
77
+ it("treats a run not opening with a prefix as an ordinary comment", () => {
78
+ const text = doc("// note", "// H: why?")
79
+ expect(parseCodeThreads("a.ts", text)).toEqual({ threads: [], findings: [] })
80
+ })
81
+
82
+ it("ignores trailing and block comments", () => {
83
+ const text = doc("foo() // H: why?", "/* H: why? */", "<!-- H: why? -->", " * H: why?")
84
+ expect(parseCodeThreads("Makefile", text).threads).toEqual([])
85
+ })
86
+ })
87
+
88
+ describe("parseCodeThreads — faults", () => {
89
+ it("reports an agent opening", () => {
90
+ const { findings } = parseCodeThreads("a.ts", "// A: hi")
91
+ expect(findings[0]!.message).toBe(
92
+ `Code thread at a.ts:1: the agent never starts a thread — the first entry must be "H:"`,
93
+ )
94
+ })
95
+
96
+ it("reports the same author twice", () => {
97
+ const { findings } = parseCodeThreads("a.ts", doc("// H: a", "// H: b"))
98
+ expect(findings).toHaveLength(1)
99
+ expect(findings[0]).toMatchObject({ line: 1 })
100
+ expect(findings[0]!.message).toMatch(/two consecutive "H:"/)
101
+ })
102
+
103
+ it("reports an empty entry", () => {
104
+ const { findings } = parseCodeThreads("a.ts", "// H:")
105
+ expect(findings[0]!.message).toMatch(/empty "H:" entry/)
106
+ })
107
+
108
+ it("reports nothing for a clean thread", () => {
109
+ expect(parseCodeThreads("a.ts", doc("// H: a", "// A: b")).findings).toEqual([])
110
+ })
111
+ })
112
+
113
+ describe("stripCodeThreads", () => {
114
+ it("removes thread runs and keeps ordinary comments and code", () => {
115
+ const text = doc("// note", "code()", "// H: why?", "// A: because", "more()", "// plain")
116
+ expect(stripCodeThreads("a.ts", text)).toBe(doc("// note", "code()", "more()", "// plain"))
117
+ })
118
+
119
+ it("keeps an ordinary comment written right below a thread's A: line", () => {
120
+ const text = doc("// H: why?", "// A: because", "// rename this", "code()")
121
+ expect(stripCodeThreads("a.ts", text)).toBe(doc("// rename this", "code()"))
122
+ })
123
+
124
+ it("leaves unscanned paths alone", () => {
125
+ expect(stripCodeThreads("a.md", "// H: x")).toBe("// H: x")
126
+ })
127
+ })
@@ -0,0 +1,197 @@
1
+ import type { SteeringFinding } from "./SteeringFormat.js"
2
+ import type { ThreadEntry } from "./Threads.js"
3
+
4
+ export interface CodeThread {
5
+ readonly path: string
6
+ /** 0-based line of the run's first comment line. */
7
+ readonly line: number
8
+ readonly endLine: number
9
+ readonly entries: readonly Pick<ThreadEntry, "author" | "text" | "line">[]
10
+ /** `"human"` means the last entry is the agent's: the thread is open. */
11
+ readonly waitingOn: "human" | "agent"
12
+ }
13
+
14
+ const TOKENS = ["//", "#", "--", ";"] as const
15
+ type Token = (typeof TOKENS)[number]
16
+
17
+ const BY_EXTENSION: Readonly<Record<Token, readonly string[]>> = {
18
+ "//": [
19
+ "ts",
20
+ "tsx",
21
+ "js",
22
+ "jsx",
23
+ "mjs",
24
+ "cjs",
25
+ "mts",
26
+ "cts",
27
+ "go",
28
+ "rs",
29
+ "c",
30
+ "h",
31
+ "cc",
32
+ "cpp",
33
+ "hpp",
34
+ "java",
35
+ "kt",
36
+ "swift",
37
+ "cs",
38
+ "scala",
39
+ "php",
40
+ "dart",
41
+ ],
42
+ "#": [
43
+ "feature",
44
+ "py",
45
+ "sh",
46
+ "bash",
47
+ "zsh",
48
+ "fish",
49
+ "rb",
50
+ "yaml",
51
+ "yml",
52
+ "toml",
53
+ "pl",
54
+ "r",
55
+ "ex",
56
+ "exs",
57
+ ],
58
+ "--": ["sql", "lua", "hs"],
59
+ ";": ["lisp", "clj", "cljs", "ini", "asm", "el", "scm"],
60
+ }
61
+
62
+ /** The comment tokens a path is scanned for; none for `.md`/`.markdown` and `.gtd/**`. A file with no mapped extension is scanned for all four. */
63
+ const codeThreadTokens = (path: string): readonly Token[] => {
64
+ if (path === ".gtd" || path.startsWith(".gtd/")) return []
65
+ const base = path.slice(path.lastIndexOf("/") + 1)
66
+ const dot = base.lastIndexOf(".")
67
+ const ext = dot > 0 ? base.slice(dot + 1).toLowerCase() : ""
68
+ if (ext === "md" || ext === "markdown") return []
69
+ const own = TOKENS.filter((token) => BY_EXTENSION[token].includes(ext))
70
+ return own.length > 0 ? own : TOKENS
71
+ }
72
+
73
+ const PREFIX_RE = /^(H|A):\s*/
74
+
75
+ interface CommentLine {
76
+ readonly token: Token
77
+ readonly text: string
78
+ }
79
+
80
+ // A comment line's FIRST non-blank text is the token, so a trailing comment
81
+ // after code never matches.
82
+ const commentOf = (line: string, tokens: readonly Token[]): CommentLine | undefined => {
83
+ const trimmed = line.trimStart()
84
+ const token = tokens.find((candidate) => trimmed.startsWith(candidate))
85
+ return token === undefined ? undefined : { token, text: trimmed.slice(token.length).trim() }
86
+ }
87
+
88
+ interface Run {
89
+ readonly start: number
90
+ readonly end: number
91
+ readonly texts: readonly string[]
92
+ }
93
+
94
+ // The agent's entry is one line, so an unprefixed comment below an `A:` line
95
+ // is an ordinary comment: absorbing it would let a reviewer's note vanish
96
+ // into the thread (and out of a review's code edits) when the thread is
97
+ // stripped.
98
+ const endsThreadRun = (texts: readonly string[], next: string): boolean =>
99
+ PREFIX_RE.test(texts[0]!) && texts[texts.length - 1]!.startsWith("A:") && !PREFIX_RE.test(next)
100
+
101
+ const runsOf = (lines: readonly string[], tokens: readonly Token[]): readonly Run[] => {
102
+ const runs: Run[] = []
103
+ let current: { start: number; token: Token; texts: string[] } | undefined
104
+ const close = (end: number): void => {
105
+ if (current) runs.push({ start: current.start, end, texts: current.texts })
106
+ current = undefined
107
+ }
108
+ lines.forEach((line, i) => {
109
+ const comment = commentOf(line, tokens)
110
+ if (
111
+ !comment ||
112
+ (current && (current.token !== comment.token || endsThreadRun(current.texts, comment.text)))
113
+ ) {
114
+ close(i - 1)
115
+ }
116
+ if (!comment) return
117
+ if (current) current.texts.push(comment.text)
118
+ else current = { start: i, token: comment.token, texts: [comment.text] }
119
+ })
120
+ close(lines.length - 1)
121
+ return runs
122
+ }
123
+
124
+ const isThreadRun = (run: Run): boolean => PREFIX_RE.test(run.texts[0]!)
125
+
126
+ /** Every thread run of `content` (0-based lines), the scanner's one line-based walk. */
127
+ export const parseCodeThreads = (
128
+ path: string,
129
+ content: string,
130
+ ): { readonly threads: readonly CodeThread[]; readonly findings: readonly SteeringFinding[] } => {
131
+ const tokens = codeThreadTokens(path)
132
+ if (tokens.length === 0) return { threads: [], findings: [] }
133
+ const lines = content.split(/\r?\n/)
134
+ const threads: CodeThread[] = []
135
+ const findings: SteeringFinding[] = []
136
+ for (const run of runsOf(lines, tokens).filter(isThreadRun)) {
137
+ const entries: { author: "me" | "agent"; text: string; line: number }[] = []
138
+ run.texts.forEach((text, offset) => {
139
+ const match = PREFIX_RE.exec(text)
140
+ const line = run.start + offset
141
+ if (match) {
142
+ entries.push({
143
+ author: match[1] === "H" ? "me" : "agent",
144
+ text: text.slice(match[0].length).trim(),
145
+ line,
146
+ })
147
+ return
148
+ }
149
+ const last = entries[entries.length - 1]!
150
+ if (text !== "") last.text = last.text === "" ? text : `${last.text} ${text}`
151
+ })
152
+ const report = (message: string, entry: (typeof entries)[number]): void => {
153
+ findings.push({
154
+ message: `Code thread at ${path}:${run.start + 1}: ${message}`,
155
+ line: entry.line,
156
+ range: {
157
+ start: { line: entry.line, character: 0 },
158
+ end: { line: entry.line, character: (lines[entry.line] ?? "").length },
159
+ },
160
+ })
161
+ }
162
+ entries.forEach((entry, i) => {
163
+ const letter = entry.author === "me" ? "H" : "A"
164
+ if (entry.text === "") {
165
+ report(`empty "${letter}:" entry — write your own words there`, entry)
166
+ }
167
+ if (i === 0 && entry.author === "agent") {
168
+ report(`the agent never starts a thread — the first entry must be "H:"`, entry)
169
+ } else if (i > 0 && entries[i - 1]!.author === entry.author) {
170
+ report(`two consecutive "${letter}:" entries — entries must alternate`, entry)
171
+ }
172
+ })
173
+ threads.push({
174
+ path,
175
+ line: run.start,
176
+ endLine: run.end,
177
+ entries,
178
+ waitingOn: entries[entries.length - 1]?.author === "agent" ? "human" : "agent",
179
+ })
180
+ }
181
+ return { threads, findings }
182
+ }
183
+
184
+ /** `text` with every thread run removed; ordinary comments stay. */
185
+ export const stripCodeThreads = (path: string, text: string): string => {
186
+ const tokens = codeThreadTokens(path)
187
+ if (tokens.length === 0) return text
188
+ const lines = text.split("\n")
189
+ const drop = new Set<number>()
190
+ for (const run of runsOf(
191
+ lines.map((l) => l.replace(/\r$/, "")),
192
+ tokens,
193
+ ).filter(isThreadRun)) {
194
+ for (let i = run.start; i <= run.end; i++) drop.add(i)
195
+ }
196
+ return lines.filter((_, i) => !drop.has(i)).join("\n")
197
+ }
@@ -0,0 +1,19 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { applySteeringEdits } from "./Edits.js"
3
+
4
+ describe("applySteeringEdits", () => {
5
+ it("splices edits back-to-front so earlier offsets stay valid", () => {
6
+ const content = "abc\ndef\n"
7
+ const edits = [
8
+ {
9
+ range: { start: { line: 0, character: 1 }, end: { line: 0, character: 1 } },
10
+ newText: "X",
11
+ },
12
+ {
13
+ range: { start: { line: 1, character: 0 }, end: { line: 1, character: 0 } },
14
+ newText: "Y",
15
+ },
16
+ ]
17
+ expect(applySteeringEdits(content, edits)).toBe("aXbc\nYdef\n")
18
+ })
19
+ })
@@ -0,0 +1,20 @@
1
+ import type { SteeringEdit } from "./SteeringFormat.js"
2
+
3
+ /** Splices `edits` into `content`, sorted last-to-first so an earlier range's offset is never invalidated by a later edit. Positions are 0-based line/character over `\n`-split lines. */
4
+ export const applySteeringEdits = (content: string, edits: readonly SteeringEdit[]): string => {
5
+ const lines = content.split("\n")
6
+ const toOffset = (pos: { readonly line: number; readonly character: number }): number => {
7
+ let offset = 0
8
+ for (let i = 0; i < pos.line; i += 1) offset += (lines[i]?.length ?? 0) + 1
9
+ return offset + pos.character
10
+ }
11
+ const sorted = [...edits].sort((a, b) => toOffset(b.range.start) - toOffset(a.range.start))
12
+ let result = content
13
+ for (const edit of sorted) {
14
+ result =
15
+ result.slice(0, toOffset(edit.range.start)) +
16
+ edit.newText +
17
+ result.slice(toOffset(edit.range.end))
18
+ }
19
+ return result
20
+ }