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