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