@try-works/dsh-recursive-mode 0.4.5 → 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.
- package/README.md +15 -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/errors.d.ts +47 -1
- package/lib/index.js +483 -48
- package/lib/recursive_ask.tool.d.ts +93 -16
- package/lib/recursive_closeout.tool.d.ts +10 -0
- package/lib/recursive_init.tool.d.ts +8 -0
- package/lib/recursive_phase.tool.d.ts +8 -0
- package/lib/recursive_scratch.tool.d.ts +10 -0
- package/lib/recursive_worktree.tool.d.ts +16 -0
- package/lib/run-id.d.ts +62 -0
- 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/errors.ts +48 -2
- package/src/recursive_ask.tool.ts +217 -41
- package/src/recursive_closeout.tool.ts +53 -35
- package/src/recursive_init.tool.ts +20 -4
- package/src/recursive_phase.tool.ts +22 -2
- package/src/recursive_scratch.tool.ts +17 -1
- package/src/recursive_worktree.tool.ts +27 -2
- package/src/run-id.ts +100 -0
- package/src/run-spec.ts +148 -0
- package/src/run-start.ts +68 -3
|
@@ -125,24 +125,101 @@ export declare function createRecursiveAskTool(recursive: RecursiveRuntime): imp
|
|
|
125
125
|
/**
|
|
126
126
|
* PHASE 0 — record the answer to the run-start gate, and start the run only if it says so.
|
|
127
127
|
*
|
|
128
|
-
* ⚠
|
|
129
|
-
*
|
|
130
|
-
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
*
|
|
136
|
-
*
|
|
137
|
-
* (
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
128
|
+
* ⚠ THREE OUTCOMES, AND TELLING THEM APART IS THE FIX. The first version of this function collapsed all of
|
|
129
|
+
* them into one `null`: "the channel threw", "the channel resolved with something unrecognisable", and
|
|
130
|
+
* "nobody answered" produced the same refusal, whose text asserted a cause ("so no person was asked") the
|
|
131
|
+
* plugin had already thrown away. A live session paid for that: the call failed after 22.9 s, the operator
|
|
132
|
+
* could not be told why, and the refusal's own advice prescribed the call that had just failed. So:
|
|
133
|
+
*
|
|
134
|
+
* 1. A PERSON WAS REACHED (`unusable`): the channel resolved, so somebody answered, and their answer is
|
|
135
|
+
* not a label this gate offered — a skip, a custom value, several labels at once. That is a DECISION
|
|
136
|
+
* the gate cannot record, and it is final: neither the caller's `answer` nor `relay=true` may replace
|
|
137
|
+
* it. (RM5504)
|
|
138
|
+
* 2. THE CHANNEL FAILED (`unavailable`): no decision came back at all, and the refusal NAMES THE CAUSE
|
|
139
|
+
* from the error the channel threw. Here the run can still be started, because a composition whose
|
|
140
|
+
* channel cannot deliver the question would otherwise be unable to start any run — but only by the
|
|
141
|
+
* caller asking for the relay in so many words (`relay=true`), which the result reports as
|
|
142
|
+
* `source: "relayed"` rather than as a person's own selection. (RM5503)
|
|
143
|
+
* 3. NO CHANNEL IS MOUNTED: the relayed answer is the only possible source, exactly as before. (RM5502
|
|
144
|
+
* when there is no answer either)
|
|
145
|
+
*
|
|
146
|
+
* ⚠ AND A CANCELLED OR CLOSED QUESTION IS NEVER RELAYABLE. `ASK_CANCELLED`, `ASK_ABORTED` and
|
|
147
|
+
* `ASK_TIMED_OUT` are the codes that mean the question was settled from outside this gate — the card was
|
|
148
|
+
* dismissed, the turn was cancelled, or a foreground window ended. The relay is refused for those, so a
|
|
149
|
+
* question the operator stopped cannot be turned into an approval by asking again in the same breath.
|
|
150
|
+
* Every other failure is a composition or capability failure — the question reached nobody — which is the
|
|
151
|
+
* class the relay exists for.
|
|
152
|
+
*
|
|
153
|
+
* ⚠ WHAT THE GATE STILL DOES *NOT* CLAIM: a relayed approval is a relayed approval. The plugin cannot
|
|
154
|
+
* verify that a person gave the label, and it does not pretend otherwise — the result's `source` and
|
|
155
|
+
* `channel` fields say where the decision came from, and a direct selection is preferred whenever the
|
|
156
|
+
* channel can produce one.
|
|
143
157
|
*/
|
|
144
158
|
export declare function recordRunStartAnswer(recursive: RecursiveRuntime, root: string, runId: string, answer: string | undefined, exec: {
|
|
145
159
|
agent?: unknown;
|
|
146
160
|
signal?: unknown;
|
|
147
161
|
callId?: unknown;
|
|
148
|
-
}): Promise<Record<string, unknown>>;
|
|
162
|
+
}, relay?: boolean): Promise<Record<string, unknown>>;
|
|
163
|
+
/**
|
|
164
|
+
* PHASE 0 — WHAT THE BLOCKING CHANNEL ACTUALLY DID.
|
|
165
|
+
*
|
|
166
|
+
* ⚠ THIS TYPE EXISTS BECAUSE ITS ABSENCE WAS THE DEFECT. The first version returned a bare `null` from a
|
|
167
|
+
* `catch {}` for every failure and for every unrecognisable selection, so "the channel threw NO_PROVIDER",
|
|
168
|
+
* "the caller is not the live root agent", "the person skipped the question" and "the person typed a
|
|
169
|
+
* custom value" were ONE value. The refusal built from it then asserted the one cause it could not know.
|
|
170
|
+
* An outcome carries the cause, the raw message, and whether the failure is the class a relay may answer.
|
|
171
|
+
*/
|
|
172
|
+
export type RunStartChannelOutcome =
|
|
173
|
+
/** The person answered, and their selection is exactly one of the gate's own labels. */
|
|
174
|
+
{
|
|
175
|
+
kind: 'answered';
|
|
176
|
+
answer: string;
|
|
177
|
+
}
|
|
178
|
+
/** The channel RESOLVED, so a person was reached — but the answer is not a label this gate offered. */
|
|
179
|
+
| {
|
|
180
|
+
kind: 'unusable';
|
|
181
|
+
detail: string;
|
|
182
|
+
}
|
|
183
|
+
/** The channel THREW: no decision came back, and the cause is named rather than discarded. */
|
|
184
|
+
| {
|
|
185
|
+
kind: 'unavailable';
|
|
186
|
+
cause: string;
|
|
187
|
+
detail: string;
|
|
188
|
+
relayable: boolean;
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* ⚠ THE CODES THAT MEAN THE QUESTION WAS CANCELLED OR CLOSED rather than never delivered: the person
|
|
192
|
+
* dismissed the card, their turn was cancelled, or a foreground window ended. A caller may not convert any
|
|
193
|
+
* of those into an approval by asking for the relay in the same breath. Every other failure means the
|
|
194
|
+
* question reached nobody — a composition or capability failure, which is the class the relay exists for.
|
|
195
|
+
*/
|
|
196
|
+
export declare const NON_RELAYABLE_CHANNEL_CODES: readonly ["ASK_CANCELLED", "ASK_ABORTED", "ASK_TIMED_OUT"];
|
|
197
|
+
/**
|
|
198
|
+
* Name the failure of one `ask()` call, without inventing anything about it.
|
|
199
|
+
*
|
|
200
|
+
* The cause is the error's own `code` when it has one (the harness's `UserQuestionError` carries
|
|
201
|
+
* `NO_PROVIDER`, `CALLER_NOT_LIVE`, `DELEGATED_CALLER`, `ASK_ABORTED`, …), else its `name`, else its
|
|
202
|
+
* JavaScript type. `detail` keeps the message verbatim so a reader sees the channel's own words rather
|
|
203
|
+
* than this plugin's paraphrase — the paraphrase is exactly how the previous version came to assert a
|
|
204
|
+
* cause nobody had.
|
|
205
|
+
*/
|
|
206
|
+
export declare function classifyChannelFailure(err: unknown): {
|
|
207
|
+
cause: string;
|
|
208
|
+
detail: string;
|
|
209
|
+
relayable: boolean;
|
|
210
|
+
};
|
|
211
|
+
/**
|
|
212
|
+
* Ask the run-start question through the blocking channel and report WHAT HAPPENED.
|
|
213
|
+
*
|
|
214
|
+
* ⚠ THE CATCH IS THE POINT. It used to be `catch { return null }` — a blocking human question whose
|
|
215
|
+
* failure cause was erased at the exact moment the cause was the only thing worth knowing. Every path out
|
|
216
|
+
* of this function now says which path it was.
|
|
217
|
+
*
|
|
218
|
+
* The selection is filtered to the gate's OWN labels: a question a UI answered with a free-text custom
|
|
219
|
+
* value must not become an approval just because it arrived on the right channel.
|
|
220
|
+
*/
|
|
221
|
+
export declare function askRunStartDirectly(channel: NonNullable<RecursiveRuntime['userQuestionsChannel']>, exec: {
|
|
222
|
+
agent?: unknown;
|
|
223
|
+
signal?: unknown;
|
|
224
|
+
callId?: unknown;
|
|
225
|
+
}): Promise<RunStartChannelOutcome>;
|
|
@@ -4,5 +4,15 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
4
4
|
* SESSION's workspace only (R1 workspace-scoping invariant). The run is resolved
|
|
5
5
|
* via the session agent's cwd -> workspace registry; a runId outside the current
|
|
6
6
|
* workspace is rejected.
|
|
7
|
+
*
|
|
8
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and "outside the current workspace is rejected" is NOT enough
|
|
9
|
+
* on its own: `closeoutRun` joins the id onto the run layer and then writes a receipt under it, and its
|
|
10
|
+
* scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment. A `..\` segment
|
|
11
|
+
* that lands on a SIBLING of the run layer passes that check whenever the sibling's name begins with
|
|
12
|
+
* `run`, so a path-shaped id can still receive a write. MEASURED pre-fix: `..\run-away` and `../run-away`
|
|
13
|
+
* were accepted and reached the report; the other shapes below were stopped only by the sibling not
|
|
14
|
+
* existing, which is the operator's filesystem deciding, not the tool. The rule is `run-id.ts`; this
|
|
15
|
+
* boundary is where the name enters, so this is where it is refused, with `recursive_init`'s refusal shape
|
|
16
|
+
* — `BAD_RUN_ID` (RM1107), same detail sentence.
|
|
7
17
|
*/
|
|
8
18
|
export declare function createRecursiveCloseoutTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -13,5 +13,13 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
13
13
|
* rather than "this call created something": re-initialising an APPROVED run reports `approved: true`
|
|
14
14
|
* (and the run keeps its goal), which is what a caller re-scaffolding a run it already started needs to
|
|
15
15
|
* see.
|
|
16
|
+
*
|
|
17
|
+
* AND A RUN ID IS A NAME, NOT A PATH. This is the boundary where the name enters, so it is the boundary
|
|
18
|
+
* that refuses a path-shaped one — loudly, and before `initRun` can mkdir anything. The check lives here
|
|
19
|
+
* (through the shared `run-id.ts` rule) rather than in `runtime.ts` because `initRun` is not the only
|
|
20
|
+
* caller and because the runtime's `join(root, '.recursive', 'run', runId)` is CORRECT for a name; what
|
|
21
|
+
* was missing was a gate on the name. Teaching the runtime to accept a path would silently relocate the
|
|
22
|
+
* run layer instead of rejecting the call. See `run-id.ts` for the rule, the evidence behind it, and the
|
|
23
|
+
* "do not fix this back" note.
|
|
16
24
|
*/
|
|
17
25
|
export declare function createRecursiveInitTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -5,5 +5,13 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
5
5
|
* (runtime.phaseRules -> phaseRulesFor) as the once-per-phase pre-step
|
|
6
6
|
* reminder, so the agent can re-ask for the rules without re-injecting them on
|
|
7
7
|
* every step. Returns { error } when no active phase is found.
|
|
8
|
+
*
|
|
9
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, INCLUDING WHEN IT IS OMITTED. Omitted and path-shaped are
|
|
10
|
+
* different cases and must stay different: omitted means "the latest run by mtime", which `resolveRunDir`
|
|
11
|
+
* answers by DISCOVERY rather than by joining anything, and that case is untouched below. A path-shaped id,
|
|
12
|
+
* by contrast, is joined by `phaseRules` -> `resolveRunDir` and then read, and `recordInjection` WRITES
|
|
13
|
+
* `memory-injections.json` under whatever directory it resolved to — with no scoping check at all on this
|
|
14
|
+
* path. So the gate fires only on an id that was actually supplied, and `run-id.ts` owns the rule. The
|
|
15
|
+
* refusal is `recursive_init`'s, `BAD_RUN_ID` (RM1107), composed identically.
|
|
8
16
|
*/
|
|
9
17
|
export declare function createRecursivePhaseTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -3,5 +3,15 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
3
3
|
* `recursive_scratch` — read/write/append the run-scoped disposable scratchpad
|
|
4
4
|
* (R5) under the CURRENT session workspace only (R1). Scratch is git-ignored
|
|
5
5
|
* and never citable as an Input.
|
|
6
|
+
*
|
|
7
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO. `scratchRun` joins it onto the run layer, and it then WRITES
|
|
8
|
+
* (write/append) into the directory it landed on, so a path-shaped id does not merely fail to find a run:
|
|
9
|
+
* with a `..\` segment it can find and write into a SIBLING of the run layer, because the runtime's
|
|
10
|
+
* workspace-scoping check is `runDir.startsWith(runRoot)` — a string prefix test, not containment — and a
|
|
11
|
+
* sibling directory whose name begins with `run` passes it. MEASURED pre-fix: `..\run-away` was accepted
|
|
12
|
+
* and `scratchRun` wrote `scratch.md` into `<workspace>\.recursive\run-away\scratch\`. The rule is
|
|
13
|
+
* `run-id.ts`; the gate sits here, at the boundary where the name enters, rather than in `scratchRun`, for
|
|
14
|
+
* the same reason `recursive_init`'s does. Refusal shape is `recursive_init`'s, `BAD_RUN_ID` (RM1107),
|
|
15
|
+
* composed identically: one rule, one message.
|
|
6
16
|
*/
|
|
7
17
|
export declare function createRecursiveScratchTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
|
@@ -3,5 +3,21 @@ import type { RecursiveRuntime } from './runtime.ts';
|
|
|
3
3
|
* `recursive_worktree` — create a linked git worktree for a run and/or
|
|
4
4
|
* promote a branch up the dev/stage/main chain. Workspace-scoped: the
|
|
5
5
|
* operations run under the SESSION's control-plane root only.
|
|
6
|
+
*
|
|
7
|
+
* A RUN ID IS A NAME, NOT A PATH HERE TOO, and this is the worst place to be without the rule: a `create`
|
|
8
|
+
* builds TWO things out of the id — the linked worktree directory `.worktrees/<runId>` AND the git branch
|
|
9
|
+
* `recursive/<runId>` (git accepts '/' inside a ref) — so a path-shaped id used to leave a worktree and a
|
|
10
|
+
* ref behind, not just a folder. MEASURED pre-fix, per id, against a fresh repo: `nested/child-run`
|
|
11
|
+
* returned ok:true and created BOTH `.worktrees/nested/child-run` and
|
|
12
|
+
* `refs/heads/recursive/nested/child-run`, while the shapes git itself refuses as ref syntax
|
|
13
|
+
* (`recursive//tmp/x`, `recursive/C:…`, `.hidden-run`, a trailing space) failed the worktree add and
|
|
14
|
+
* created neither. The rule is `run-id.ts` and is not restated here; the gate sits at this boundary, ahead
|
|
15
|
+
* of `createRunWorktree`, so the refusal no longer depends on git happening to dislike the ref name.
|
|
16
|
+
*
|
|
17
|
+
* The refusal is `BAD_RUN_ID` (RM1107), composed exactly as `recursive_init` composes it — same code, same
|
|
18
|
+
* detail, same sentence. One message for one rule is what keeps a caller from having to learn a second
|
|
19
|
+
* vocabulary for the same defect, and the shared remedy it carries ("call recursive_init again") is right
|
|
20
|
+
* for this tool as well: a `create` for a run that does not exist yet is exactly what `recursive_init`
|
|
21
|
+
* with `createWorktree: true` does.
|
|
6
22
|
*/
|
|
7
23
|
export declare function createRecursiveWorktreeTool(recursive: RecursiveRuntime): import("@deepseek-ai/dsh-tools").ToolDefinition;
|
package/lib/run-id.d.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A RUN ID IS A NAME, NOT A PATH.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. Every consumer of a run id JOINS it onto a directory
|
|
5
|
+
* that already carries the meaning "the run layer":
|
|
6
|
+
*
|
|
7
|
+
* join(root, '.recursive', 'run', runId) // runtime.ts, run.ts, handoff.ts, scratch.ts
|
|
8
|
+
* join(repoRoot, '.worktrees', runId) // worktree.ts (a linked worktree)
|
|
9
|
+
* 'recursive/' + runId // worktree.ts (the run's git branch)
|
|
10
|
+
*
|
|
11
|
+
* `join` is a PATH operation: absolute paths, drive specifiers and `..` segments
|
|
12
|
+
* are all legal input to it, and each one silently changes what the call means.
|
|
13
|
+
* A caller who passes `E:\tmp\rm-live-diagnostics\01-calculator-lib` is asking
|
|
14
|
+
* for a run "on another drive"; what they get is a `mkdir` of
|
|
15
|
+
*
|
|
16
|
+
* <workspace>\.recursive\run\E:\tmp\rm-live-diagnostics\01-calculator-lib
|
|
17
|
+
*
|
|
18
|
+
* which is not drive-qualified at all — on POSIX and Windows alike the colon is
|
|
19
|
+
* just another character in a relative component. The result is a bogus nested
|
|
20
|
+
* folder INSIDE the workspace, created before anything can refuse it, surfacing
|
|
21
|
+
* far away as an ENOENT-shaped runtime failure (RM5501) with the operator's
|
|
22
|
+
* filesystem already dirty.
|
|
23
|
+
*
|
|
24
|
+
* SO THE RULE IS ENFORCED WHERE THE NAME ENTERS, and NOT by teaching the runtime
|
|
25
|
+
* to accept a path. The joins in `runtime.ts` are CORRECT for a name; what was
|
|
26
|
+
* missing was a gate on the name. Do not "fix" this back: a run on another drive
|
|
27
|
+
* or in a worktree is reached through the session's control-plane root
|
|
28
|
+
* (`recursive_worktree`, `00-worktree.md`) — the run layer is never relocated by
|
|
29
|
+
* smuggling a path into the id.
|
|
30
|
+
*
|
|
31
|
+
* The charset below is deliberately the SAME one the read path already uses
|
|
32
|
+
* (`live-route.ts` `DOC_SAFE_RE`) so a name this gate accepts is a name that
|
|
33
|
+
* route can serve.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The accepted shape, as prose that can be embedded in a model-facing parameter
|
|
37
|
+
* description and in a refusal detail, so the rule is stated once.
|
|
38
|
+
*/
|
|
39
|
+
export declare const RUN_ID_RULE = "letters, digits, dot, underscore or dash only, no leading or trailing dot, no path separator, no drive specifier and no \"..\" segment";
|
|
40
|
+
/** Two ids in the shapes the scaffold convention actually produces. */
|
|
41
|
+
export declare const RUN_ID_EXAMPLES = "01-calculator-lib, fixture-run";
|
|
42
|
+
/**
|
|
43
|
+
* Longest run id accepted. Directory-name components cap at 255 bytes on NTFS
|
|
44
|
+
* and ext4; a run id also becomes a git ref component (`recursive/<runId>`) and
|
|
45
|
+
* a prefix of every lock/receipt filename inside the run, so the ceiling is set
|
|
46
|
+
* well below the filesystem limit rather than at it.
|
|
47
|
+
*/
|
|
48
|
+
export declare const RUN_ID_MAX_LENGTH = 100;
|
|
49
|
+
/**
|
|
50
|
+
* Why a run id is refused, or `null` when it is a usable NAME.
|
|
51
|
+
*
|
|
52
|
+
* The returned string is the SPECIFIC problem (which rule the id broke), with no
|
|
53
|
+
* trailing punctuation and no sentence of its own, so a caller can hand it to
|
|
54
|
+
* `toolError('BAD_RUN_ID', …)` as the detail. `RUN_ID_RULE` states the shape.
|
|
55
|
+
*
|
|
56
|
+
* The order of the checks is part of the message quality: a Windows absolute
|
|
57
|
+
* path is reported as a drive-qualified path (what the caller passed) rather
|
|
58
|
+
* than as a separator complaint (what that path is made of).
|
|
59
|
+
*/
|
|
60
|
+
export declare function runIdProblem(raw: string): string | null;
|
|
61
|
+
/** True when `raw` is a usable run NAME. Convenience for callers that only branch. */
|
|
62
|
+
export declare function isValidRunId(raw: string): boolean;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* IS THIS RUN SPEC STILL A HOLLOW TEMPLATE? — one answer, two callers.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS MODULE EXISTS. `recursive_init` scaffolds Phase 0 as a TEMPLATE: the requirement block
|
|
5
|
+
* still reads `### \`R1\` <short title>`, the acceptance criteria are still `[observable condition 1]`,
|
|
6
|
+
* the checklists are still unchecked and both gates still read `FAIL`. The owner's defect report is that
|
|
7
|
+
* `recursive_ask gate=run-start` raised the "start this run or hold?" gate while the document was in
|
|
8
|
+
* exactly that state — *"i was never shown the spec before that so how could i approve if i havent seen
|
|
9
|
+
* it"*. Approving an unfilled template is not a decision about a spec; there is no spec yet.
|
|
10
|
+
*
|
|
11
|
+
* So the QUESTION "is this document still the template?" must have ONE answer, and two consumers need it:
|
|
12
|
+
*
|
|
13
|
+
* 1. the SERVER gate (`recursive_ask`), which must REFUSE to raise the run-start question while the
|
|
14
|
+
* answer is yes, and
|
|
15
|
+
* 2. the CLIENT sheet (`client/spec-sheet.tsx`), which must SAY SO plainly rather than dress a hollow
|
|
16
|
+
* document up as an approvable one — the honesty rule of `client/settings-view.ts`, applied to a
|
|
17
|
+
* document instead of a value.
|
|
18
|
+
*
|
|
19
|
+
* ⚠ THIS MODULE IS NODE-FREE ON PURPOSE. It is reached by both halves of the bundle, and the client bundle
|
|
20
|
+
* is a BROWSER closure: a `node:fs` import anywhere in its graph breaks the page. So the file reading stays
|
|
21
|
+
* in `run-start.ts` (which owns the artifact path) and this module takes TEXT and returns a VERDICT.
|
|
22
|
+
*
|
|
23
|
+
* ⚠ AND IT PINS THE TEMPLATE, NOT A COPY OF IT. The markers below are the literal lines
|
|
24
|
+
* `init-templates.ts::requirementsContent` writes. A checker that instead carried its own copy of the whole
|
|
25
|
+
* template would silently stop matching the moment the template changed; a checker that carried a CHECKSUM
|
|
26
|
+
* would call every edited document filled and every untouched one unfilled on the strength of a byte count.
|
|
27
|
+
* Naming the placeholder markers is the check that keeps meaning what it says.
|
|
28
|
+
*/
|
|
29
|
+
/** What the checker decided about one artifact's text. */
|
|
30
|
+
export type ArtifactVerdict = 'unfilled' | 'filled';
|
|
31
|
+
/** One piece of evidence found in the document, quoted with its line number. */
|
|
32
|
+
export interface ArtifactMarkerHit {
|
|
33
|
+
/** Stable id of the marker that matched. */
|
|
34
|
+
id: string;
|
|
35
|
+
/** 1-based line number in the document as it was read. */
|
|
36
|
+
line: number;
|
|
37
|
+
/** The line verbatim, trimmed of surrounding whitespace. */
|
|
38
|
+
text: string;
|
|
39
|
+
}
|
|
40
|
+
/** The verdict plus the evidence for it. */
|
|
41
|
+
export interface ArtifactVerdictResult {
|
|
42
|
+
verdict: ArtifactVerdict;
|
|
43
|
+
/** Marker hits, in line order. Empty exactly when the verdict is `filled`. */
|
|
44
|
+
hits: ArtifactMarkerHit[];
|
|
45
|
+
}
|
|
46
|
+
/** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
|
|
47
|
+
export declare const ARTIFACT_MARKER_IDS: {
|
|
48
|
+
readonly placeholder: "placeholder";
|
|
49
|
+
readonly uncheckedTodo: "unchecked-todo";
|
|
50
|
+
readonly failedGate: "failed-gate";
|
|
51
|
+
};
|
|
52
|
+
export type ArtifactMarkerId = (typeof ARTIFACT_MARKER_IDS)[keyof typeof ARTIFACT_MARKER_IDS];
|
|
53
|
+
/**
|
|
54
|
+
* Which marker a single line carries, or null.
|
|
55
|
+
*
|
|
56
|
+
* Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
|
|
57
|
+
* re-derived them would be a second answer to the same question.
|
|
58
|
+
*/
|
|
59
|
+
export declare function markerIdsOnLine(line: string): ArtifactMarkerId[];
|
|
60
|
+
/**
|
|
61
|
+
* Classify one artifact's text.
|
|
62
|
+
*
|
|
63
|
+
* A verdict of `unfilled` means the document still carries the template's own placeholder text — the
|
|
64
|
+
* evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
|
|
65
|
+
* instead of asserting a state the reader cannot check.
|
|
66
|
+
*/
|
|
67
|
+
export declare function classifyArtifact(text: string): ArtifactVerdictResult;
|
|
68
|
+
/** The unfilled evidence only (what a refusal names). */
|
|
69
|
+
export declare function unfilledEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[];
|
|
70
|
+
/** The weak, contextual markers (unchecked boxes, FAIL gates). */
|
|
71
|
+
export declare function contextEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[];
|
|
72
|
+
/**
|
|
73
|
+
* One line naming what is missing, for a refusal sentence.
|
|
74
|
+
*
|
|
75
|
+
* The line number and the text are both quoted: "line 12: <short title>" is a thing a reader can go and
|
|
76
|
+
* look at, while "the requirements are not filled in" is an assertion they would have to take on trust.
|
|
77
|
+
*/
|
|
78
|
+
export declare function describeEvidence(hits: readonly ArtifactMarkerHit[], limit?: number): string;
|
package/lib/run-start.d.ts
CHANGED
|
@@ -53,3 +53,30 @@ export declare function readRunStartApproval(root: string, runId: string): {
|
|
|
53
53
|
* the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
|
|
54
54
|
*/
|
|
55
55
|
export declare const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
|
|
56
|
+
/**
|
|
57
|
+
* PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
|
|
58
|
+
*
|
|
59
|
+
* THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
|
|
60
|
+
* "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
|
|
61
|
+
* acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
|
|
62
|
+
* person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
|
|
63
|
+
* before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
|
|
64
|
+
* about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
|
|
65
|
+
* without reading.
|
|
66
|
+
*
|
|
67
|
+
* ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
|
|
68
|
+
* start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
|
|
69
|
+
* still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
|
|
70
|
+
* timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
|
|
71
|
+
* for a document that cannot be approved meaningfully.
|
|
72
|
+
*
|
|
73
|
+
* ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
|
|
74
|
+
* refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
|
|
75
|
+
* existing route handle it.
|
|
76
|
+
*/
|
|
77
|
+
export declare function runStartSpecGuard(root: string, runId: string): {
|
|
78
|
+
ok: true;
|
|
79
|
+
} | {
|
|
80
|
+
ok: false;
|
|
81
|
+
reason: string;
|
|
82
|
+
};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@try-works/dsh-recursive-mode",
|
|
3
3
|
"description": "recursive-mode workflow as a DeepSeek Harness bundle: RecursiveRuntime service + 13 recursive_* tools (recursive_status, recursive_init, recursive_lock, recursive_lint, recursive_closeout, recursive_scratch, recursive_worktree, recursive_phase, recursive_audit_team, recursive_review, recursive_delegate, recursive_ask, recursive_preview)",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.7",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
7
7
|
"types": "lib/index.d.ts",
|
package/src/client/contract.ts
CHANGED
|
@@ -62,6 +62,16 @@ export interface ClientSlots {
|
|
|
62
62
|
/** A registered slot's options. */
|
|
63
63
|
export interface SlotOptions {
|
|
64
64
|
name: string
|
|
65
|
+
/**
|
|
66
|
+
* The dispatch key of a KEYED seat (`tool.call.toolview` is one), required there and ignored elsewhere.
|
|
67
|
+
*
|
|
68
|
+
* ⚠ THIS FIELD WAS MISSING AND ITS ABSENCE WAS NOT COSMETIC: `slots.ts` registers the run-start spec sheet
|
|
69
|
+
* on `tool.call.toolview` under the tool's wire name, and the harness's own options type makes `key`
|
|
70
|
+
* mandatory for a keyed slot (`KindOptions` in `packages/client/ui-slots`, where a keyed registration with
|
|
71
|
+
* no key THROWS `keyed slot "<name>" requires options.key`). Without the field here the face did not match
|
|
72
|
+
* the API it describes, so `tsc --noEmit` rejected the registration and the sheet was unreachable code.
|
|
73
|
+
*/
|
|
74
|
+
key?: string
|
|
65
75
|
id?: string
|
|
66
76
|
order?: number
|
|
67
77
|
label?: string
|
|
@@ -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),
|