@try-works/dsh-recursive-mode 0.4.6 → 0.4.8

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.
@@ -25,6 +25,27 @@ export interface DocLine {
25
25
  text: string;
26
26
  /** table: data rows (header separator row dropped); first row is the header. */
27
27
  cells?: string[][];
28
+ /**
29
+ * li: the item WAS a `- [ ]` / `- [x]` task box, and whether it was ticked.
30
+ *
31
+ * ⚠ THIS IS A FIELD ON `li`, NOT A NEW KIND, ON PURPOSE. A task box is a list item — it keeps the bullet
32
+ * layout, the line index and the search behaviour every existing `li` has — and the ONE thing the reader
33
+ * must be able to see that a plain string cannot carry is the BOX ITSELF. The scaffolded Phase 0 template
34
+ * ships seven unticked boxes, so "is this still the template?" is partly a question about these marks.
35
+ *
36
+ * ⚠ AND THE BOX IS NOT LEFT IN `text`. `text` is the item's CONTENT, exactly the shape the base parser
37
+ * already produces for a plain bullet, so `search`/`copy` and every other consumer of a line's text see the
38
+ * words rather than the syntax. The tick survives as this field, and the renderer draws the mark back.
39
+ */
40
+ checked?: boolean;
41
+ /**
42
+ * plain: this line is a `Coverage:` / `Approval:` GATE reading, and how it reads.
43
+ *
44
+ * Same reasoning as `checked`: a gate line is a line of text, so it stays `plain` and gains the one fact
45
+ * the renderer cannot re-derive — whether it currently says PASS or FAIL, which is exactly what a person
46
+ * must be able to see before approving the document that contains it.
47
+ */
48
+ gate?: 'pass' | 'fail';
28
49
  }
29
50
 
30
51
  /** Inline segment parsed from line text (bold / code span / link). */
@@ -34,10 +55,33 @@ export interface InlineSegment {
34
55
  href?: string;
35
56
  }
36
57
 
58
+ /**
59
+ * A list item: the text AFTER its marker. The marker is consumed here exactly as the base parser consumed
60
+ * it — `text` is the item's CONTENT, and the renderer draws the `- ` / box back from `kind` + `checked`.
61
+ */
62
+ const BULLET_RE = /^\s*[-*]\s+(.+)$/;
63
+
64
+ /**
65
+ * A task box (`[ ]`, `[x]`, `[X]`) at the front of a list item, split into its tick and the rest of the item.
66
+ *
67
+ * The tick is the one fact a reader of the document cannot recover from the text alone once the box has been
68
+ * recognised, so it travels as `checked`; everything after it is the item's content, as for any other bullet.
69
+ */
70
+ const TASK_BOX_RE = /^\[([ xX])\]\s*(.*)$/;
71
+
72
+ /** A gate reading: `Coverage: FAIL` / `Approval: PASS`, as `run-spec.ts` reads the same lines. */
73
+ const GATE_RE = /^\s*(?:Coverage|Approval)\s*:\s*(PASS|FAIL)\s*$/i;
74
+
37
75
  /**
38
76
  * Markdown -> line tokens. Base is parsePlan (blank/h1-h4/li/plain, MIT),
39
77
  * extended for fenced code blocks (one code line per block) and pipe tables
40
78
  * (one table line per block, header separator row dropped).
79
+ *
80
+ * AND EXTENDED FOR WHAT THE RUN ARTIFACTS ACTUALLY CONTAIN — the task boxes and gate readings the
81
+ * scaffolded `00-requirements.md` ships (`- [ ] …`, `Coverage: FAIL`, `Approval: FAIL`), because a preview
82
+ * that renders an unticked box and a FAIL gate as generic body text hides the two marks a person who is
83
+ * being asked to approve the document most needs to see. Both are additive FIELDS on the existing `li` and
84
+ * `plain` kinds, so no line is retyped, no character is dropped, and every existing caller keeps working.
41
85
  */
