@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.
package/lib/errors.d.ts CHANGED
@@ -151,6 +151,19 @@ export declare const TOOL_ERRORS: {
151
151
  readonly problem: "the run has unresolved delegated work, so this phase cannot lock yet";
152
152
  readonly next: "call recursive_status to see the pending delegation, have the child write its reply.md, then lock again";
153
153
  };
154
+ /**
155
+ * THE ORDERING DEFECT, AS A CODE. `recursive_ask gate=run-start` used to raise "start this run or hold?"
156
+ * over a Phase 0 document that was still the scaffold `recursive_init` wrote — placeholder requirements,
157
+ * unchecked lists, `FAIL` gates — and nothing put that document in front of the person either. The owner:
158
+ * *"i was never shown the spec before that so how could i approve if i havent seen it"*. Approving an
159
+ * unfilled template is not a decision about a spec, so the gate refuses to be raised until there is one.
160
+ */
161
+ readonly RUN_START_SPEC_UNFILLED: {
162
+ readonly code: "RM4404";
163
+ readonly klass: "state";
164
+ readonly problem: "the Phase 0 requirements document is still the unfilled template, so there is no run spec for a person to approve";
165
+ readonly next: "fill the requirements document in (define the requirement ids and their acceptance criteria, and complete the TODO list) and then call recursive_ask with gate: run-start again";
166
+ };
154
167
  readonly RUN_START_NO_CHANNEL: {
155
168
  readonly code: "RM5502";
156
169
  readonly klass: "runtime";
package/lib/index.js CHANGED
@@ -5480,6 +5480,19 @@ const TOOL_ERRORS = {
5480
5480
  problem: "the run has unresolved delegated work, so this phase cannot lock yet",
5481
5481
  next: "call recursive_status to see the pending delegation, have the child write its reply.md, then lock again"
5482
5482
  },
5483
+ /**
5484
+ * THE ORDERING DEFECT, AS A CODE. `recursive_ask gate=run-start` used to raise "start this run or hold?"
5485
+ * over a Phase 0 document that was still the scaffold `recursive_init` wrote — placeholder requirements,
5486
+ * unchecked lists, `FAIL` gates — and nothing put that document in front of the person either. The owner:
5487
+ * *"i was never shown the spec before that so how could i approve if i havent seen it"*. Approving an
5488
+ * unfilled template is not a decision about a spec, so the gate refuses to be raised until there is one.
5489
+ */
5490
+ RUN_START_SPEC_UNFILLED: {
5491
+ code: "RM4404",
5492
+ klass: "state",
5493
+ problem: "the Phase 0 requirements document is still the unfilled template, so there is no run spec for a person to approve",
5494
+ next: "fill the requirements document in (define the requirement ids and their acceptance criteria, and complete the TODO list) and then call recursive_ask with gate: run-start again"
5495
+ },
5483
5496
  RUN_START_NO_CHANNEL: {
5484
5497
  code: "RM5502",
5485
5498
  klass: "runtime",
@@ -6864,6 +6877,79 @@ function extractAndGroup(runner, env, options = {}) {
6864
6877
  };
6865
6878
  }
6866
6879
  //#endregion
6880
+ //#region src/run-spec.ts
6881
+ /** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
6882
+ const ARTIFACT_MARKER_IDS = {
6883
+ placeholder: "placeholder",
6884
+ uncheckedTodo: "unchecked-todo",
6885
+ failedGate: "failed-gate"
6886
+ };
6887
+ /** Every marker, in the order they are reported for a single line. */
6888
+ const MARKER_PATTERNS = [
6889
+ {
6890
+ id: ARTIFACT_MARKER_IDS.placeholder,
6891
+ re: /(^\s*\.\.\.\s*$)|(\[[^[\]\n<>]{2,120}\])|(<[^<>\n]{2,120}>)/
6892
+ },
6893
+ {
6894
+ id: ARTIFACT_MARKER_IDS.uncheckedTodo,
6895
+ re: /^\s*[-*]\s*\[ \]/
6896
+ },
6897
+ {
6898
+ id: ARTIFACT_MARKER_IDS.failedGate,
6899
+ re: /^\s*(Coverage|Approval):\s*FAIL\b/i
6900
+ }
6901
+ ];
6902
+ /**
6903
+ * Which marker a single line carries, or null.
6904
+ *
6905
+ * Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
6906
+ * re-derived them would be a second answer to the same question.
6907
+ */
6908
+ function markerIdsOnLine(line) {
6909
+ const ids = [];
6910
+ for (const pattern of MARKER_PATTERNS) if (pattern.re.test(line)) ids.push(pattern.id);
6911
+ return ids;
6912
+ }
6913
+ /**
6914
+ * Classify one artifact's text.
6915
+ *
6916
+ * A verdict of `unfilled` means the document still carries the template's own placeholder text — the
6917
+ * evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
6918
+ * instead of asserting a state the reader cannot check.
6919
+ */
6920
+ function classifyArtifact(text) {
6921
+ const lines = text.split(/\r?\n/);
6922
+ const hits = [];
6923
+ for (let i = 0; i < lines.length; i += 1) {
6924
+ const line = lines[i];
6925
+ for (const id of markerIdsOnLine(line)) hits.push({
6926
+ id,
6927
+ line: i + 1,
6928
+ text: line.trim()
6929
+ });
6930
+ }
6931
+ return {
6932
+ verdict: hits.some((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder) ? "unfilled" : "filled",
6933
+ hits
6934
+ };
6935
+ }
6936
+ /** The unfilled evidence only (what a refusal names). */
6937
+ function unfilledEvidence(result) {
6938
+ return result.hits.filter((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder);
6939
+ }
6940
+ /**
6941
+ * One line naming what is missing, for a refusal sentence.
6942
+ *
6943
+ * The line number and the text are both quoted: "line 12: <short title>" is a thing a reader can go and
6944
+ * look at, while "the requirements are not filled in" is an assertion they would have to take on trust.
6945
+ */
6946
+ function describeEvidence(hits, limit = 3) {
6947
+ const shown = hits.slice(0, limit).map((hit) => "line " + String(hit.line) + ": " + hit.text);
6948
+ const rest = hits.length - shown.length;
6949
+ const suffix = rest > 0 ? " (and " + String(rest) + " more)" : "";
6950
+ return shown.join(" | ") + suffix;
6951
+ }
6952
+ //#endregion
6867
6953
  //#region src/run-start.ts
6868
6954
  /**
6869
6955
  * PHASE 0 — STARTING A RUN IS A HUMAN DECISION, NOT A SIDE EFFECT OF SCAFFOLDING.
@@ -6986,6 +7072,43 @@ function readRunStartApproval(root, runId) {
6986
7072
  * the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
6987
7073
  */
6988
7074
  const RUN_START_NOT_APPROVED = "run not started: phase 0 approval has not been granted";
7075
+ /**
7076
+ * PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
7077
+ *
7078
+ * THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
7079
+ * "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
7080
+ * acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
7081
+ * person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
7082
+ * before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
7083
+ * about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
7084
+ * without reading.
7085
+ *
7086
+ * ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
7087
+ * start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
7088
+ * still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
7089
+ * timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
7090
+ * for a document that cannot be approved meaningfully.
7091
+ *
7092
+ * ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
7093
+ * refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
7094
+ * existing route handle it.
7095
+ */
7096
+ function runStartSpecGuard(root, runId) {
7097
+ const content = readRunStartArtifact(root, runId);
7098
+ if (content === null) return {
7099
+ ok: false,
7100
+ reason: toolError("RUN_START_SPEC_UNFILLED", "there is no Phase 0 document to approve: " + runStartArtifactPath(root, runId) + " does not exist yet")
7101
+ };
7102
+ const verdict = classifyArtifact(content);
7103
+ if (verdict.verdict === "filled") return { ok: true };
7104
+ const evidence = unfilledEvidence(verdict);
7105
+ const context = verdict.hits.filter((hit) => hit.id !== "placeholder");
7106
+ const contextNote = context.length === 0 ? "" : " (it also carries " + String(context.length) + " unfinished marker(s) of its own, starting at line " + String(context[0]?.line ?? 0) + ")";
7107
+ return {
7108
+ ok: false,
7109
+ reason: toolError("RUN_START_SPEC_UNFILLED", "00-requirements.md for run " + JSON.stringify(runId) + " still carries the template scaffold" + contextNote + ": " + describeEvidence(evidence))
7110
+ };
7111
+ }
6989
7112
  //#endregion
6990
7113
  //#region src/recursive_ask.tool.ts
6991
7114
  /**
@@ -7201,7 +7324,7 @@ function pendingGateFor(artifactFile, artifactText) {
7201
7324
  function createRecursiveAskTool(recursive) {
7202
7325
  return defineTool({
7203
7326
  name: "recursive_ask",
7204
- description: "Ask a human gate as a structured decision (tdd-mode, qa-signoff, gate-block), or ASK TO START A RUN (run-start: nothing runs, and no goal exists, until this gate is approved). Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. For run-start the mounted human channel is asked first and its own selection wins; when that channel cannot deliver the question, the refusal names the cause and `relay=true` with an explicit `answer` records the person's relayed approval. One ask per step.",
7327
+ description: "Ask a human gate as a structured decision (tdd-mode, qa-signoff, gate-block), or ASK TO START A RUN (run-start: nothing runs, and no goal exists, until this gate is approved). Call without `answer` to ask; call with it to write the answer into the artifact as a durable marker. For run-start the mounted human channel is asked first and its own selection wins; when that channel cannot deliver the question, the refusal names the cause and `relay=true` with an explicit `answer` records the person's relayed approval. The run-start gate is REFUSED while the Phase 0 requirements document is still the unfilled template — the refusal quotes the placeholder lines, and there is nothing to approve until they are written. One ask per step.",
7205
7328
  parameters: {
7206
7329
  gate: {
7207
7330
  type: "string",
@@ -7240,6 +7363,16 @@ function createRecursiveAskTool(recursive) {
7240
7363
  const root = await recursive.resolveWorkspaceRoot(exec.agent);
7241
7364
  if (!root) return { error: toolError("NO_WORKSPACE") };
7242
7365
  const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId]).trim();
7366
+ if (isRunStartGate(gateId)) {
7367
+ const guard = runStartSpecGuard(root, runId);
7368
+ if (!guard.ok) return {
7369
+ error: guard.reason,
7370
+ gate: RUN_START_GATE_ID,
7371
+ runId,
7372
+ artifact: RUN_START_ARTIFACT,
7373
+ question: buildAskQuestionFor(RUN_START_GATE_ID)
7374
+ };
7375
+ }
7243
7376
  let question;
7244
7377
  try {
7245
7378
  question = buildAskQuestionFor(gateId);
@@ -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;
@@ -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.6",
4
+ "version": "0.4.7",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/index.d.ts",
@@ -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
- const li = /^\s*[-*]\s+(.+)$/.exec(line);
88
- if (li) { out.push({ kind: 'li', text: li[1] }); i += 1; continue; }
130
+ // list item — a task box is CLASSIFIED (its tick kept as `checked`) and its text is the item's CONTENT,
131
+ // which is the shape the base parser already produces for a bullet. The marker and the box are DRAWN by
132
+ // the renderer from `kind` + `checked`, so no line is retyped and the reader still sees `- [ ] item`.
133
+ const item = BULLET_RE.exec(line);
134
+ if (item) {
135
+ const box = TASK_BOX_RE.exec(item[1]);
136
+ out.push(box === null
137
+ ? { kind: 'li', text: item[1] }
138
+ : { kind: 'li', text: box[2], checked: box[1] !== ' ' });
139
+ i += 1;
140
+ continue;
141
+ }
142
+ // gate reading: `Coverage: FAIL` / `Approval: PASS`
143
+ const gate = GATE_RE.exec(line);
144
+ if (gate) { out.push({ kind: 'plain', text: line, gate: gate[1].toUpperCase() === 'FAIL' ? 'fail' : 'pass' }); i += 1; continue; }
89
145
  out.push({ kind: 'plain', text: line });
90
146
  i += 1;
91
147
  }
@@ -137,12 +193,41 @@ function inlineNodes(segments: InlineSegment[], baseKey: string): ReactNode[] {
137
193
  });
138
194
  }
139
195
 
196
+ /**
197
+ * The mark of a task box.
198
+ *
199
+ * ⚠ THE MARK REPLACES `[ ]` / `[x]` IN PLACE, GLYPH FOR GLYPH. The parser hands over the item's content
200
+ * without the box, so what the reader sees is `- [ ] item` where the document says `- [x] item`: same line,
201
+ * same position, same length of reading — and the box is still legible as a box rather than as an assertion
202
+ * about the item.
203
+ *
204
+ * ⚠ AND THE TICK IS CARRIED TWICE — once as that glyph for the eye and once as text, visually hidden, for the
205
+ * ear — because the two bracket forms are read inconsistently by screen readers, and the whole point of the
206
+ * mark is that "this box is not ticked" survives every way of reading it. This span carries NO separator of
207
+ * its own: the item's text follows it directly, separated by the leading space on that text (see
208
+ * `lineElement`), so that neither side of the boundary has a trailing space to lose.
209
+ */
210
+ function todoMark(line: DocLine, key: string): ReactNode {
211
+ const done = line.checked === true;
212
+ const cls = 'rec-doc-todo-check' + (done ? ' rec-doc-todo-check-on' : ' rec-doc-todo-check-off');
213
+ return createElement('span', { key, className: cls, title: done ? 'done' : 'not done' },
214
+ createElement('span', { 'aria-hidden': 'true' }, done ? '[x]' : '[ ]'),
215
+ createElement('span', { className: 'rec-doc-sr' }, done ? 'done:' : 'not done:'),
216
+ );
217
+ }
218
+
140
219
  /**
141
220
  * Render one parsed line as a React element. Headings/bullets get parsePlan
142
221
  * sizing; code/table get block layout; inline markup applies to plain-ish text.
222
+ *
223
+ * ⚠ ONE RENDERER, TWO READERS. `DocViewer` and the run-start spec sheet's preview both come through here,
224
+ * so a mark that means "unticked box" or "gate reads FAIL" cannot mean one thing in the phase-doc viewer and
225
+ * another in the document a person is approving. Exported for that reason alone.
143
226
  */
144
- function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
227
+ export function lineElement(line: DocLine, i: number, isCurrent = false): ReactNode {
145
228
  const cls = 'rec-doc-line rec-doc-' + line.kind + (isCurrent ? ' rec-doc-line-current' : '');
229
+ const gateCls = line.gate === undefined ? '' : ' rec-doc-gate rec-doc-gate-' + line.gate;
230
+ const todoCls = line.checked === undefined ? '' : ' rec-doc-todo' + (line.checked ? ' rec-doc-todo-done' : ' rec-doc-todo-open');
146
231
  if (line.kind === 'blank') return createElement('div', { key: i, 'data-line': String(i), className: cls }, null);
147
232
  if (line.kind === 'code') return createElement('pre', { key: i, 'data-line': String(i), className: cls + ' rec-doc-pre' }, createElement('code', { className: 'rec-doc-code' }, line.text));
148
233
  if (line.kind === 'table') {
@@ -157,8 +242,43 @@ function lineElement(line: DocLine, i: number, isCurrent: boolean): ReactNode {
157
242
  );
158
243
  }
159
244
  const nodes = inlineNodes(parseInline(line.text), String(i));
160
- if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '•'), createElement('span', { className: 'rec-doc-li-text' }, nodes));
161
- return createElement('div', { key: i, 'data-line': String(i), className: cls }, nodes);
245
+ // ⚠ THE PREVIEW LINE IS THE DOCUMENT'S OWN LINE. The parser keeps list content MARKER-FREE (the base
246
+ // parser's shape), so the renderer puts the marker back: `- ` for a bullet, and a drawn box IN PLACE OF
247
+ // the `[ ]` / `[x]` for a task box. That is what lets a reader check the preview against the source and
248
+ // see at a glance that an unticked box is unticked.
249
+ //
250
+ // ⚠ AND THE SEPARATOR IS A NON-BREAKING SPACE WRITTEN AS AN ESCAPE, ON THE ITEM'S SIDE OF THE MARK.
251
+ // Measured, twice: a text node that ENDS in a space loses it (so a `'- '` bullet glyph renders as `-`,
252
+ // and a minifier carries that through to the shipped bundle, turning every bullet into `-item`), and a
253
+ // plain leading space is normalised away by the JSX transform before React ever sees it. An ESCAPED
254
+ // non-breaking space is neither trailing nor transformable, so it is what these separators are.
255
+ const SPACER = '\u00A0';
256
+ if (line.kind === 'li' && line.checked !== undefined) {
257
+ return createElement('div', { key: i, 'data-line': String(i), className: cls + todoCls },
258
+ createElement('span', { className: 'rec-doc-bullet' }, '-'),
259
+ todoMark(line, 'todo-' + String(i)),
260
+ createElement('span', { className: 'rec-doc-li-text' }, SPACER, nodes));
261
+ }
262
+ if (line.kind === 'li') return createElement('div', { key: i, 'data-line': String(i), className: cls }, createElement('span', { className: 'rec-doc-bullet' }, '-'), createElement('span', { className: 'rec-doc-li-text' }, SPACER, nodes));
263
+ return createElement('div', { key: i, 'data-line': String(i), className: cls + gateCls }, nodes);
264
+ }
265
+
266
+ /**
267
+ * The parsed lines as elements — the preview built from `parseDoc` + `lineElement`, with no shell of its own.
268
+ *
269
+ * ⚠ THIS IS WHAT MAKES A SECOND RENDERER UNNECESSARY. Any surface that wants to show a run artifact as a
270
+ * PREVIEW (the phase-doc viewer's body, the run-start spec sheet's document body) renders these nodes inside
271
+ * whatever frame it owns, so the markdown is parsed and drawn exactly once in the plugin. `keyBase` namespaces
272
+ * the React keys when several of these are on screen at once; `current` is the vim cursor line, which the
273
+ * spec sheet never sets.
274
+ */
275
+ export function PreviewLines({ lines, keyBase = 'doc', current = -1 }: { lines: DocLine[]; keyBase?: string; current?: number }): ReactNode {
276
+ return createElement('div', { className: 'rec-doc-lines', 'data-preview-lines': String(lines.length) },
277
+ ...lines.map((line, i) => createElement(
278
+ 'div',
279
+ { key: keyBase + '-line-' + String(i), className: 'rec-doc-line-wrap' },
280
+ lineElement(line, i, i === current),
281
+ )));
162
282
  }
163
283
 
164
284
  /**
@@ -269,11 +389,10 @@ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: Doc
269
389
  }
270
390
  };
271
391
 
272
- const matchSet = new Set(matches);
273
- const activeLine = matches.length > 0 ? matches[activeMatch] : -1;
274
-
275
- const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
276
-
392
+ // NOTE: `n`/`N` move the cursor to the matching line, which is marked `rec-doc-line-current` by
393
+ // `PreviewLines` below. The lines themselves are NOT individually match-highlighted — they were not
394
+ // before this file gained a shared preview renderer either, and inventing a highlight here would change
395
+ // how the phase-doc viewer draws a document as a side effect of the run-start spec sheet's work.
277
396
  const searchBar = searchOpen ? createElement('div', { className: 'rec-doc-search' },
278
397
  createElement('input', { className: 'rec-doc-search-input', value: query, placeholder: '/ search doc…', onChange: onSearchChange, onKeyDown: onSearchKey }),
279
398
  createElement('span', { className: 'rec-doc-search-count' }, matches.length > 0 ? (activeMatch + 1) + '/' + matches.length : (query ? '0' : '')),
@@ -296,7 +415,7 @@ export function DocViewer({ runId, worktreeRoot, fileName, theme, onClose }: Doc
296
415
  createElement('div', { className: 'rec-doc-body' },
297
416
  text === null && error === null ? createElement('p', { className: 'rec-doc-text' }, 'Loading doc…') : null,
298
417
  error !== null ? createElement('p', { className: 'rec-doc-text rec-doc-error' }, error) : null,
299
- text !== null ? lineEls : null,
418
+ text !== null ? createElement(PreviewLines, { lines: docLines, keyBase: 'doc', current: cursor }) : null,
300
419
  ),
301
420
  createElement('footer', { className: 'rec-doc-footer' },
302
421
  createElement('div', { className: statusCls, role: 'status' }, statusText),
@@ -22,6 +22,7 @@ import { isRecursivePreset, currentWorkspacePath } from './contract.ts'
22
22
  import { Board } from './board.tsx'
23
23
  import { Inspector } from './inspector.tsx'
24
24
  import { RecursiveSettings, RecursiveSettingsLive, type RecursiveSettingsSeatProps } from './settings.tsx'
25
+ import { RunStartSpecSheet, RUN_START_TOOL_NAME } from './spec-sheet.tsx'
25
26
  import { useLiveProjection } from './use-live.ts'
26
27
  import { boardState, useBoardState } from './open-state.ts'
27
28
  import { injectBoardStyles } from './styles.ts'
@@ -138,6 +139,17 @@ export function registerSlots(ctx: ClientContext): () => void {
138
139
  return createElement(RecursiveBoardOverlay, { useSessions, useWorkspaces: props?.useWorkspaces })
139
140
  })))
140
141
 
142
+ // RUN-START SPEC SHEET (tool.call.toolview, keyed by tool name). The seat that exists exactly while a
143
+ // `recursive_ask` call is on screen — and the only one that can render BESIDE the question card, because
144
+ // the composer is a chain and the run-start gate's pending interaction is a question, not an approval (see
145
+ // the header of spec-sheet.tsx for the three structural reasons `conversation.approval.detail` cannot be
146
+ // it). The view returns null for every other gate, so `tdd-mode` / `qa-signoff` / `gate-block` calls keep
147
+ // the generic tool row they have today.
148
+ disposers.push(ctx.slots.inject('tool.call.toolview', () => ctx.slots.register({
149
+ name: 'tool.call.toolview',
150
+ key: RUN_START_TOOL_NAME,
151
+ }, RunStartSpecSheet)))
152
+
141
153
  // Settings section (root scope, always present; no gate — configuration is always available).
142
154
  // The seat receives the shell's `close` PLUS the root standard kit (useSessions/useWorkspaces,
143
155
  // scoped-slots standardProps), so the panel can subscribe to the SAME live route the board