@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.
- package/README.md +11 -4
- package/lib/client/contract.d.ts +10 -0
- package/lib/client/doc-viewer.d.ts +50 -0
- package/lib/client/spec-sheet-view.d.ts +237 -0
- package/lib/client/spec-sheet.d.ts +103 -0
- package/lib/client/use-live.d.ts +17 -1
- package/lib/client.js +988 -14
- package/lib/delegation.d.ts +35 -1
- package/lib/errors.d.ts +13 -0
- package/lib/index.js +164 -9
- package/lib/run-spec.d.ts +78 -0
- package/lib/run-start.d.ts +27 -0
- package/package.json +1 -1
- package/src/client/contract.ts +10 -0
- package/src/client/doc-viewer.tsx +131 -12
- package/src/client/slots.ts +12 -0
- package/src/client/spec-sheet-view.ts +325 -0
- package/src/client/spec-sheet.tsx +409 -0
- package/src/client/styles.ts +298 -1
- package/src/client/use-live.ts +23 -2
- package/src/delegation.ts +58 -4
- package/src/errors.ts +13 -0
- package/src/recursive_ask.tool.ts +28 -1
- package/src/run-spec.ts +148 -0
- package/src/run-start.ts +59 -0
- package/src/runtime.ts +78 -24
|
@@ -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
|
-
|
|
88
|
-
|
|
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
|
|
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
|
-
|
|
161
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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 ?
|
|
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),
|
package/src/client/slots.ts
CHANGED
|
@@ -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
|
+
}
|