42
86
  export function parseDoc(plan: string): DocLine[] {
43
87
  const raw = String(plan == null ? '' : plan).split('\n');
@@ -83,9 +127,21 @@ export function parseDoc(plan: string): DocLine[] {
83
127
  i += 1;
84
128
  continue;
85
129
  }
86
- // list item
87
- const li = /^\s*[-*]\s+(.+)$/.exec(line);
88
- if (li) { out.push({ kind: 'li', text: li[1] }); i += 1; continue; }
130
+ // list item — a task box is CLASSIFIED (its tick kept as `checked`) and its text is the item's CONTENT,
131
+ // which is the shape the base parser already produces for a bullet. The marker and the box are DRAWN by
132
+ // the renderer from `kind` + `checked`, so no line is retyped and the reader still sees `- [ ] item`.
133
+ const item = BULLET_RE.exec(line);
134
+ if (item) {
135
+ const box = TASK_BOX_RE.exec(item[1]);
136
+ out.push(box === null
137
+ ? { kind: 'li', text: item[1] }
138
+ : { kind: 'li', text: box[2], checked: box[1] !== ' ' });
139
+ i += 1;
140
+ continue;
141
+ }
142
+ // gate reading: `Coverage: FAIL` / `Approval: PASS`
143
+ const gate = GATE_RE.exec(line);
144
+ if (gate) { out.push({ kind: 'plain', text: line, gate: gate[1].toUpperCase() === 'FAIL' ? 'fail' : 'pass' }); i += 1; continue; }
89
145
  out.push({ kind: 'plain', text: line });
90
146
  i += 1;
91
147
  }
@@ -137,12 +193,41 @@ function inlineNodes(segments: InlineSegment[], baseKey: string): ReactNode[] {
137
193
  });
138
194
  }
139
195
 
196
+ /**
197
+ * The mark of a task box.
198
+ *
199
+ * ⚠ THE MARK REPLACES `[ ]` / `[x]` IN PLACE, GLYPH FOR GLYPH. The parser hands over the item's content
200
+ * without the box, so what the reader sees is `- [ ] item` where the document says `- [x] item`: same line,
201
+ * same position, same length of reading — and the box is still legible as a box rather than as an assertion
202
+ * about the item.
203
+ *
204
+ * ⚠ AND THE TICK IS CARRIED TWICE — once as that glyph for the eye and once as text, visually hidden, for the
205
+ * ear — because the two bracket forms are read inconsistently by screen readers, and the whole point of the
206
+ * mark is that "this box is not ticked" survives every way of reading it. This span carries NO separator of
207
+ * its own: the item's text follows it directly, separated by the leading space on that text (see
208
+ * `lineElement`), so that neither side of the boundary has a trailing space to lose.
209
+ */
210
+ function todoMark(line: DocLine, key: string): ReactNode {
211
+ const done = line.checked === true;
212
+ const cls = 'rec-doc-todo-check' + (done ? ' rec-doc-todo-check-on' : ' rec-doc-todo-check-off');
213
+ return createElement('span', { key, className: cls, title: done ? 'done' : 'not done' },
214
+ createElement('span', { 'aria-hidden': 'true' }, done ? '[x]' : '[ ]'),
215
+ createElement('span', { className: 'rec-doc-sr' }, done ? 'done:' : 'not done:'),
216
+ );
217
+ }
218
+
140
219
  /**
141
220
  * Render one parsed line as a React element. Headings/bullets get parsePlan
142
221
  * sizing; code/table get block layout; inline markup applies to plain-ish text.
222
+ *
223
+ * ⚠ ONE RENDERER, TWO READERS. `DocViewer` and the run-start spec sheet's preview both come through here,
224
+ * so a mark that means "unticked box" or "gate reads FAIL" cannot mean one thing in the phase-doc viewer and
225
+ * another in the document a person is approving. Exported for that reason alone.
143
226
  */
144
- function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
227
+ export function lineElement(line: DocLine, i: number, isCurrent = false): ReactNode {
145
228
  const cls = 'rec-doc-line rec-doc-' + line.kind + (isCurrent ? ' rec-doc-line-current' : '');
229
+ const gateCls = line.gate === undefined ? '' : ' rec-doc-gate rec-doc-gate-' + line.gate;
230
+ const todoCls = line.checked === undefined ? '' : ' rec-doc-todo' + (line.checked ? ' rec-doc-todo-done' : ' rec-doc-todo-open');
146
231
  if (line.kind === 'blank') return createElement('div', { key: i, 'data-line': String(i), className: cls }, null);
147
232
  if (line.kind === 'code') return createElement('pre', { key: i, 'data-line': String(i), className: cls + ' rec-doc-pre' }, createElement('code', { className: 'rec-doc-code' }, line.text));
148
233
  if (line.kind === 'table') {
@@ -157,8 +242,43 @@ function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
157
242
  );
158
243
  }
