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

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.
@@ -0,0 +1,409 @@
1
+ /**
2
+ * THE RUN-START SPEC SHEET — the document beside the question it decides.
3
+ *
4
+ * THE DEFECT THIS ANSWERS. The owner asked for a run spec, the plugin scaffolded one and raised the
5
+ * `run-start` gate — "start this run or hold?" — and NOBODY WAS SHOWN THE DOCUMENT:
6
+ * *"the card ui for accepting the spec appeared, but i was never shown the spec before that so how could i
7
+ * approve if i havent seen it"*. The gate was decidable before it was readable.
8
+ *
9
+ * ⚠ WHERE THIS RENDERS, AND WHY NOT IN `conversation.approval.detail`. That seat was the first candidate —
10
+ * its catalog summary reads "Optional detail for the Tool call correlated with an approval request" — and it
11
+ * is the WRONG one, for a reason that is structural rather than stylistic:
12
+ *
13
+ * 1. `conversation.composer` is a CHAIN slot: its entries' selectors run in order and the FIRST non-null
14
+ * match renders. `ui-approval` claims it with `select: pendingInteraction instanceof PendingApproval`
15
+ * and declares `conversation.approval.detail` as its child; `ui-user-questions` claims it with
16
+ * `select: pendingInteraction instanceof PendingQuestion` and declares
17
+ * `conversation.plan-review.actions` instead. They are two mutually exclusive cells of ONE chain.
18
+ * 2. The run-start gate is asked through the USER-QUESTIONS channel (`askRunStartDirectly` →
19
+ * `channel.ask(...)`, correlated by `wait: { callId: exec.callId }`), so while it is pending the
20
+ * pending interaction IS a `PendingQuestion` — the question composer owns the composer, and
21
+ * `conversation.approval.detail` is not mounted at all. A sheet registered there would render on the
22
+ * one occasion it is not needed and never on the one it is.
23
+ * 3. `tool.call.toolview` keyed by tool name is the seat that exists exactly while THIS tool call is on
24
+ * screen, is unclaimed for `recursive_ask` (the harness's own `ask_user_question` IS claimed, which is
25
+ * the parallel that matters), and hands the view its own `callId`, the session `cwd` and the frozen
26
+ * call block — which is how the `runId` is read out of the call's own arguments.
27
+ *
28
+ * WHAT IT SHOWS — AND THE DEFECT THAT CHANGED IT. This seat first showed the ACTUAL bytes of
29
+ * `.recursive/run/<runId>/00-requirements.md` as RAW TEXT, on the reasoning that verbatim is the honest
30
+ * thing to print. The honest answer to "is that a preview?" was NO, and the owner was being asked to APPROVE
31
+ * what they read: *reading `##` headings and pipe-table syntax is not reviewing a spec*. So the default is now
32
+ * a RENDERED PREVIEW — headings, lists, fenced code and pipe tables drawn as such — and the verbatim text is
33
+ * one press away behind "View source".
34
+ *
35
+ * ⚠ AND THE RAW MODE IS NOT DECORATION. A preview is an INTERPRETATION, and this seat asks a person to
36
+ * approve a document on the strength of it. The raw view exists so that "the preview is not paraphrasing or
37
+ * hiding anything" is a claim the reader can CHECK against the bytes rather than one they must take from the
38
+ * renderer. Its state is carried by `aria-pressed` and repeated in a `role="status"` line that names the mode
39
+ * on screen, so the mode is announced and not merely coloured.
40
+ *
41
+ * ⚠ ONE RENDERER, NOT TWO. The markdown is parsed and drawn by `doc-viewer.tsx` — `parseDoc` and its
42
+ * `PreviewLines` — the same code path the phase-doc viewer uses. A second markdown renderer in one plugin
43
+ * would be a second answer to "what does this document say", which is the defect this plugin exists to
44
+ * refuse. What `parseDoc` could not carry for a real `00-requirements.md` (the task boxes and the gate
45
+ * readings the template ships) was added THERE, for both readers, rather than forked here.
46
+ *
47
+ * WHAT IT STILL REFUSES TO DO. It is READ-ONLY (R9): the sheet has no approve/hold control, because a second
48
+ * control that looks like the question card's would be a second way to answer one question — the sheet may
49
+ * change HOW the document is shown, never WHETHER it is approved. And the honesty rule of
50
+ * `settings-view.ts` is unchanged: when the artifact is still the unfilled template the rendered view says so
51
+ * loudly and the unfilled marks stay visible AS unfilled (`☐ …` items and `Coverage: FAIL` gates are drawn as
52
+ * exactly that), so a pretty preview of an empty form can never read as an approvable spec.
53
+ *
54
+ * ACCESSIBILITY. The outer element is a `region` named by its own heading (`aria-labelledby`); the document
55
+ * body is a focusable (`tabIndex=0`) scrolling box with its own `aria-label` and `aria-readonly`, so the
56
+ * keyboard reaches the text and can scroll it without the decision controls leaving the viewport (the cap is
57
+ * CSS `max-height` with `overflow-y: auto`). The box is the SAME element in both modes, so toggling never
58
+ * drops focus. Escape is swallowed deliberately, because on a dialog-ish surface Escape is the key that
59
+ * dismisses and dismissing this sheet must never be read as a decision; and the one transition it has is none
60
+ * under `prefers-reduced-motion` (see the `.rec-spec` rules in `styles.ts`). No modal is used: nothing here
61
+ * needs a focus trap, and a trap would take the keyboard away from the decision controls.
62
+ */
63
+ import { createElement, useEffect, useMemo, useState, type ReactNode } from 'react'
64
+ import type { SessionListStateLike, SnapshotSelectorHook, WorkspaceListStateLike } from './contract.ts'
65
+ import { currentSessionCwd, currentWorkspacePath } from './contract.ts'
66
+ import { parseDoc, PreviewLines } from './doc-viewer.tsx'
67
+ import { fetchPhaseDoc } from './host-api.ts'
68
+ import { useLiveProjection } from './use-live.ts'
69
+ import { unfilledEvidence } from '../run-spec.ts'
70
+ import {
71
+ decisionLine,
72
+ isSpecSheetCall,
73
+ otherMode,
74
+ PREPARING_NOTICE,
75
+ readCall,
76
+ RUN_START_OPTION_MEANING,
77
+ RUN_START_QUESTION,
78
+ RUN_START_SPEC_FILE,
79
+ SPEC_DEFAULT_MODE,
80
+ specBodyContent,
81
+ specPath,
82
+ specSheetModel,
83
+ specViewToggle,
84
+ TRUNCATED_NOTICE,
85
+ type SpecFetch,
86
+ type SpecSheetBlock,
87
+ type SpecSheetModel,
88
+ type SpecViewMode,
89
+ } from './spec-sheet-view.ts'
90
+
91
+ export {
92
+ RUN_START_TOOL_NAME,
93
+ RUN_START_SPEC_FILE,
94
+ parseRecursiveAskArgs,
95
+ isRunStartCall,
96
+ specPath,
97
+ specSheetModel,
98
+ decisionLine,
99
+ UNFILLED_NOTICE,
100
+ UNFILLED_SHORT,
101
+ NO_CALL_NOTICE,
102
+ PREPARING_NOTICE,
103
+ TRUNCATED_NOTICE,
104
+ SPEC_DEFAULT_MODE,
105
+ SPEC_MODE_LABEL,
106
+ SPEC_MODE_NOTE,
107
+ SPEC_TOGGLE_LABEL,
108
+ otherMode,
109
+ specBodyContent,
110
+ specViewToggle,
111
+ } from './spec-sheet-view.ts'
112
+ export type { SpecSheetModel, SpecSheetBlock, SpecFetchState, RecursiveAskArgs, SpecViewMode, SpecViewToggle } from './spec-sheet-view.ts'
113
+
114
+ /** The session-standard hooks the seat is given, all optional so the spec can render the sheet alone. */
115
+ export interface SpecSheetProps {
116
+ /** The owner's call block; `null` when the owner supplied none (a window-truncated caller). */
117
+ block?: SpecSheetBlock
118
+ /** The session workspace root the owner supplied (`ToolCallCommonProps.cwd`). */
119
+ cwd?: string
120
+ useSessions?: SnapshotSelectorHook<SessionListStateLike>
121
+ useWorkspaces?: SnapshotSelectorHook<WorkspaceListStateLike>
122
+ }
123
+
124
+ const EMPTY_WORKSPACES: WorkspaceListStateLike = { items: [], recentWorkspaceId: undefined }
125
+
126
+ /**
127
+ * A scope that resolves nothing.
128
+ *
129
+ * The hook stays UNCONDITIONAL (the run 13 lesson: a conditional hook changed the hook count across renders
130
+ * and threw "Rendered more hooks than during the previous render"), so a row that is not a run-start call
131
+ * passes this and the route is never asked for it.
132
+ */
133
+ const NO_SCOPE = { sessionId: undefined as string | undefined, cwd: undefined as string | undefined }
134
+
135
+ /**
136
+ * Resolve the workspace root the `/doc` route will accept.
137
+ *
138
+ * ⚠ THE ROOT CANNOT BE THE SESSION CWD BY GUESSWORK. The route re-validates whatever root it is handed
139
+ * against the host's own workspace registry (`live-route.ts`: `resolveRoot(undefined, root)` must return the
140
+ * same canonical path), so the root used here is the one the live route ITSELF answered with; the workspace
141
+ * path is the hydration hint every other seat passes, and the owner-supplied `cwd` is the last resort.
142
+ */
143
+ export function resolveSpecRoot(
144
+ snapshotRoot: string | null | undefined,
145
+ workspacePath: string,
146
+ cwd: string,
147
+ ): string | null {
148
+ if (typeof snapshotRoot === 'string' && snapshotRoot.trim() !== '') return snapshotRoot
149
+ if (workspacePath.trim() !== '') return workspacePath
150
+ return cwd.trim() === '' ? null : cwd
151
+ }
152
+
153
+ /** The route states the /doc fetch can produce, and what each one MEANS. */
154
+ function readDoc(
155
+ runId: string | null,
156
+ root: string | null,
157
+ file: string,
158
+ ): SpecFetch {
159
+ const [fetchState, setFetchState] = useState<SpecFetch>({ state: 'loading' })
160
+ useEffect(() => {
161
+ if (runId === null || root === null) {
162
+ // Nothing to read yet: the route has not resolved a root (or the call names no run). This is the
163
+ // LOADING state, not an error — the subscription is what resolves the root.
164
+ setFetchState({ state: 'loading' })
165
+ return
166
+ }
167
+ let disposed = false
168
+ setFetchState({ state: 'loading' })
169
+ fetchPhaseDoc({ root, runId, file })
170
+ .then((text) => { if (!disposed) setFetchState({ state: 'loaded', text }) })
171
+ .catch((err: unknown) => {
172
+ if (disposed) return
173
+ const message = err instanceof Error ? err.message : String(err)
174
+ // ⚠ 404 IS AN ANSWER, NOT A FAILURE. The route distinguishes "this document is not there" from every
175
+ // other failure, and a client that folded them together would report a broken route when the truth
176
+ // is that the spec has not been written — the difference between "fix the client" and "write it".
177
+ const missing = /HTTP 404\b/.test(message)
178
+ setFetchState({ state: missing ? 'absent' : 'error', error: message })
179
+ })
180
+ return () => { disposed = true }
181
+ }, [runId, root, file])
182
+ return fetchState
183
+ }
184
+
185
+ /**
186
+ * The seat component: renders the spec sheet for ONE `recursive_ask` tool call, and NOTHING for any other.
187
+ *
188
+ * Returning `null` is how an unclaimed key behaves, so a `tdd-mode` / `qa-signoff` / `gate-block` ask, or a
189
+ * preparing call whose arguments have not been dispatched yet, keeps the generic tool row it has today.
190
+ */
191
+ export function RunStartSpecSheet({ block, cwd, useSessions, useWorkspaces }: SpecSheetProps): ReactNode {
192
+ const sessions = useSessions === undefined ? null : useSessions((s) => s)
193
+ const workspaces = useWorkspaces === undefined ? EMPTY_WORKSPACES : useWorkspaces((s) => s) ?? EMPTY_WORKSPACES
194
+ const workspacePath = sessions === null ? '' : currentWorkspacePath(workspaces, sessions)
195
+ const sessionCwd = sessions === null ? '' : currentSessionCwd(sessions)
196
+
197
+ const read = readCall(block)
198
+ const claims = isSpecSheetCall(read)
199
+ const runId = read.ok ? read.args.runId : null
200
+ const scope = sessions === null
201
+ ? NO_SCOPE
202
+ : { sessionId: sessions.current, cwd: workspacePath !== '' ? workspacePath : sessionCwd }
203
+ const snapshot = useLiveProjection(scope, { enabled: claims })
204
+ const root = resolveSpecRoot(snapshot?.root ?? null, workspacePath, cwd ?? '')
205
+ const fetchState = readDoc(claims ? runId : null, root, RUN_START_SPEC_FILE)
206
+
207
+ if (!read.ok) {
208
+ // Each failure says WHICH failure it is: an empty frame would read as "the document is empty", which is
209
+ // a different and much worse claim than "this panel could not resolve the call".
210
+ if (read.reason === 'missing') return null
211
+ const text = read.reason === 'preparing' ? PREPARING_NOTICE : TRUNCATED_NOTICE
212
+ return specFrame(createElement('p', { className: 'rec-spec-notice', role: 'status' }, text))
213
+ }
214
+ if (!claims) return null
215
+ if (read.args.runId === null) {
216
+ // Unreachable through `claims` (which requires a runId), kept as the explicit statement of the rule.
217
+ return specFrame(createElement('p', { className: 'rec-spec-notice', role: 'status' },
218
+ 'This `recursive_ask` call names no runId, so no run spec can be shown for it.'))
219
+ }
220
+
221
+ return createElement(RunStartSpecSheetBody, {
222
+ model: specSheetModel({ args: read.args, root, fetch: fetchState }),
223
+ })
224
+ }
225
+
226
+ /**
227
+ * The body, from a MODEL — split out so the spec can drive every state (stub, filled, absent, error, a
228
+ * recorded Hold, a recorded approval) without a route, a session, or a fetch.
229
+ *
230
+ * The MODE is local state and starts at the rendered preview (`SPEC_DEFAULT_MODE`); everything that decides
231
+ * what the sheet SAYS still comes from the pure model, so a spec can assert the words and the marks without
232
+ * mounting a component.
233
+ */
234
+ export function RunStartSpecSheetBody({ model }: { model: SpecSheetModel }): ReactNode {
235
+ const headingId = 'rec-spec-title-' + (model.runId ?? 'unresolved')
236
+ const [mode, setMode] = useState<SpecViewMode>(SPEC_DEFAULT_MODE)
237
+ const toggle = specViewToggle(mode)
238
+ return createElement('section', {
239
+ className: 'rec-spec',
240
+ role: 'region',
241
+ 'aria-labelledby': headingId,
242
+ 'aria-readonly': 'true',
243
+ 'data-verdict': model.verdict ?? model.state,
244
+ 'data-mode': mode,
245
+ // ⚠ ESCAPE MUST NOT SILENTLY APPROVE. There is no control here to dismiss and no decision to cast, so the
246
+ // key is consumed and dropped — written out rather than left to whatever a surrounding dialog might do
247
+ // with it. (The decision itself belongs to the question card, which owns its own keyboard handling. The
248
+ // preview/source toggle is a mode, not a dismissal, so it is a button and Escape deliberately ignores it.)
249
+ onKeyDown: (event: { key?: string; preventDefault?: () => void; stopPropagation?: () => void }) => {
250
+ if (event.key !== 'Escape') return
251
+ event.preventDefault?.()
252
+ event.stopPropagation?.()
253
+ },
254
+ },
255
+ createElement('header', { className: 'rec-spec-header' },
256
+ createElement('h3', { className: 'rec-spec-title', id: headingId },
257
+ 'Run spec — ' + (model.runId ?? 'runId not carried') + ' / ' + model.file),
258
+ createElement('span', { className: 'rec-spec-tag' }, 'read-only'),
259
+ ),
260
+ createElement('p', { className: 'rec-spec-path' }, specPath(model.runId, model.root, model.file)),
261
+ notice(model),
262
+ questionBlock(model),
263
+ viewControls(toggle, model, () => { setMode(otherMode(toggle.mode)) }),
264
+ documentBody(model, mode),
265
+ )
266
+ }
267
+
268
+ /**
269
+ * The mode control: one button, and the line that announces the state it just put the sheet in.
270
+ *
271
+ * ⚠ KEYBOARD-REACHABLE BY BEING A BUTTON. It is a real `<button type="button">`, so Tab reaches it and
272
+ * Enter/Space press it with no key handler of our own to get wrong. `aria-pressed` carries the state to a
273
+ * screen reader on the control, and the `role="status"` paragraph repeats it in words, naming the mode ON
274
+ * SCREEN ("Showing: raw source") rather than the action — so neither a pointer user nor a reader has to infer
275
+ * the current mode from the button's face.
276
+ */
277
+ function viewControls(toggle: ReturnType<typeof specViewToggle>, model: SpecSheetModel, onToggle: () => void): ReactNode {
278
+ const shown = model.text !== null
279
+ return createElement('div', { className: 'rec-spec-view' },
280
+ createElement('button', {
281
+ type: 'button',
282
+ className: 'rec-spec-view-toggle',
283
+ // `aria-pressed` is the STATE; the label is the ACTION. A toggle whose label says "raw" while the
284
+ // preview is on screen is the classic way a control lies about which of two things is displayed.
285
+ 'aria-pressed': toggle.pressed,
286
+ title: toggle.announce,
287
+ // ⚠ `aria-disabled`, NOT `disabled`. A real `disabled` attribute does not stop the adjacent
288
+ // `role="status"` line from announcing a mode, so with no document loaded the control would be inert
289
+ // while the line beside it claimed a mode was on screen. `aria-disabled` keeps the control reachable and
290
+ // its state true, and the click is refused below instead of by the browser.
291
+ 'aria-disabled': !shown,
292
+ onClick: () => { if (shown) onToggle() },
293
+ }, toggle.action),
294
+ createElement('span', { className: 'rec-spec-view-state', role: 'status' }, toggle.announce),
295
+ )
296
+ }
297
+
298
+ /**
299
+ * The document body: the rendered preview, or the raw bytes — and only ever the document's OWN text.
300
+ *
301
+ * Both modes are built from the same string, so neither is a second reading of the file: `raw` prints the
302
+ * characters, `preview` hands those same characters to the one parser this plugin has.
303
+ */
304
+ function documentBody(model: SpecSheetModel, mode: SpecViewMode): ReactNode {
305
+ const content = specBodyContent(model.text, mode)
306
+ // The box is rendered in EVERY state, including the empty one: it is the element the keyboard focuses and
307
+ // the label names, and a box that appears and disappears would move that focus target around the page.
308
+ const box = {
309
+ className: 'rec-spec-body-scroll' + (content !== null && content.mode === 'raw' ? ' rec-spec-body-raw' : ''),
310
+ // The text IS the point of this seat: focusable so the keyboard can reach and scroll it, and named
311
+ // separately from the region so the two are not announced as one thing. The same element serves both
312
+ // modes, so pressing the toggle changes its children and never its identity — focus survives the swap.
313
+ tabIndex: 0,
314
+ role: 'group' as const,
315
+ 'aria-label': 'Document text of ' + (model.runId ?? 'the run') + ' / ' + model.file + ', read-only, '
316
+ + (mode === 'raw' ? 'raw source' : 'rendered preview'),
317
+ }
318
+ if (content === null) {
319
+ return createElement('div', box,
320
+ createElement('p', { className: 'rec-spec-empty' }, 'No document text to show.'),
321
+ )
322
+ }
323
+ if (content.mode === 'raw') {
324
+ return createElement('div', box, createElement('pre', { className: 'rec-spec-text' }, content.text))
325
+ }
326
+ return createElement('div', box, createElement(SpecPreview, { text: content.text, runId: model.runId, file: model.file }))
327
+ }
328
+
329
+ /**
330
+ * The rendered document.
331
+ *
332
+ * The parsing is memoised on the TEXT, so a toggle back and forth does not re-parse the document, and the
333
+ * elements come from `doc-viewer.tsx`'s `PreviewLines` — the one place in this plugin where a `DocLine`
334
+ * becomes markup.
335
+ */
336
+ function SpecPreview({ text, runId, file }: { text: string; runId: string | null; file: string }): ReactNode {
337
+ const lines = useMemo(() => parseDoc(text), [text])
338
+ return createElement(PreviewLines, { lines, keyBase: 'rec-spec-' + (runId ?? 'run') + '-' + file })
339
+ }
340
+
341
+
342
+ /** The verdict notice: what the document IS, decided from its own text. */
343
+ function notice(model: SpecSheetModel): ReactNode {
344
+ if (model.state === 'loading') {
345
+ return createElement('p', { className: 'rec-spec-notice', role: 'status' }, 'Reading the document…')
346
+ }
347
+ if (model.state === 'absent') {
348
+ return createElement('p', { className: 'rec-spec-notice rec-spec-notice-error', role: 'status' },
349
+ 'That document does not exist yet, so there is nothing here to read and nothing to approve.')
350
+ }
351
+ if (model.state === 'error') {
352
+ return createElement('p', { className: 'rec-spec-notice rec-spec-notice-error', role: 'status' },
353
+ 'The document could not be read: ' + (model.error ?? 'the route gave no reason'))
354
+ }
355
+ if (model.verdict === 'unfilled') {
356
+ const strong = unfilledEvidence({ verdict: 'unfilled', hits: model.evidence })
357
+ const context = model.evidence.filter((hit) => hit.id !== 'placeholder')
358
+ return createElement('div', { className: 'rec-spec-notice rec-spec-notice-unfilled', role: 'status' },
359
+ createElement('p', { className: 'rec-spec-unfilled-lead' },
360
+ 'This document is still the UNFILLED TEMPLATE. There is no spec to approve yet.'),
361
+ createElement('p', { className: 'rec-spec-unfilled-why' },
362
+ 'The template\'s own placeholder text is still in it:'),
363
+ createElement('ul', { className: 'rec-spec-evidence' },
364
+ strong.slice(0, 8).map((hit, n) => createElement('li', {
365
+ key: 'placeholder-' + String(n),
366
+ className: 'rec-spec-evidence-item',
367
+ }, 'line ' + String(hit.line) + ': ' + hit.text)),
368
+ ),
369
+ strong.length > 8
370
+ ? createElement('p', { className: 'rec-spec-unfilled-why' },
371
+ '…and ' + String(strong.length - 8) + ' more placeholder lines.')
372
+ : null,
373
+ context.length > 0
374
+ ? createElement('p', { className: 'rec-spec-unfilled-why' },
375
+ 'It also carries ' + String(context.length) + ' unfinished marker(s) of its own: '
376
+ + context.slice(0, 3).map((hit) => 'line ' + String(hit.line) + ': ' + hit.text).join(' | '))
377
+ : null,
378
+ createElement('p', { className: 'rec-spec-unfilled-next' },
379
+ 'Fill the document in first. Approving here would record a decision about a spec that does not exist yet.'),
380
+ )
381
+ }
382
+ if (model.verdict === 'filled') {
383
+ return createElement('p', { className: 'rec-spec-notice rec-spec-notice-filled', role: 'status' },
384
+ 'The document carries real content: no template placeholder remains in it.')
385
+ }
386
+ return null
387
+ }
388
+
389
+ /** The decision under way, in the gate's own words, plus the fact that the controls live elsewhere. */
390
+ function questionBlock(model: SpecSheetModel): ReactNode {
391
+ const recorded = decisionLine(model.args.answer)
392
+ return createElement('div', { className: 'rec-spec-question' },
393
+ createElement('p', { className: 'rec-spec-question-lead' }, 'The question about this document is: “' + RUN_START_QUESTION + '”'),
394
+ createElement('p', { className: 'rec-spec-question-options' }, RUN_START_OPTION_MEANING),
395
+ createElement('p', { className: 'rec-spec-question-where' },
396
+ 'The decision controls are the question card\'s. This panel is read-only: it casts no vote and writes nothing.'),
397
+ recorded === null ? null : createElement('p', { className: 'rec-spec-decision' }, recorded),
398
+ )
399
+ }
400
+
401
+ /** A minimal named frame for the states that precede a document (no call, no run id). */
402
+ function specFrame(...children: ReactNode[]): ReactNode {
403
+ return createElement('section', {
404
+ className: 'rec-spec',
405
+ role: 'region',
406
+ 'aria-label': 'Run spec',
407
+ 'aria-readonly': 'true',
408
+ }, ...children)
409
+ }