159
244
  const nodes = inlineNodes(parseInline(line.text), String(i));
160
- if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '•'), createElement('span', { className: 'rec-doc-li-text' }, nodes));
161
- return createElement('div', { key: i, 'data-line': String(i), className: cls }, nodes);
245
+ // ⚠ THE PREVIEW LINE IS THE DOCUMENT'S OWN LINE. The parser keeps list content MARKER-FREE (the base
246
+ // parser's shape), so the renderer puts the marker back: `- ` for a bullet, and a drawn box IN PLACE OF
247
+ // the `[ ]` / `[x]` for a task box. That is what lets a reader check the preview against the source and
248
+ // see at a glance that an unticked box is unticked.
249
+ //
250
+ // ⚠ AND THE SEPARATOR IS A NON-BREAKING SPACE WRITTEN AS AN ESCAPE, ON THE ITEM'S SIDE OF THE MARK.
251
+ // Measured, twice: a text node that ENDS in a space loses it (so a `'- '` bullet glyph renders as `-`,
252
+ // and a minifier carries that through to the shipped bundle, turning every bullet into `-item`), and a
253
+ // plain leading space is normalised away by the JSX transform before React ever sees it. An ESCAPED
254
+ // non-breaking space is neither trailing nor transformable, so it is what these separators are.
255
+ const SPACER = '\u00A0';
256
+ if (line.kind === 'li' && line.checked !== undefined) {
257
+ return createElement('div', { key: i, 'data-line': String(i), className: cls + todoCls },
258
+ createElement('span', { className: 'rec-doc-bullet' }, '-'),
259
+ todoMark(line, 'todo-' + String(i)),
260
+ createElement('span', { className: 'rec-doc-li-text' }, SPACER, nodes));
261
+ }
262
+ if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '-'), createElement('span', { className: 'rec-doc-li-text' }, SPACER, nodes));
263
+ return createElement('div', { key: i, 'data-line': String(i), className: cls + gateCls }, nodes);
264
+ }
265
+
266
+ /**
267
+ * The parsed lines as elements — the preview built from `parseDoc` + `lineElement`, with no shell of its own.
268
+ *
269
+ * ⚠ THIS IS WHAT MAKES A SECOND RENDERER UNNECESSARY. Any surface that wants to show a run artifact as a
270
+ * PREVIEW (the phase-doc viewer's body, the run-start spec sheet's document body) renders these nodes inside
271
+ * whatever frame it owns, so the markdown is parsed and drawn exactly once in the plugin. `keyBase` namespaces
272
+ * the React keys when several of these are on screen at once; `current` is the vim cursor line, which the
273
+ * spec sheet never sets.
274
+ */
275
+ export function PreviewLines({ lines, keyBase = 'doc', current = -1 }: { lines: DocLine[]; keyBase?: string; current?: number }): ReactNode {
276
+ return createElement('div', { className: 'rec-doc-lines', 'data-preview-lines': String(lines.length) },
277
+ ...lines.map((line, i) => createElement(
278
+ 'div',
279
+ { key: keyBase + '-line-' + String(i), className: 'rec-doc-line-wrap' },
280
+ lineElement(line, i, i === current),
281
+ )));
162
282
  }
163
283
 
164
284
  /**
@@ -269,11 +389,10 @@ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: Doc
269
389
  }
270
390
  };
271
391
 
272
- const matchSet = new Set(matches);
273
- const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
274
-
275
- const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
276
-
392
+ // NOTE: `n`/`N` move the cursor to the matching line, which is marked `rec-doc-line-current` by
393
+ // `PreviewLines` below. The lines themselves are NOT individually match-highlighted — they were not
394
+ // before this file gained a shared preview renderer either, and inventing a highlight here would change
395
+ // how the phase-doc viewer draws a document as a side effect of the run-start spec sheet's work.
277
396
  const searchBar = searchOpen ? createElement('div', { className: 'rec-doc-search' },
278
397
  createElement('input', { className: 'rec-doc-search-input', value: query, placeholder: '/ search doc…', onChange: onSearchChange, onKeyDown: onSearchKey }),
279
398
  createElement('span', { className: 'rec-doc-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : '')),
@@ -296,7 +415,7 @@ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: Doc
296
415
  createElement('div', { className: 'rec-doc-body' },
297
416
  text === null && error === null ? createElement('p', { className: 'rec-doc-text' }, 'Loading doc…') : null,
298
417
  error !== null ? createElement('p', { className: 'rec-doc-text rec-doc-error' }, error) : null,
299
- text !== null ? lineEls : null,
418
+ text !== null ? createElement(PreviewLines, { lines: docLines, keyBase: 'doc', current: cursor }) : null,
300
419
  ),
301
420
  createElement('footer', { className: 'rec-doc-footer' },
302
421
  createElement('div', { className: statusCls, role: 'status' }, statusText),
@@ -22,6 +22,7 @@ import { isRecursivePreset, currentWorkspacePath } from './contract.ts'
22
22
  import { Board } from './board.tsx'
23
23
  import { Inspector } from './inspector.tsx'
24
24
  import { RecursiveSettings, RecursiveSettingsLive, type RecursiveSettingsSeatProps } from './settings.tsx'
25
+ import { RunStartSpecSheet, RUN_START_TOOL_NAME } from './spec-sheet.tsx'
25
26
  import { useLiveProjection } from './use-live.ts'
26
27
  import { boardState, useBoardState } from './open-state.ts'
27
28
  import { injectBoardStyles } from './styles.ts'
@@ -138,6 +139,17 @@ export function registerSlots(ctx: ClientContext): () => void {
138
139
  return createElement(RecursiveBoardOverlay, { useSessions, useWorkspaces: props?.useWorkspaces })
139
140
  })))
140
141
 
142
+ // RUN-START SPEC SHEET (tool.call.toolview, keyed by tool name). The seat that exists exactly while a
143
+ // `recursive_ask` call is on screen — and the only one that can render BESIDE the question card, because
144
+ // the composer is a chain and the run-start gate's pending interaction is a question, not an approval (see
145
+ // the header of spec-sheet.tsx for the three structural reasons `conversation.approval.detail` cannot be
146
+ // it). The view returns null for every other gate, so `tdd-mode` / `qa-signoff` / `gate-block` calls keep
147
+ // the generic tool row they have today.
148
+ disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
149
+ name: 'tool.call.toolview',
150
+ key: RUN_START_TOOL_NAME,
151
+ }, RunStartSpecSheet)))
152
+
141
153
  // Settings section (root scope, always present; no gate — configuration is always available).
142
154
  // The seat receives the shell's `close` PLUS the root standard kit (useSessions/useWorkspaces,
143
155
  // scoped-slots standardProps), so the panel can subscribe to the SAME live route the board
@@ -0,0 +1,325 @@
1
+ /**
2
+ * Pure display model for the RUN-START SPEC SHEET.
3
+ *
4
+ * WHY THIS MODULE EXISTS. `src/client/spec-sheet.tsx` renders the Phase 0 document beside the run-start
5
+ * question so a person can read what they are approving. Everything that DECIDES what the sheet says is
6
+ * here instead: reading the tool call's own arguments, classifying the document, and choosing the fetch
7
+ * state. It is pure (no React, no fs, no session window), which is what lets the spec drive every branch —
8
+ * a stub, a filled document, a missing file, a failed route, a call with no runId — without a browser.
9
+ *
10
+ * ⚠ THE VERDICT COMES FROM THE TEXT, NEVER FROM THE FETCH. `loading`, `absent`, `error` and `loaded` are
11
+ * four different facts, and a single "not loaded" would collapse them — which is how a client comes to
12
+ * render an empty box for a failure and a failure for a document nobody has written yet.
13
+ *
14
+ * ⚠ AND IT IS NODE-FREE. This file is reached by the BROWSER bundle. Reading the artifact belongs to the
15
+ * server half (`run-start.ts`, `recursive_ask.tool.ts`); this half only ever DISPLAYS what the read-only
16
+ * `/doc` route returned.
17
+ */
18
+ import { classifyArtifact, type ArtifactMarkerHit, type ArtifactVerdict } from '../run-spec.ts'
19
+
20
+ /**
21
+ * The wire name of the tool whose call row the sheet replaces.
22
+ *
23
+ * `tool.call.toolview` is a KEYED seat dispatched by this exact string, so a typo renders nothing at all
24
+ * rather than rendering wrongly — the failure mode the harness documents for a keyed seat.
25
+ */
26
+ export const RUN_START_TOOL_NAME = 'recursive_ask'
27
+
28
+ /**
29
+ * The Phase 0 artifact the run-start decision is recorded in.
30
+ *
31
+ * ⚠ DUPLICATED DELIBERATELY, AND PINNED BY A TEST. `run-start.ts` holds the same string, but importing it
32
+ * here would pull `node:fs` / `node:crypto` into the browser bundle through `status.ts` and take the page
33
+ * down for a constant. `tests/spec-sheet.spec.ts` asserts this value EQUALS `RUN_START_ARTIFACT`, so the copy
34
+ * cannot drift; the client only ever reads through it and never writes.
35
+ */
36
+ export const RUN_START_SPEC_FILE = '00-requirements.md'
37
+
38
+ /** The gate id whose question this sheet accompanies. */
39
+ export const RUN_START_GATE_ID = 'run-start'
40
+
41
+ /** The tool call's own arguments, as far as the client reads them. */
42
+ export interface RecursiveAskArgs {
43
+ /** The gate id the call asked for; null when the call did not name one. */
44
+ gate: string | null
45
+ /** The run whose spec the call is about; null when absent or blank. */
46
+ runId: string | null
47
+ /** The artifact the call named, if any (the run-start gate never lets a caller redirect it). */
48
+ artifact: string | null
49
+ /** The approval label the call carried, when it carried one. */
50
+ answer: string | null
51
+ }
52
+
53
+ /** The all-null argument set: what a malformed, truncated, or absent payload yields. */
54
+ const NO_ARGS: RecursiveAskArgs = { gate: null, runId: null, artifact: null, answer: null }
55
+
56
+ /**
57
+ * Read the arguments out of the raw JSON the model dispatched.
58
+ *
59
+ * A malformed or empty payload yields all-nulls rather than throwing: a truncated mid-stream argument string
60
+ * is a normal sight on this seat (the harness exposes preparing calls with no arguments at all), and a view
61
+ * that threw on one would take the transcript down with it.
62
+ */
63
+ export function parseRecursiveAskArgs(argsRaw: string | null): RecursiveAskArgs {
64
+ if (argsRaw === null || argsRaw.trim() === '') return { ...NO_ARGS }
65
+ let parsed: unknown
66
+ try {
67
+ parsed = JSON.parse(argsRaw)
68
+ } catch {
69
+ return { ...NO_ARGS }
70
+ }
71
+ if (typeof parsed !== 'object' || parsed === null) return { ...NO_ARGS }
72
+ const record = parsed as Record<string, unknown>
73
+ const str = (value: unknown): string | null => (typeof value === 'string' && value.trim() !== '' ? value.trim() : null)
74
+ return { gate: str(record.gate), runId: str(record.runId), artifact: str(record.artifact), answer: str(record.answer) }
75
+ }
76
+
77
+ /**
78
+ * The parts of the owner's call block this sheet reads.
79
+ *
80
+ * Structurally typed rather than imported: the toolview owner passes one of three phase shapes
81
+ * (`preparing` carries no arguments, `start` carries `argsRaw`, `result` carries the paired call — which is
82
+ * NULL when window truncation left the call outside the loaded window).
83
+ */
84
+ export type SpecSheetBlock =
85
+ | { readonly phase: 'preparing' }
86
+ | { readonly phase: 'start'; readonly argsRaw: string }
87
+ | { readonly phase: 'result'; readonly call?: { readonly name: string; readonly argsRaw: string } | null }
88
+ | null
89
+
90
+ /** What the sheet could read from the call: its arguments, or WHY it could not. */
91
+ export type CallRead =
92
+ /** The call's arguments were dispatched and parsed. */
93
+ | { readonly ok: true; readonly args: RecursiveAskArgs }
94
+ /** No block was supplied at all. */
95
+ | { readonly ok: false; readonly reason: 'missing' }
96
+ /** The call is still PREPARING: the owner dispatches no arguments until the call starts. */
97
+ | { readonly ok: false; readonly reason: 'preparing' }
98
+ /** The call settled, but the loaded window left its call head outside, so no arguments are available. */
99
+ | { readonly ok: false; readonly reason: 'truncated' }
100
+
101
+ /**
102
+ * Read the call's arguments out of whichever phase the owner supplied.
103
+ *
104
+ * ⚠ THE THREE FAILURES ARE KEPT APART. "No block", "the arguments have not been dispatched yet" and "the
105
+ * window truncated the call" are three different facts, and a client that collapsed them into one empty
106
+ * argument set would tell a person the call named no run — a claim about the CALLER, made on evidence about
107
+ * the CLIENT.
108
+ */
109
+ export function readCall(block: SpecSheetBlock | undefined): CallRead {
110
+ if (block === null || block === undefined) return { ok: false, reason: 'missing' }
111
+ if (block.phase === 'preparing') return { ok: false, reason: 'preparing' }
112
+ if (block.phase === 'start') return { ok: true, args: parseRecursiveAskArgs(block.argsRaw) }
113
+ const carried = block.call ?? null
114
+ return carried === null ? { ok: false, reason: 'truncated' } : { ok: true, args: parseRecursiveAskArgs(carried.argsRaw) }
115
+ }
116
+
117
+ /** Is this the run-start gate? Everything else keeps the generic tool row. */
118
+ export function isRunStartCall(args: RecursiveAskArgs): boolean {
119
+ return args.gate === RUN_START_GATE_ID
120
+ }
121
+
122
+ /** Whether this call is one the sheet claims at all: the run-start gate, with a run named. */
123
+ export function isSpecSheetCall(read: CallRead): boolean {
124
+ return read.ok && isRunStartCall(read.args) && read.args.runId !== null
125
+ }
126
+
127
+ /** Where the fetch of the document stands. */
128
+ export type SpecFetchState = 'loading' | 'loaded' | 'absent' | 'error'
129
+
130
+ /** The fetch outcome, as the component holds it. */
131
+ export interface SpecFetch {
132
+ state: SpecFetchState
133
+ text?: string | null
134
+ error?: string | null
135
+ }
136
+
137
+ /** Everything the sheet renders from — pure data, so the spec can assert it without a renderer. */
138
+ export interface SpecSheetModel {
139
+ /** The run the call names, or null. */
140
+ runId: string | null
141
+ /** The workspace root the live route resolved, or null while it has not answered. */
142
+ root: string | null
143
+ /** The artifact this sheet shows. */
144
+ file: string
145
+ /** The fetch's state. */
146
+ state: SpecFetchState
147
+ /** Why an `error` state happened, in the route's own words. */
148
+ error: string | null
149
+ /** The document's text VERBATIM, or null when there is nothing to show. */
150
+ text: string | null
151
+ /** `run-spec.ts`'s verdict over that text, or null when there is no text. */
152
+ verdict: ArtifactVerdict | null
153
+ /** The marker hits, in line order (empty when filled, and when there is no text). */
154
+ evidence: ArtifactMarkerHit[]
155
+ /** The call's own arguments, so the sheet can show which decision is being asked. */
156
+ args: RecursiveAskArgs
157
+ }
158
+
159
+ /**
160
+ * Build the sheet's model from what the client actually has.
161
+ *
162
+ * ⚠ THE TEXT IS ONLY EVER TAKEN FROM A `loaded` FETCH. A stale body from a previous run is not shown against
163
+ * a new run id, because "this is the document" is the one claim this seat exists to make truthfully.
164
+ */
165
+ export function specSheetModel(input: {
166
+ args: RecursiveAskArgs
167
+ root: string | null
168
+ fetch: SpecFetch
169
+ file?: string
170
+ }): SpecSheetModel {
171
+ const file = input.file ?? RUN_START_SPEC_FILE
172
+ const text = input.fetch.state === 'loaded' ? input.fetch.text ?? null : null
173
+ const verdict = text === null ? null : classifyArtifact(text)
174
+ return {
175
+ runId: input.args.runId,
176
+ root: input.root,
177
+ file,
178
+ state: input.fetch.state,
179
+ error: input.fetch.error ?? null,
180
+ text,
181
+ verdict: verdict === null ? null : verdict.verdict,
182
+ evidence: verdict === null ? [] : verdict.hits,
183
+ args: input.args,
184
+ }
185
+ }
186
+
187
+ /** The document's path, as it should be PRINTED (never as it is fetched). */
188
+ export function specPath(runId: string | null, root: string | null, file: string): string {
189
+ const at = '.recursive/run/' + (runId ?? '<runId>') + '/' + file
190
+ return root === null || root.trim() === '' ? at : at + ' (in ' + root + ')'
191
+ }
192
+
193
+ /* ============================ preview / raw source ============================ */
194
+
195
+ /**
196
+ * How the sheet is showing the document.
197
+ *
198
+ * ⚠ TWO MODES, AND THE SECOND ONE IS NOT A CONVENIENCE. The rendered preview is what makes the document
199
+ * REVIEWABLE — reading `##` and `- [ ]` is not reading a spec — but a person is still being asked to approve
200
+ * the document, and a preview is an interpretation. `raw` is the escape hatch that lets them check the
201
+ * interpretation against the bytes, so "the preview is not paraphrasing or hiding anything" is a claim they
202
+ * can verify rather than one they have to take on trust.
203
+ */
204
+ export type SpecViewMode = 'preview' | 'raw'
205
+
206
+ /** The mode a freshly mounted sheet opens in: the rendered document. */
207
+ export const SPEC_DEFAULT_MODE: SpecViewMode = 'preview'
208
+
209
+ /** The toggle's own label, ALWAYS naming the mode that is on screen — never the one a click would reach. */
210
+ export const SPEC_MODE_LABEL: Record<SpecViewMode, string> = {
211
+ preview: 'Showing: rendered preview',
212
+ raw: 'Showing: raw source',
213
+ }
214
+
215
+ /** What the toggle does next, in the imperative, so the control reads as an action and its state reads apart. */
216
+ export const SPEC_TOGGLE_LABEL: Record<SpecViewMode, string> = {
217
+ preview: 'View source',
218
+ raw: 'Back to preview',
219
+ }
220
+
221
+ /** One line describing what is on screen, in both modes, for the announced status. */
222
+ export const SPEC_MODE_NOTE: Record<SpecViewMode, string> = {
223
+ preview: 'The document is rendered here as a preview: headings, lists, code and tables are drawn as such.',
224
+ raw: 'The document is shown here VERBATIM — the exact bytes, with nothing rendered and nothing removed.',
225
+ }
226
+
227
+ /** The other mode — what one press of the toggle reaches. */
228
+ export function otherMode(mode: SpecViewMode): SpecViewMode {
229
+ return mode === 'preview' ? 'raw' : 'preview'
230
+ }
231
+
232
+ /** The toggle as a pure control description: its label, its pressed state and what it will do. */
233
+ export interface SpecViewToggle {
234
+ /** The mode currently on screen. */
235
+ mode: SpecViewMode
236
+ /** The button's accessible name — the ACTION it performs. */
237
+ action: string
238
+ /** Whether the control is pressed, i.e. whether the verbatim source is the thing on screen. */
239
+ pressed: boolean
240
+ /** The line a status region announces, which names the mode CURRENTLY on screen. */
241
+ announce: string
242
+ }
243
+
244
+ /**
245
+ * Describe the toggle for a mode.
246
+ *
247
+ * ⚠ `pressed` IS DERIVED FROM THE MODE, NEVER STORED BESIDE IT. A second boolean that could disagree with the
248
+ * mode is how a control comes to say "pressed" while the rendered document is on screen — and this is the one
249
+ * control whose whole job is to tell the reader which of the two things they are looking at.
250
+ */
251
+ export function specViewToggle(mode: SpecViewMode): SpecViewToggle {
252
+ return {
253
+ mode,
254
+ action: SPEC_TOGGLE_LABEL[mode],
255
+ pressed: mode === 'raw',
256
+ announce: SPEC_MODE_LABEL[mode] + '. ' + SPEC_MODE_NOTE[mode],
257
+ }
258
+ }
259
+
260
+ /**
261
+ * What the body should be built from, so the two modes cannot be confused for one another.
262
+ *
263
+ * `preview` carries the TEXT to parse (parsing happens in the component, from the one parser this plugin
264
+ * has); `raw` carries the TEXT to print. Both are the same bytes: neither mode reads a different source.
265
+ */
266
+ export type SpecBodyContent =
267
+ | { readonly mode: 'preview'; readonly text: string }
268
+ | { readonly mode: 'raw'; readonly text: string }
269
+
270
+ /**
271
+ * Decide the body content for a document text and a mode.
272
+ *
273
+ * ⚠ NULL TEXT IS NOT `''`. A `null` document is one that was never loaded, and it must not be printed as an
274
+ * empty document — "there is nothing here" and "the document is empty" are different claims, and this seat
275
+ * exists to make claims a reader can trust.
276
+ */
277
+ export function specBodyContent(text: string | null, mode: SpecViewMode): SpecBodyContent | null {
278
+ if (text === null) return null
279
+ return mode === 'raw' ? { mode: 'raw', text } : { mode: 'preview', text }
280
+ }
281
+
282
+ /** The verdict in the plainest words available — no hedging, and nothing that reads as encouragement. */
283
+ export const UNFILLED_NOTICE = 'This document is still the UNFILLED TEMPLATE. There is no spec to approve yet.'
284
+
285
+ /** The same verdict as one phrase, for a status/data attribute. */
286
+ export const UNFILLED_SHORT = 'unfilled template'
287
+
288
+ /** What the sheet says when the block it was given carries no dispatched call. */
289
+ export const NO_CALL_NOTICE =
290
+ 'This panel could not read the tool call it belongs to, so it cannot say which run spec the question is about.'
291
+
292
+ /** What the sheet says while the call is still preparing (its arguments are not dispatched yet). */
293
+ export const PREPARING_NOTICE =
294
+ 'This tool call has not been dispatched yet, so its arguments — and the run it names — are not available.'
295
+
296
+ /** What the sheet says when the loaded window truncated the call head away. */
297
+ export const TRUNCATED_NOTICE =
298
+ 'The loaded window left this call\'s own arguments outside it, so the run spec it names cannot be resolved here.'
299
+
300
+ /** The labels the run-start gate offers, echoed so the sheet can state what is being decided. */
301
+ export const RUN_START_APPROVE_LABEL = 'Start run'
302
+ export const RUN_START_HOLD_LABEL = 'Hold'
303
+
304
+ /** The gate's own question, quoted verbatim rather than paraphrased. */
305
+ export const RUN_START_QUESTION =
306
+ 'Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.'
307
+
308
+ /** What each offered label means, quoted from the gate's own option descriptions. */
309
+ export const RUN_START_OPTION_MEANING =
310
+ '“' + RUN_START_APPROVE_LABEL + '” records the approval and arms the run goal; “' + RUN_START_HOLD_LABEL
311
+ + '” leaves the spec inert: no run goal, no autonomous rounds.'
312
+
313
+ /**
314
+ * The recorded decision, when this call carries one.
315
+ *
316
+ * ⚠ A `Hold` IS NOT AN APPROVAL and is not printed as one. The test is the same VALUE test the server makes
317
+ * (`run-start.ts::isRunStartApproval` — the approving label, not the mere presence of a decision line), so
318
+ * the sheet can never describe a hold as an approval.
319
+ */
320
+ export function decisionLine(answer: string | null): string | null {
321
+ if (answer === null) return null
322
+ return answer.trim() === RUN_START_APPROVE_LABEL
323
+ ? 'This call records: ' + answer + ' — the approving label, which arms the run goal.'
324
+ : 'This call records: ' + answer + ' — not the approving label, so no run goal is armed.'
325
+ }