@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/client.js CHANGED
@@ -522,9 +522,29 @@ window.__ModuleLoader__.load({
522
522
  * carried over — this viewer reads run artifacts.
523
523
  */
524
524
  /**
525
+ * A list item: the text AFTER its marker. The marker is consumed here exactly as the base parser consumed
526
+ * it — `text` is the item's CONTENT, and the renderer draws the `- ` / box back from `kind` + `checked`.
527
+ */
528
+ const BULLET_RE = /^\s*[-*]\s+(.+)$/;
529
+ /**
530
+ * A task box (`[ ]`, `[x]`, `[X]`) at the front of a list item, split into its tick and the rest of the item.
531
+ *
532
+ * The tick is the one fact a reader of the document cannot recover from the text alone once the box has been
533
+ * recognised, so it travels as `checked`; everything after it is the item's content, as for any other bullet.
534
+ */
535
+ const TASK_BOX_RE = /^\[([ xX])\]\s*(.*)$/;
536
+ /** A gate reading: `Coverage: FAIL` / `Approval: PASS`, as `run-spec.ts` reads the same lines. */
537
+ const GATE_RE = /^\s*(?:Coverage|Approval)\s*:\s*(PASS|FAIL)\s*$/i;
538
+ /**
525
539
  * Markdown -> line tokens. Base is parsePlan (blank/h1-h4/li/plain, MIT),
526
540
  * extended for fenced code blocks (one code line per block) and pipe tables
527
541
  * (one table line per block, header separator row dropped).
542
+ *
543
+ * AND EXTENDED FOR WHAT THE RUN ARTIFACTS ACTUALLY CONTAIN — the task boxes and gate readings the
544
+ * scaffolded `00-requirements.md` ships (`- [ ] …`, `Coverage: FAIL`, `Approval: FAIL`), because a preview
545
+ * that renders an unticked box and a FAIL gate as generic body text hides the two marks a person who is
546
+ * being asked to approve the document most needs to see. Both are additive FIELDS on the existing `li` and
547
+ * `plain` kinds, so no line is retyped, no character is dropped, and every existing caller keeps working.
528
548
  */
529
549
  function parseDoc(plan) {
530
550
  const raw = String(plan == null ? "" : plan).split("\n");
@@ -591,11 +611,26 @@ window.__ModuleLoader__.load({
591
611
  i += 1;
592
612
  continue;
593
613
  }
594
- const li = /^\s*[-*]\s+(.+)$/.exec(line);
595
- if (li) {
596
- out.push({
614
+ const item = BULLET_RE.exec(line);
615
+ if (item) {
616
+ const box = TASK_BOX_RE.exec(item[1]);
617
+ out.push(box === null ? {
618
+ kind: "li",
619
+ text: item[1]
620
+ } : {
597
621
  kind: "li",
598
- text: li[1]
622
+ text: box[2],
623
+ checked: box[1] !== " "
624
+ });
625
+ i += 1;
626
+ continue;
627
+ }
628
+ const gate = GATE_RE.exec(line);
629
+ if (gate) {
630
+ out.push({
631
+ kind: "plain",
632
+ text: line,
633
+ gate: gate[1].toUpperCase() === "FAIL" ? "fail" : "pass"
599
634
  });
600
635
  i += 1;
601
636
  continue;
@@ -671,11 +706,39 @@ window.__ModuleLoader__.load({
671
706
  });
672
707
  }
673
708
  /**
709
+ * The mark of a task box.
710
+ *
711
+ * ⚠ THE MARK REPLACES `[ ]` / `[x]` IN PLACE, GLYPH FOR GLYPH. The parser hands over the item's content
712
+ * without the box, so what the reader sees is `- [ ] item` where the document says `- [x] item`: same line,
713
+ * same position, same length of reading — and the box is still legible as a box rather than as an assertion
714
+ * about the item.
715
+ *
716
+ * ⚠ AND THE TICK IS CARRIED TWICE — once as that glyph for the eye and once as text, visually hidden, for the
717
+ * ear — because the two bracket forms are read inconsistently by screen readers, and the whole point of the
718
+ * mark is that "this box is not ticked" survives every way of reading it. This span carries NO separator of
719
+ * its own: the item's text follows it directly, separated by the leading space on that text (see
720
+ * `lineElement`), so that neither side of the boundary has a trailing space to lose.
721
+ */
722
+ function todoMark(line, key) {
723
+ const done = line.checked === true;
724
+ return (0, react.createElement)("span", {
725
+ key,
726
+ className: "rec-doc-todo-check" + (done ? " rec-doc-todo-check-on" : " rec-doc-todo-check-off"),
727
+ title: done ? "done" : "not done"
728
+ }, (0, react.createElement)("span", { "aria-hidden": "true" }, done ? "[x]" : "[ ]"), (0, react.createElement)("span", { className: "rec-doc-sr" }, done ? "done:" : "not done:"));
729
+ }
730
+ /**
674
731
  * Render one parsed line as a React element. Headings/bullets get parsePlan
675
732
  * sizing; code/table get block layout; inline markup applies to plain-ish text.
733
+ *
734
+ * ⚠ ONE RENDERER, TWO READERS. `DocViewer` and the run-start spec sheet's preview both come through here,
735
+ * so a mark that means "unticked box" or "gate reads FAIL" cannot mean one thing in the phase-doc viewer and
736
+ * another in the document a person is approving. Exported for that reason alone.
676
737
  */
677
- function lineElement(line, i, isCurrent) {
738
+ function lineElement(line, i, isCurrent = false) {
678
739
  const cls = "rec-doc-line rec-doc-" + line.kind + (isCurrent ? " rec-doc-line-current" : "");
740
+ const gateCls = line.gate === void 0 ? "" : " rec-doc-gate rec-doc-gate-" + line.gate;
741
+ const todoCls = line.checked === void 0 ? "" : " rec-doc-todo" + (line.checked ? " rec-doc-todo-done" : " rec-doc-todo-open");
679
742
  if (line.kind === "blank") return (0, react.createElement)("div", {
680
743
  key: i,
681
744
  "data-line": String(i),
@@ -697,18 +760,42 @@ window.__ModuleLoader__.load({
697
760
  }, (0, react.createElement)("table", { className: "rec-doc-table" }, (0, react.createElement)("thead", null, (0, react.createElement)("tr", null, header.map((c, n) => (0, react.createElement)("th", { key: "th-" + String(n) }, c)))), (0, react.createElement)("tbody", null, body.map((r, n) => (0, react.createElement)("tr", { key: "tr-" + String(n) }, r.map((c, m) => (0, react.createElement)("td", { key: "td-" + String(m) }, c)))))));
698
761
  }
699
762
  const nodes = inlineNodes(parseInline(line.text), String(i));
763
+ const SPACER = "\xA0";
764
+ if (line.kind === "li" && line.checked !== void 0) return (0, react.createElement)("div", {
765
+ key: i,
766
+ "data-line": String(i),
767
+ className: cls + todoCls
768
+ }, (0, react.createElement)("span", { className: "rec-doc-bullet" }, "-"), todoMark(line, "todo-" + String(i)), (0, react.createElement)("span", { className: "rec-doc-li-text" }, SPACER, nodes));
700
769
  if (line.kind === "li") return (0, react.createElement)("div", {
701
770
  key: i,
702
771
  "data-line": String(i),
703
772
  className: cls
704
- }, (0, react.createElement)("span", { className: "rec-doc-bullet" }, "•"), (0, react.createElement)("span", { className: "rec-doc-li-text" }, nodes));
773
+ }, (0, react.createElement)("span", { className: "rec-doc-bullet" }, "-"), (0, react.createElement)("span", { className: "rec-doc-li-text" }, SPACER, nodes));
705
774
  return (0, react.createElement)("div", {
706
775
  key: i,
707
776
  "data-line": String(i),
708
- className: cls
777
+ className: cls + gateCls
709
778
  }, nodes);
710
779
  }
711
780
  /**
781
+ * The parsed lines as elements — the preview built from `parseDoc` + `lineElement`, with no shell of its own.
782
+ *
783
+ * ⚠ THIS IS WHAT MAKES A SECOND RENDERER UNNECESSARY. Any surface that wants to show a run artifact as a
784
+ * PREVIEW (the phase-doc viewer's body, the run-start spec sheet's document body) renders these nodes inside
785
+ * whatever frame it owns, so the markdown is parsed and drawn exactly once in the plugin. `keyBase` namespaces
786
+ * the React keys when several of these are on screen at once; `current` is the vim cursor line, which the
787
+ * spec sheet never sets.
788
+ */
789
+ function PreviewLines({ lines, keyBase = "doc", current = -1 }) {
790
+ return (0, react.createElement)("div", {
791
+ className: "rec-doc-lines",
792
+ "data-preview-lines": String(lines.length)
793
+ }, ...lines.map((line, i) => (0, react.createElement)("div", {
794
+ key: keyBase + "-line-" + String(i),
795
+ className: "rec-doc-line-wrap"
796
+ }, lineElement(line, i, i === current))));
797
+ }
798
+ /**
712
799
  * The per-phase doc viewer. Fetches the route on mount / fileName change.
713
800
  * Vim nav + / search + n/N + Esc; y copies the doc. Esc closes search first,
714
801
  * else the viewer. data-theme is passed down by the hoisting Inspector
@@ -878,9 +965,6 @@ window.__ModuleLoader__.load({
878
965
  else onClose();
879
966
  }
880
967
  };
881
- new Set(matches);
882
- matches.length > 0 && matches[activeMatch];
883
- const lineEls = docLines.map((line, i) => lineElement(line, i, i === cursor));
884
968
  const searchBar = searchOpen ? (0, react.createElement)("div", { className: "rec-doc-search" }, (0, react.createElement)("input", {
885
969
  className: "rec-doc-search-input",
886
970
  value: query,
@@ -908,7 +992,11 @@ window.__ModuleLoader__.load({
908
992
  onClick: onClose,
909
993
  title: "Close",
910
994
  "aria-label": "Close"
911
- }, "Close")), searchBar, (0, react.createElement)("div", { className: "rec-doc-body" }, text === null && error === null ? (0, react.createElement)("p", { className: "rec-doc-text" }, "Loading doc…") : null, error !== null ? (0, react.createElement)("p", { className: "rec-doc-text rec-doc-error" }, error) : null, text !== null ? lineEls : null), (0, react.createElement)("footer", { className: "rec-doc-footer" }, (0, react.createElement)("div", {
995
+ }, "Close")), searchBar, (0, react.createElement)("div", { className: "rec-doc-body" }, text === null && error === null ? (0, react.createElement)("p", { className: "rec-doc-text" }, "Loading doc…") : null, error !== null ? (0, react.createElement)("p", { className: "rec-doc-text rec-doc-error" }, error) : null, text !== null ? (0, react.createElement)(PreviewLines, {
996
+ lines: docLines,
997
+ keyBase: "doc",
998
+ current: cursor
999
+ }) : null), (0, react.createElement)("footer", { className: "rec-doc-footer" }, (0, react.createElement)("div", {
912
1000
  className: statusCls,
913
1001
  role: "status"
914
1002
  }, statusText), (0, react.createElement)("div", { className: "rec-doc-hints" }, SEARCH_HINTS.map((h, n) => (0, react.createElement)("span", { key: "hint-" + String(n) }, (0, react.createElement)("kbd", null, h[0]), " " + h[1] + (n < SEARCH_HINTS.length - 1 ? " |" : ""))))));
@@ -998,10 +1086,18 @@ window.__ModuleLoader__.load({
998
1086
  * Subscribe to the live route for one scope. Returns the current state. The SSE
999
1087
  * feed pushes full frames on every fs change; a dropped stream (sleep/error)
1000
1088
  * triggers one refetch so the board never serves a stale fold.
1089
+ *
1090
+ * @param scope - sessionId PRIMARY + cwd fallback hint (host resolves the root).
1091
+ * @param options - `enabled: false` clears the snapshot and never fetches.
1001
1092
  */
1002
- function useLiveProjection(scope) {
1093
+ function useLiveProjection(scope, options = {}) {
1094
+ const { enabled = true } = options;
1003
1095
  const [state, setState] = (0, react.useState)(null);
1004
1096
  (0, react.useEffect)(() => {
1097
+ if (!enabled) {
1098
+ setState(null);
1099
+ return;
1100
+ }
1005
1101
  if (isEmpty(scope)) {
1006
1102
  setState(null);
1007
1103
  return;
@@ -1018,7 +1114,11 @@ window.__ModuleLoader__.load({
1018
1114
  disposed = true;
1019
1115
  disposeEvents();
1020
1116
  };
1021
- }, [scope.sessionId, scope.cwd]);
1117
+ }, [
1118
+ scope.sessionId,
1119
+ scope.cwd,
1120
+ enabled
1121
+ ]);
1022
1122
  return state;
1023
1123
  }
1024
1124
  //#endregion
@@ -1305,6 +1405,579 @@ window.__ModuleLoader__.load({
1305
1405
  });
1306
1406
  }
1307
1407
  //#endregion
1408
+ //#region src/run-spec.ts
1409
+ /** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
1410
+ const ARTIFACT_MARKER_IDS = {
1411
+ placeholder: "placeholder",
1412
+ uncheckedTodo: "unchecked-todo",
1413
+ failedGate: "failed-gate"
1414
+ };
1415
+ /** Every marker, in the order they are reported for a single line. */
1416
+ const MARKER_PATTERNS = [
1417
+ {
1418
+ id: ARTIFACT_MARKER_IDS.placeholder,
1419
+ re: /(^\s*\.\.\.\s*$)|(\[[^[\]\n<>]{2,120}\])|(<[^<>\n]{2,120}>)/
1420
+ },
1421
+ {
1422
+ id: ARTIFACT_MARKER_IDS.uncheckedTodo,
1423
+ re: /^\s*[-*]\s*\[ \]/
1424
+ },
1425
+ {
1426
+ id: ARTIFACT_MARKER_IDS.failedGate,
1427
+ re: /^\s*(Coverage|Approval):\s*FAIL\b/i
1428
+ }
1429
+ ];
1430
+ /**
1431
+ * Which marker a single line carries, or null.
1432
+ *
1433
+ * Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
1434
+ * re-derived them would be a second answer to the same question.
1435
+ */
1436
+ function markerIdsOnLine(line) {
1437
+ const ids = [];
1438
+ for (const pattern of MARKER_PATTERNS) if (pattern.re.test(line)) ids.push(pattern.id);
1439
+ return ids;
1440
+ }
1441
+ /**
1442
+ * Classify one artifact's text.
1443
+ *
1444
+ * A verdict of `unfilled` means the document still carries the template's own placeholder text — the
1445
+ * evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
1446
+ * instead of asserting a state the reader cannot check.
1447
+ */
1448
+ function classifyArtifact(text) {
1449
+ const lines = text.split(/\r?\n/);
1450
+ const hits = [];
1451
+ for (let i = 0; i < lines.length; i += 1) {
1452
+ const line = lines[i];
1453
+ for (const id of markerIdsOnLine(line)) hits.push({
1454
+ id,
1455
+ line: i + 1,
1456
+ text: line.trim()
1457
+ });
1458
+ }
1459
+ return {
1460
+ verdict: hits.some((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder) ? "unfilled" : "filled",
1461
+ hits
1462
+ };
1463
+ }
1464
+ /** The unfilled evidence only (what a refusal names). */
1465
+ function unfilledEvidence(result) {
1466
+ return result.hits.filter((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder);
1467
+ }
1468
+ //#endregion
1469
+ //#region src/client/spec-sheet-view.ts
1470
+ /**
1471
+ * Pure display model for the RUN-START SPEC SHEET.
1472
+ *
1473
+ * WHY THIS MODULE EXISTS. `src/client/spec-sheet.tsx` renders the Phase 0 document beside the run-start
1474
+ * question so a person can read what they are approving. Everything that DECIDES what the sheet says is
1475
+ * here instead: reading the tool call's own arguments, classifying the document, and choosing the fetch
1476
+ * state. It is pure (no React, no fs, no session window), which is what lets the spec drive every branch —
1477
+ * a stub, a filled document, a missing file, a failed route, a call with no runId — without a browser.
1478
+ *
1479
+ * ⚠ THE VERDICT COMES FROM THE TEXT, NEVER FROM THE FETCH. `loading`, `absent`, `error` and `loaded` are
1480
+ * four different facts, and a single "not loaded" would collapse them — which is how a client comes to
1481
+ * render an empty box for a failure and a failure for a document nobody has written yet.
1482
+ *
1483
+ * ⚠ AND IT IS NODE-FREE. This file is reached by the BROWSER bundle. Reading the artifact belongs to the
1484
+ * server half (`run-start.ts`, `recursive_ask.tool.ts`); this half only ever DISPLAYS what the read-only
1485
+ * `/doc` route returned.
1486
+ */
1487
+ /**
1488
+ * The wire name of the tool whose call row the sheet replaces.
1489
+ *
1490
+ * `tool.call.toolview` is a KEYED seat dispatched by this exact string, so a typo renders nothing at all
1491
+ * rather than rendering wrongly — the failure mode the harness documents for a keyed seat.
1492
+ */
1493
+ const RUN_START_TOOL_NAME = "recursive_ask";
1494
+ /**
1495
+ * The Phase 0 artifact the run-start decision is recorded in.
1496
+ *
1497
+ * ⚠ DUPLICATED DELIBERATELY, AND PINNED BY A TEST. `run-start.ts` holds the same string, but importing it
1498
+ * here would pull `node:fs` / `node:crypto` into the browser bundle through `status.ts` and take the page
1499
+ * down for a constant. `tests/spec-sheet.spec.ts` asserts this value EQUALS `RUN_START_ARTIFACT`, so the copy
1500
+ * cannot drift; the client only ever reads through it and never writes.
1501
+ */
1502
+ const RUN_START_SPEC_FILE = "00-requirements.md";
1503
+ /** The gate id whose question this sheet accompanies. */
1504
+ const RUN_START_GATE_ID = "run-start";
1505
+ /** The all-null argument set: what a malformed, truncated, or absent payload yields. */
1506
+ const NO_ARGS = {
1507
+ gate: null,
1508
+ runId: null,
1509
+ artifact: null,
1510
+ answer: null
1511
+ };
1512
+ /**
1513
+ * Read the arguments out of the raw JSON the model dispatched.
1514
+ *
1515
+ * A malformed or empty payload yields all-nulls rather than throwing: a truncated mid-stream argument string
1516
+ * is a normal sight on this seat (the harness exposes preparing calls with no arguments at all), and a view
1517
+ * that threw on one would take the transcript down with it.
1518
+ */
1519
+ function parseRecursiveAskArgs(argsRaw) {
1520
+ if (argsRaw === null || argsRaw.trim() === "") return { ...NO_ARGS };
1521
+ let parsed;
1522
+ try {
1523
+ parsed = JSON.parse(argsRaw);
1524
+ } catch {
1525
+ return { ...NO_ARGS };
1526
+ }
1527
+ if (typeof parsed !== "object" || parsed === null) return { ...NO_ARGS };
1528
+ const record = parsed;
1529
+ const str = (value) => typeof value === "string" && value.trim() !== "" ? value.trim() : null;
1530
+ return {
1531
+ gate: str(record.gate),
1532
+ runId: str(record.runId),
1533
+ artifact: str(record.artifact),
1534
+ answer: str(record.answer)
1535
+ };
1536
+ }
1537
+ /**
1538
+ * Read the call's arguments out of whichever phase the owner supplied.
1539
+ *
1540
+ * ⚠ THE THREE FAILURES ARE KEPT APART. "No block", "the arguments have not been dispatched yet" and "the
1541
+ * window truncated the call" are three different facts, and a client that collapsed them into one empty
1542
+ * argument set would tell a person the call named no run — a claim about the CALLER, made on evidence about
1543
+ * the CLIENT.
1544
+ */
1545
+ function readCall(block) {
1546
+ if (block === null || block === void 0) return {
1547
+ ok: false,
1548
+ reason: "missing"
1549
+ };
1550
+ if (block.phase === "preparing") return {
1551
+ ok: false,
1552
+ reason: "preparing"
1553
+ };
1554
+ if (block.phase === "start") return {
1555
+ ok: true,
1556
+ args: parseRecursiveAskArgs(block.argsRaw)
1557
+ };
1558
+ const carried = block.call ?? null;
1559
+ return carried === null ? {
1560
+ ok: false,
1561
+ reason: "truncated"
1562
+ } : {
1563
+ ok: true,
1564
+ args: parseRecursiveAskArgs(carried.argsRaw)
1565
+ };
1566
+ }
1567
+ /** Is this the run-start gate? Everything else keeps the generic tool row. */
1568
+ function isRunStartCall(args) {
1569
+ return args.gate === RUN_START_GATE_ID;
1570
+ }
1571
+ /** Whether this call is one the sheet claims at all: the run-start gate, with a run named. */
1572
+ function isSpecSheetCall(read) {
1573
+ return read.ok && isRunStartCall(read.args) && read.args.runId !== null;
1574
+ }
1575
+ /**
1576
+ * Build the sheet's model from what the client actually has.
1577
+ *
1578
+ * ⚠ THE TEXT IS ONLY EVER TAKEN FROM A `loaded` FETCH. A stale body from a previous run is not shown against
1579
+ * a new run id, because "this is the document" is the one claim this seat exists to make truthfully.
1580
+ */
1581
+ function specSheetModel(input) {
1582
+ const file = input.file ?? "00-requirements.md";
1583
+ const text = input.fetch.state === "loaded" ? input.fetch.text ?? null : null;
1584
+ const verdict = text === null ? null : classifyArtifact(text);
1585
+ return {
1586
+ runId: input.args.runId,
1587
+ root: input.root,
1588
+ file,
1589
+ state: input.fetch.state,
1590
+ error: input.fetch.error ?? null,
1591
+ text,
1592
+ verdict: verdict === null ? null : verdict.verdict,
1593
+ evidence: verdict === null ? [] : verdict.hits,
1594
+ args: input.args
1595
+ };
1596
+ }
1597
+ /** The document's path, as it should be PRINTED (never as it is fetched). */
1598
+ function specPath(runId, root, file) {
1599
+ const at = ".recursive/run/" + (runId ?? "<runId>") + "/" + file;
1600
+ return root === null || root.trim() === "" ? at : at + " (in " + root + ")";
1601
+ }
1602
+ /** The mode a freshly mounted sheet opens in: the rendered document. */
1603
+ const SPEC_DEFAULT_MODE = "preview";
1604
+ /** The toggle's own label, ALWAYS naming the mode that is on screen — never the one a click would reach. */
1605
+ const SPEC_MODE_LABEL = {
1606
+ preview: "Showing: rendered preview",
1607
+ raw: "Showing: raw source"
1608
+ };
1609
+ /** What the toggle does next, in the imperative, so the control reads as an action and its state reads apart. */
1610
+ const SPEC_TOGGLE_LABEL = {
1611
+ preview: "View source",
1612
+ raw: "Back to preview"
1613
+ };
1614
+ /** One line describing what is on screen, in both modes, for the announced status. */
1615
+ const SPEC_MODE_NOTE = {
1616
+ preview: "The document is rendered here as a preview: headings, lists, code and tables are drawn as such.",
1617
+ raw: "The document is shown here VERBATIM — the exact bytes, with nothing rendered and nothing removed."
1618
+ };
1619
+ /** The other mode — what one press of the toggle reaches. */
1620
+ function otherMode(mode) {
1621
+ return mode === "preview" ? "raw" : "preview";
1622
+ }
1623
+ /**
1624
+ * Describe the toggle for a mode.
1625
+ *
1626
+ * ⚠ `pressed` IS DERIVED FROM THE MODE, NEVER STORED BESIDE IT. A second boolean that could disagree with the
1627
+ * mode is how a control comes to say "pressed" while the rendered document is on screen — and this is the one
1628
+ * control whose whole job is to tell the reader which of the two things they are looking at.
1629
+ */
1630
+ function specViewToggle(mode) {
1631
+ return {
1632
+ mode,
1633
+ action: SPEC_TOGGLE_LABEL[mode],
1634
+ pressed: mode === "raw",
1635
+ announce: SPEC_MODE_LABEL[mode] + ". " + SPEC_MODE_NOTE[mode]
1636
+ };
1637
+ }
1638
+ /**
1639
+ * Decide the body content for a document text and a mode.
1640
+ *
1641
+ * ⚠ NULL TEXT IS NOT `''`. A `null` document is one that was never loaded, and it must not be printed as an
1642
+ * empty document — "there is nothing here" and "the document is empty" are different claims, and this seat
1643
+ * exists to make claims a reader can trust.
1644
+ */
1645
+ function specBodyContent(text, mode) {
1646
+ if (text === null) return null;
1647
+ return mode === "raw" ? {
1648
+ mode: "raw",
1649
+ text
1650
+ } : {
1651
+ mode: "preview",
1652
+ text
1653
+ };
1654
+ }
1655
+ /** What the sheet says while the call is still preparing (its arguments are not dispatched yet). */
1656
+ const PREPARING_NOTICE = "This tool call has not been dispatched yet, so its arguments — and the run it names — are not available.";
1657
+ /** What the sheet says when the loaded window truncated the call head away. */
1658
+ const TRUNCATED_NOTICE = "The loaded window left this call's own arguments outside it, so the run spec it names cannot be resolved here.";
1659
+ /** What each offered label means, quoted from the gate's own option descriptions. */
1660
+ const RUN_START_OPTION_MEANING = "“Start run” records the approval and arms the run goal; “Hold” leaves the spec inert: no run goal, no autonomous rounds.";
1661
+ /**
1662
+ * The recorded decision, when this call carries one.
1663
+ *
1664
+ * ⚠ A `Hold` IS NOT AN APPROVAL and is not printed as one. The test is the same VALUE test the server makes
1665
+ * (`run-start.ts::isRunStartApproval` — the approving label, not the mere presence of a decision line), so
1666
+ * the sheet can never describe a hold as an approval.
1667
+ */
1668
+ function decisionLine(answer) {
1669
+ if (answer === null) return null;
1670
+ return answer.trim() === "Start run" ? "This call records: " + answer + " — the approving label, which arms the run goal." : "This call records: " + answer + " — not the approving label, so no run goal is armed.";
1671
+ }
1672
+ //#endregion
1673
+ //#region src/client/spec-sheet.tsx
1674
+ /**
1675
+ * THE RUN-START SPEC SHEET — the document beside the question it decides.
1676
+ *
1677
+ * THE DEFECT THIS ANSWERS. The owner asked for a run spec, the plugin scaffolded one and raised the
1678
+ * `run-start` gate — "start this run or hold?" — and NOBODY WAS SHOWN THE DOCUMENT:
1679
+ * *"the card ui for accepting the spec appeared, but i was never shown the spec before that so how could i
1680
+ * approve if i havent seen it"*. The gate was decidable before it was readable.
1681
+ *
1682
+ * ⚠ WHERE THIS RENDERS, AND WHY NOT IN `conversation.approval.detail`. That seat was the first candidate —
1683
+ * its catalog summary reads "Optional detail for the Tool call correlated with an approval request" — and it
1684
+ * is the WRONG one, for a reason that is structural rather than stylistic:
1685
+ *
1686
+ * 1. `conversation.composer` is a CHAIN slot: its entries' selectors run in order and the FIRST non-null
1687
+ * match renders. `ui-approval` claims it with `select: pendingInteraction instanceof PendingApproval`
1688
+ * and declares `conversation.approval.detail` as its child; `ui-user-questions` claims it with
1689
+ * `select: pendingInteraction instanceof PendingQuestion` and declares
1690
+ * `conversation.plan-review.actions` instead. They are two mutually exclusive cells of ONE chain.
1691
+ * 2. The run-start gate is asked through the USER-QUESTIONS channel (`askRunStartDirectly` →
1692
+ * `channel.ask(...)`, correlated by `wait: { callId: exec.callId }`), so while it is pending the
1693
+ * pending interaction IS a `PendingQuestion` — the question composer owns the composer, and
1694
+ * `conversation.approval.detail` is not mounted at all. A sheet registered there would render on the
1695
+ * one occasion it is not needed and never on the one it is.
1696
+ * 3. `tool.call.toolview` keyed by tool name is the seat that exists exactly while THIS tool call is on
1697
+ * screen, is unclaimed for `recursive_ask` (the harness's own `ask_user_question` IS claimed, which is
1698
+ * the parallel that matters), and hands the view its own `callId`, the session `cwd` and the frozen
1699
+ * call block — which is how the `runId` is read out of the call's own arguments.
1700
+ *
1701
+ * WHAT IT SHOWS — AND THE DEFECT THAT CHANGED IT. This seat first showed the ACTUAL bytes of
1702
+ * `.recursive/run/<runId>/00-requirements.md` as RAW TEXT, on the reasoning that verbatim is the honest
1703
+ * thing to print. The honest answer to "is that a preview?" was NO, and the owner was being asked to APPROVE
1704
+ * what they read: *reading `##` headings and pipe-table syntax is not reviewing a spec*. So the default is now
1705
+ * a RENDERED PREVIEW — headings, lists, fenced code and pipe tables drawn as such — and the verbatim text is
1706
+ * one press away behind "View source".
1707
+ *
1708
+ * ⚠ AND THE RAW MODE IS NOT DECORATION. A preview is an INTERPRETATION, and this seat asks a person to
1709
+ * approve a document on the strength of it. The raw view exists so that "the preview is not paraphrasing or
1710
+ * hiding anything" is a claim the reader can CHECK against the bytes rather than one they must take from the
1711
+ * renderer. Its state is carried by `aria-pressed` and repeated in a `role="status"` line that names the mode
1712
+ * on screen, so the mode is announced and not merely coloured.
1713
+ *
1714
+ * ⚠ ONE RENDERER, NOT TWO. The markdown is parsed and drawn by `doc-viewer.tsx` — `parseDoc` and its
1715
+ * `PreviewLines` — the same code path the phase-doc viewer uses. A second markdown renderer in one plugin
1716
+ * would be a second answer to "what does this document say", which is the defect this plugin exists to
1717
+ * refuse. What `parseDoc` could not carry for a real `00-requirements.md` (the task boxes and the gate
1718
+ * readings the template ships) was added THERE, for both readers, rather than forked here.
1719
+ *
1720
+ * WHAT IT STILL REFUSES TO DO. It is READ-ONLY (R9): the sheet has no approve/hold control, because a second
1721
+ * control that looks like the question card's would be a second way to answer one question — the sheet may
1722
+ * change HOW the document is shown, never WHETHER it is approved. And the honesty rule of
1723
+ * `settings-view.ts` is unchanged: when the artifact is still the unfilled template the rendered view says so
1724
+ * loudly and the unfilled marks stay visible AS unfilled (`☐ …` items and `Coverage: FAIL` gates are drawn as
1725
+ * exactly that), so a pretty preview of an empty form can never read as an approvable spec.
1726
+ *
1727
+ * ACCESSIBILITY. The outer element is a `region` named by its own heading (`aria-labelledby`); the document
1728
+ * body is a focusable (`tabIndex=0`) scrolling box with its own `aria-label` and `aria-readonly`, so the
1729
+ * keyboard reaches the text and can scroll it without the decision controls leaving the viewport (the cap is
1730
+ * CSS `max-height` with `overflow-y: auto`). The box is the SAME element in both modes, so toggling never
1731
+ * drops focus. Escape is swallowed deliberately, because on a dialog-ish surface Escape is the key that
1732
+ * dismisses and dismissing this sheet must never be read as a decision; and the one transition it has is none
1733
+ * under `prefers-reduced-motion` (see the `.rec-spec` rules in `styles.ts`). No modal is used: nothing here
1734
+ * needs a focus trap, and a trap would take the keyboard away from the decision controls.
1735
+ */
1736
+ const EMPTY_WORKSPACES = {
1737
+ items: [],
1738
+ recentWorkspaceId: void 0
1739
+ };
1740
+ /**
1741
+ * A scope that resolves nothing.
1742
+ *
1743
+ * The hook stays UNCONDITIONAL (the run 13 lesson: a conditional hook changed the hook count across renders
1744
+ * and threw "Rendered more hooks than during the previous render"), so a row that is not a run-start call
1745
+ * passes this and the route is never asked for it.
1746
+ */
1747
+ const NO_SCOPE = {
1748
+ sessionId: void 0,
1749
+ cwd: void 0
1750
+ };
1751
+ /**
1752
+ * Resolve the workspace root the `/doc` route will accept.
1753
+ *
1754
+ * ⚠ THE ROOT CANNOT BE THE SESSION CWD BY GUESSWORK. The route re-validates whatever root it is handed
1755
+ * against the host's own workspace registry (`live-route.ts`: `resolveRoot(undefined, root)` must return the
1756
+ * same canonical path), so the root used here is the one the live route ITSELF answered with; the workspace
1757
+ * path is the hydration hint every other seat passes, and the owner-supplied `cwd` is the last resort.
1758
+ */
1759
+ function resolveSpecRoot(snapshotRoot, workspacePath, cwd) {
1760
+ if (typeof snapshotRoot === "string" && snapshotRoot.trim() !== "") return snapshotRoot;
1761
+ if (workspacePath.trim() !== "") return workspacePath;
1762
+ return cwd.trim() === "" ? null : cwd;
1763
+ }
1764
+ /** The route states the /doc fetch can produce, and what each one MEANS. */
1765
+ function readDoc(runId, root, file) {
1766
+ const [fetchState, setFetchState] = (0, react.useState)({ state: "loading" });
1767
+ (0, react.useEffect)(() => {
1768
+ if (runId === null || root === null) {
1769
+ setFetchState({ state: "loading" });
1770
+ return;
1771
+ }
1772
+ let disposed = false;
1773
+ setFetchState({ state: "loading" });
1774
+ fetchPhaseDoc({
1775
+ root,
1776
+ runId,
1777
+ file
1778
+ }).then((text) => {
1779
+ if (!disposed) setFetchState({
1780
+ state: "loaded",
1781
+ text
1782
+ });
1783
+ }).catch((err) => {
1784
+ if (disposed) return;
1785
+ const message = err instanceof Error ? err.message : String(err);
1786
+ const missing = /HTTP 404\b/.test(message);
1787
+ setFetchState({
1788
+ state: missing ? "absent" : "error",
1789
+ error: message
1790
+ });
1791
+ });
1792
+ return () => {
1793
+ disposed = true;
1794
+ };
1795
+ }, [
1796
+ runId,
1797
+ root,
1798
+ file
1799
+ ]);
1800
+ return fetchState;
1801
+ }
1802
+ /**
1803
+ * The seat component: renders the spec sheet for ONE `recursive_ask` tool call, and NOTHING for any other.
1804
+ *
1805
+ * Returning `null` is how an unclaimed key behaves, so a `tdd-mode` / `qa-signoff` / `gate-block` ask, or a
1806
+ * preparing call whose arguments have not been dispatched yet, keeps the generic tool row it has today.
1807
+ */
1808
+ function RunStartSpecSheet({ block, cwd, useSessions, useWorkspaces }) {
1809
+ const sessions = useSessions === void 0 ? null : useSessions((s) => s);
1810
+ const workspaces = useWorkspaces === void 0 ? EMPTY_WORKSPACES : useWorkspaces((s) => s) ?? EMPTY_WORKSPACES;
1811
+ const workspacePath = sessions === null ? "" : currentWorkspacePath(workspaces, sessions);
1812
+ const sessionCwd = sessions === null ? "" : currentSessionCwd(sessions);
1813
+ const read = readCall(block);
1814
+ const claims = isSpecSheetCall(read);
1815
+ const runId = read.ok ? read.args.runId : null;
1816
+ const root = resolveSpecRoot(useLiveProjection(sessions === null ? NO_SCOPE : {
1817
+ sessionId: sessions.current,
1818
+ cwd: workspacePath !== "" ? workspacePath : sessionCwd
1819
+ }, { enabled: claims })?.root ?? null, workspacePath, cwd ?? "");
1820
+ const fetchState = readDoc(claims ? runId : null, root, RUN_START_SPEC_FILE);
1821
+ if (!read.ok) {
1822
+ if (read.reason === "missing") return null;
1823
+ const text = read.reason === "preparing" ? PREPARING_NOTICE : TRUNCATED_NOTICE;
1824
+ return specFrame((0, react.createElement)("p", {
1825
+ className: "rec-spec-notice",
1826
+ role: "status"
1827
+ }, text));
1828
+ }
1829
+ if (!claims) return null;
1830
+ if (read.args.runId === null) return specFrame((0, react.createElement)("p", {
1831
+ className: "rec-spec-notice",
1832
+ role: "status"
1833
+ }, "This `recursive_ask` call names no runId, so no run spec can be shown for it."));
1834
+ return (0, react.createElement)(RunStartSpecSheetBody, { model: specSheetModel({
1835
+ args: read.args,
1836
+ root,
1837
+ fetch: fetchState
1838
+ }) });
1839
+ }
1840
+ /**
1841
+ * The body, from a MODEL — split out so the spec can drive every state (stub, filled, absent, error, a
1842
+ * recorded Hold, a recorded approval) without a route, a session, or a fetch.
1843
+ *
1844
+ * The MODE is local state and starts at the rendered preview (`SPEC_DEFAULT_MODE`); everything that decides
1845
+ * what the sheet SAYS still comes from the pure model, so a spec can assert the words and the marks without
1846
+ * mounting a component.
1847
+ */
1848
+ function RunStartSpecSheetBody({ model }) {
1849
+ const headingId = "rec-spec-title-" + (model.runId ?? "unresolved");
1850
+ const [mode, setMode] = (0, react.useState)(SPEC_DEFAULT_MODE);
1851
+ const toggle = specViewToggle(mode);
1852
+ return (0, react.createElement)("section", {
1853
+ className: "rec-spec",
1854
+ role: "region",
1855
+ "aria-labelledby": headingId,
1856
+ "aria-readonly": "true",
1857
+ "data-verdict": model.verdict ?? model.state,
1858
+ "data-mode": mode,
1859
+ onKeyDown: (event) => {
1860
+ if (event.key !== "Escape") return;
1861
+ event.preventDefault?.();
1862
+ event.stopPropagation?.();
1863
+ }
1864
+ }, (0, react.createElement)("header", { className: "rec-spec-header" }, (0, react.createElement)("h3", {
1865
+ className: "rec-spec-title",
1866
+ id: headingId
1867
+ }, "Run spec — " + (model.runId ?? "runId not carried") + " / " + model.file), (0, react.createElement)("span", { className: "rec-spec-tag" }, "read-only")), (0, react.createElement)("p", { className: "rec-spec-path" }, specPath(model.runId, model.root, model.file)), notice(model), questionBlock(model), viewControls(toggle, model, () => {
1868
+ setMode(otherMode(toggle.mode));
1869
+ }), documentBody(model, mode));
1870
+ }
1871
+ /**
1872
+ * The mode control: one button, and the line that announces the state it just put the sheet in.
1873
+ *
1874
+ * ⚠ KEYBOARD-REACHABLE BY BEING A BUTTON. It is a real `<button type="button">`, so Tab reaches it and
1875
+ * Enter/Space press it with no key handler of our own to get wrong. `aria-pressed` carries the state to a
1876
+ * screen reader on the control, and the `role="status"` paragraph repeats it in words, naming the mode ON
1877
+ * SCREEN ("Showing: raw source") rather than the action — so neither a pointer user nor a reader has to infer
1878
+ * the current mode from the button's face.
1879
+ */
1880
+ function viewControls(toggle, model, onToggle) {
1881
+ const shown = model.text !== null;
1882
+ return (0, react.createElement)("div", { className: "rec-spec-view" }, (0, react.createElement)("button", {
1883
+ type: "button",
1884
+ className: "rec-spec-view-toggle",
1885
+ "aria-pressed": toggle.pressed,
1886
+ title: toggle.announce,
1887
+ "aria-disabled": !shown,
1888
+ onClick: () => {
1889
+ if (shown) onToggle();
1890
+ }
1891
+ }, toggle.action), (0, react.createElement)("span", {
1892
+ className: "rec-spec-view-state",
1893
+ role: "status"
1894
+ }, toggle.announce));
1895
+ }
1896
+ /**
1897
+ * The document body: the rendered preview, or the raw bytes — and only ever the document's OWN text.
1898
+ *
1899
+ * Both modes are built from the same string, so neither is a second reading of the file: `raw` prints the
1900
+ * characters, `preview` hands those same characters to the one parser this plugin has.
1901
+ */
1902
+ function documentBody(model, mode) {
1903
+ const content = specBodyContent(model.text, mode);
1904
+ const box = {
1905
+ className: "rec-spec-body-scroll" + (content !== null && content.mode === "raw" ? " rec-spec-body-raw" : ""),
1906
+ tabIndex: 0,
1907
+ role: "group",
1908
+ "aria-label": "Document text of " + (model.runId ?? "the run") + " / " + model.file + ", read-only, " + (mode === "raw" ? "raw source" : "rendered preview")
1909
+ };
1910
+ if (content === null) return (0, react.createElement)("div", box, (0, react.createElement)("p", { className: "rec-spec-empty" }, "No document text to show."));
1911
+ if (content.mode === "raw") return (0, react.createElement)("div", box, (0, react.createElement)("pre", { className: "rec-spec-text" }, content.text));
1912
+ return (0, react.createElement)("div", box, (0, react.createElement)(SpecPreview, {
1913
+ text: content.text,
1914
+ runId: model.runId,
1915
+ file: model.file
1916
+ }));
1917
+ }
1918
+ /**
1919
+ * The rendered document.
1920
+ *
1921
+ * The parsing is memoised on the TEXT, so a toggle back and forth does not re-parse the document, and the
1922
+ * elements come from `doc-viewer.tsx`'s `PreviewLines` — the one place in this plugin where a `DocLine`
1923
+ * becomes markup.
1924
+ */
1925
+ function SpecPreview({ text, runId, file }) {
1926
+ const lines = (0, react.useMemo)(() => parseDoc(text), [text]);
1927
+ return (0, react.createElement)(PreviewLines, {
1928
+ lines,
1929
+ keyBase: "rec-spec-" + (runId ?? "run") + "-" + file
1930
+ });
1931
+ }
1932
+ /** The verdict notice: what the document IS, decided from its own text. */
1933
+ function notice(model) {
1934
+ if (model.state === "loading") return (0, react.createElement)("p", {
1935
+ className: "rec-spec-notice",
1936
+ role: "status"
1937
+ }, "Reading the document…");
1938
+ if (model.state === "absent") return (0, react.createElement)("p", {
1939
+ className: "rec-spec-notice rec-spec-notice-error",
1940
+ role: "status"
1941
+ }, "That document does not exist yet, so there is nothing here to read and nothing to approve.");
1942
+ if (model.state === "error") return (0, react.createElement)("p", {
1943
+ className: "rec-spec-notice rec-spec-notice-error",
1944
+ role: "status"
1945
+ }, "The document could not be read: " + (model.error ?? "the route gave no reason"));
1946
+ if (model.verdict === "unfilled") {
1947
+ const strong = unfilledEvidence({
1948
+ verdict: "unfilled",
1949
+ hits: model.evidence
1950
+ });
1951
+ const context = model.evidence.filter((hit) => hit.id !== "placeholder");
1952
+ return (0, react.createElement)("div", {
1953
+ className: "rec-spec-notice rec-spec-notice-unfilled",
1954
+ role: "status"
1955
+ }, (0, react.createElement)("p", { className: "rec-spec-unfilled-lead" }, "This document is still the UNFILLED TEMPLATE. There is no spec to approve yet."), (0, react.createElement)("p", { className: "rec-spec-unfilled-why" }, "The template's own placeholder text is still in it:"), (0, react.createElement)("ul", { className: "rec-spec-evidence" }, strong.slice(0, 8).map((hit, n) => (0, react.createElement)("li", {
1956
+ key: "placeholder-" + String(n),
1957
+ className: "rec-spec-evidence-item"
1958
+ }, "line " + String(hit.line) + ": " + hit.text))), strong.length > 8 ? (0, react.createElement)("p", { className: "rec-spec-unfilled-why" }, "…and " + String(strong.length - 8) + " more placeholder lines.") : null, context.length > 0 ? (0, react.createElement)("p", { className: "rec-spec-unfilled-why" }, "It also carries " + String(context.length) + " unfinished marker(s) of its own: " + context.slice(0, 3).map((hit) => "line " + String(hit.line) + ": " + hit.text).join(" | ")) : null, (0, react.createElement)("p", { className: "rec-spec-unfilled-next" }, "Fill the document in first. Approving here would record a decision about a spec that does not exist yet."));
1959
+ }
1960
+ if (model.verdict === "filled") return (0, react.createElement)("p", {
1961
+ className: "rec-spec-notice rec-spec-notice-filled",
1962
+ role: "status"
1963
+ }, "The document carries real content: no template placeholder remains in it.");
1964
+ return null;
1965
+ }
1966
+ /** The decision under way, in the gate's own words, plus the fact that the controls live elsewhere. */
1967
+ function questionBlock(model) {
1968
+ const recorded = decisionLine(model.args.answer);
1969
+ return (0, react.createElement)("div", { className: "rec-spec-question" }, (0, react.createElement)("p", { className: "rec-spec-question-lead" }, "The question about this document is: “Approve phase 0 and start this run? Approving creates an armed goal the harness will keep driving.”"), (0, react.createElement)("p", { className: "rec-spec-question-options" }, RUN_START_OPTION_MEANING), (0, react.createElement)("p", { className: "rec-spec-question-where" }, "The decision controls are the question card's. This panel is read-only: it casts no vote and writes nothing."), recorded === null ? null : (0, react.createElement)("p", { className: "rec-spec-decision" }, recorded));
1970
+ }
1971
+ /** A minimal named frame for the states that precede a document (no call, no run id). */
1972
+ function specFrame(...children) {
1973
+ return (0, react.createElement)("section", {
1974
+ className: "rec-spec",
1975
+ role: "region",
1976
+ "aria-label": "Run spec",
1977
+ "aria-readonly": "true"
1978
+ }, ...children);
1979
+ }
1980
+ //#endregion
1308
1981
  //#region src/client/open-state.ts
1309
1982
  /**
1310
1983
  * Shared board open-state (run 08 R1): module-level store behind the launcher,
@@ -1382,7 +2055,7 @@ window.__ModuleLoader__.load({
1382
2055
  /* ===== dsh-recursive Paper theme (run 16) ===== */
1383
2056
 
1384
2057
  /* Raw tokens: light (default) + dark + shared, scoped to the board/inspector. */
1385
- .rec-board, .rec-inspector {
2058
+ .rec-board, .rec-inspector, .rec-spec {
1386
2059
  /* light */
1387
2060
  --rm3-light-background: #FFFFFF;
1388
2061
  --rm3-light-foreground: #111111;
@@ -2041,6 +2714,9 @@ window.__ModuleLoader__.load({
2041
2714
  line-height: 1.7;
2042
2715
  padding: 0 6px;
2043
2716
  border-left: 2px solid transparent;
2717
+ /* Containing block for the visually-hidden text that carries a task box's tick to a screen reader, so it
2718
+ cannot be positioned against the page instead of against its own line. */
2719
+ position: relative;
2044
2720
  }
2045
2721
 
2046
2722
  .rec-doc-line-current {
@@ -2465,8 +3141,302 @@ dl.rec-settings-rows {
2465
3141
  overflow-wrap: anywhere;
2466
3142
  }
2467
3143
 
3144
+ /* ===== Run-start spec sheet (tool.call.toolview key 'recursive_ask') =====
3145
+ The document beside the run-start question. LIGHT is the only theme here: the sheet renders inline in a
3146
+ transcript row, not on the board, so it does not own a data-theme attribute and must not inherit the
3147
+ board's. The body is the ONLY scrolling box (a max-height cap with overflow-y: auto) so the decision
3148
+ controls below it can never be pushed out of reach by a long document. */
3149
+ .rec-spec {
3150
+ box-sizing: border-box;
3151
+ margin: 6px 0;
3152
+ padding: var(--board-space-12);
3153
+ border: 1px solid var(--board-border);
3154
+ border-radius: var(--board-radius-lg);
3155
+ background: var(--board-card);
3156
+ color: var(--board-fg);
3157
+ font-family: var(--board-font-sans);
3158
+ font-size: var(--board-text-sm);
3159
+ /* The one transition this sheet has. It is disabled outright under prefers-reduced-motion below. */
3160
+ transition: border-color 160ms ease;
3161
+ }
3162
+
3163
+ .rec-spec-header {
3164
+ display: flex;
3165
+ align-items: baseline;
3166
+ justify-content: space-between;
3167
+ gap: var(--board-space-8);
3168
+ }
3169
+
3170
+ .rec-spec-title {
3171
+ margin: 0;
3172
+ font-size: var(--board-text-sm);
3173
+ font-weight: var(--board-fw-semibold);
3174
+ letter-spacing: var(--board-tracking-tight);
3175
+ overflow-wrap: anywhere;
3176
+ }
3177
+
3178
+ .rec-spec-tag {
3179
+ flex: none;
3180
+ font-size: var(--board-text-xs);
3181
+ color: var(--board-muted-fg);
3182
+ }
3183
+
3184
+ .rec-spec-path {
3185
+ margin: 2px 0 var(--board-space-8);
3186
+ font-family: var(--board-font-mono);
3187
+ font-size: var(--board-text-xs);
3188
+ color: var(--board-muted-fg);
3189
+ overflow-wrap: anywhere;
3190
+ }
3191
+
3192
+ .rec-spec-notice {
3193
+ margin: 0 0 var(--board-space-8);
3194
+ padding: var(--board-space-8);
3195
+ border: 1px solid var(--board-border);
3196
+ border-radius: var(--board-radius-md);
3197
+ background: var(--board-muted);
3198
+ font-size: var(--board-text-xs);
3199
+ line-height: 1.5;
3200
+ }
3201
+
3202
+ /* An UNFILLED template is stated as a warning, never as a neutral fact: the whole point of this seat is that
3203
+ nobody is nudged toward approving a hollow document. */
3204
+ .rec-spec-notice-unfilled {
3205
+ border-color: var(--board-warning);
3206
+ background: color-mix(in srgb, var(--board-warning) 10%, var(--board-card));
3207
+ }
3208
+
3209
+ .rec-spec-unfilled-lead {
3210
+ margin: 0 0 6px;
3211
+ font-weight: var(--board-fw-semibold);
3212
+ color: var(--board-warning);
3213
+ }
3214
+
3215
+ .rec-spec-unfilled-why {
3216
+ margin: 0 0 4px;
3217
+ color: var(--board-muted-fg);
3218
+ }
3219
+
3220
+ .rec-spec-unfilled-next {
3221
+ margin: 6px 0 0;
3222
+ font-weight: var(--board-fw-medium);
3223
+ }
3224
+
3225
+ .rec-spec-evidence {
3226
+ margin: 0 0 4px;
3227
+ padding-left: 18px;
3228
+ font-family: var(--board-font-mono);
3229
+ font-size: var(--board-text-xs);
3230
+ }
3231
+
3232
+ .rec-spec-evidence-item {
3233
+ overflow-wrap: anywhere;
3234
+ }
3235
+
3236
+ .rec-spec-notice-filled {
3237
+ border-color: var(--board-success);
3238
+ background: color-mix(in srgb, var(--board-success) 8%, var(--board-card));
3239
+ }
3240
+
3241
+ .rec-spec-notice-error {
3242
+ border-color: var(--board-error);
3243
+ }
3244
+
3245
+ .rec-spec-question {
3246
+ margin: 0 0 var(--board-space-8);
3247
+ }
3248
+
3249
+ .rec-spec-question-lead,
3250
+ .rec-spec-question-options,
3251
+ .rec-spec-question-where {
3252
+ margin: 0 0 4px;
3253
+ font-size: var(--board-text-xs);
3254
+ line-height: 1.5;
3255
+ }
3256
+
3257
+ .rec-spec-question-where {
3258
+ color: var(--board-muted-fg);
3259
+ }
3260
+
3261
+ .rec-spec-decision {
3262
+ margin: 0;
3263
+ font-size: var(--board-text-xs);
3264
+ font-weight: var(--board-fw-medium);
3265
+ }
3266
+
3267
+ /* The document body: the one bounded scroller. Focusable (tabIndex=0) for the keyboard, and the border
3268
+ marks the focus ring's home rather than relying on a colour change alone. */
3269
+ .rec-spec-body-scroll {
3270
+ max-height: 420px;
3271
+ overflow-y: auto;
3272
+ overscroll-behavior: contain;
3273
+ border: 1px solid var(--board-border);
3274
+ border-radius: var(--board-radius-md);
3275
+ background: var(--board-muted);
3276
+ }
3277
+
3278
+ .rec-spec-body-scroll:focus-visible {
3279
+ outline: 2px solid var(--board-info);
3280
+ outline-offset: 2px;
3281
+ }
3282
+
3283
+ /* The rendered preview inside that scroller (PreviewLines from doc-viewer.tsx). The line blocks carry their
3284
+ own padding so the marks — task boxes, gate readings — line up in a column down the left edge. */
3285
+ .rec-spec-body-scroll .rec-doc-lines {
3286
+ padding: var(--board-space-12);
3287
+ }
3288
+
3289
+ .rec-spec-body-scroll .rec-doc-line {
3290
+ padding: 0;
3291
+ }
3292
+
3293
+ /* Raw source is monospace, wrapping, and NOT reflowed: the same characters the preview was built from. */
3294
+ .rec-spec-text {
3295
+ margin: 0;
3296
+ padding: var(--board-space-12);
3297
+ font-family: var(--board-font-mono);
3298
+ font-size: var(--board-text-xs);
3299
+ line-height: 1.55;
3300
+ /* The document is shown VERBATIM: only wrapping is normalised, never the characters. */
3301
+ white-space: pre-wrap;
3302
+ overflow-wrap: anywhere;
3303
+ tab-size: 2;
3304
+ }
3305
+
3306
+ /* ===== The mode control (preview <-> raw source) =====
3307
+ One real button, so Tab reaches it and Enter/Space press it; aria-pressed carries its state to a screen
3308
+ reader and the role=status line beside it names the mode on screen in words. */
3309
+ .rec-spec-view {
3310
+ display: flex;
3311
+ align-items: center;
3312
+ flex-wrap: wrap;
3313
+ gap: var(--board-space-8);
3314
+ margin: 0 0 var(--board-space-8);
3315
+ }
3316
+
3317
+ .rec-spec-view-toggle {
3318
+ flex: none;
3319
+ padding: 4px 12px;
3320
+ font-family: inherit;
3321
+ font-size: var(--board-text-xs);
3322
+ color: var(--board-fg);
3323
+ background: var(--board-card);
3324
+ border: 1px solid var(--board-border);
3325
+ border-radius: 999px;
3326
+ cursor: pointer;
3327
+ white-space: nowrap;
3328
+ }
3329
+
3330
+ .rec-spec-view-toggle:hover {
3331
+ background: var(--board-accent);
3332
+ }
3333
+
3334
+ .rec-spec-view-toggle:focus-visible {
3335
+ outline: 2px solid var(--board-info);
3336
+ outline-offset: 2px;
3337
+ }
3338
+
3339
+ /* A pressed toggle says "the verbatim source is what you are reading" — marked, and not by colour alone. */
3340
+ .rec-spec-view-toggle[aria-pressed='true'] {
3341
+ border-color: var(--board-info);
3342
+ color: var(--board-info);
3343
+ font-weight: var(--board-fw-semibold);
3344
+ }
3345
+
3346
+ .rec-spec-view-toggle[aria-disabled='true'] {
3347
+ opacity: 0.55;
3348
+ cursor: default;
3349
+ }
3350
+
3351
+ .rec-spec-view-state {
3352
+ flex: 1 1 auto;
3353
+ min-width: 0;
3354
+ font-size: var(--board-text-xs);
3355
+ color: var(--board-muted-fg);
3356
+ overflow-wrap: anywhere;
3357
+ }
3358
+
3359
+ /* ===== Marks the PREVIEW must not hide (shared with the phase-doc viewer) =====
3360
+ The scaffolded Phase 0 template ships unticked boxes and two FAIL gates; a preview that drew them as
3361
+ generic body text would make an unfilled form look like a finished spec. */
3362
+ .rec-doc-sr {
3363
+ position: absolute;
3364
+ width: 1px;
3365
+ height: 1px;
3366
+ margin: -1px;
3367
+ padding: 0;
3368
+ border: 0;
3369
+ overflow: hidden;
3370
+ clip: rect(0 0 0 0);
3371
+ white-space: nowrap;
3372
+ }
3373
+
3374
+ .rec-doc-todo {
3375
+ display: flex;
3376
+ align-items: baseline;
3377
+ gap: 8px;
3378
+ }
3379
+
3380
+ .rec-doc-todo-check {
3381
+ flex: none;
3382
+ font-family: var(--board-font-mono);
3383
+ font-size: 12.5px;
3384
+ line-height: 1.7;
3385
+ letter-spacing: var(--board-tracking-mono);
3386
+ }
3387
+
3388
+ .rec-doc-todo-check-off {
3389
+ color: var(--board-warning);
3390
+ font-weight: var(--board-fw-semibold);
3391
+ }
3392
+
3393
+ .rec-doc-todo-check-on {
3394
+ color: var(--board-success);
3395
+ }
3396
+
3397
+ /* An unticked box is stated, not merely greyed: the warning colour AND the mark together. */
3398
+ .rec-doc-todo-open .rec-doc-li-text {
3399
+ color: var(--board-fg);
3400
+ }
3401
+
3402
+ .rec-doc-todo-done .rec-doc-li-text {
3403
+ color: var(--board-muted-fg);
3404
+ }
3405
+
3406
+ .rec-doc-gate {
3407
+ font-family: var(--board-font-mono);
3408
+ font-size: var(--board-text-xs);
3409
+ font-weight: var(--board-fw-semibold);
3410
+ letter-spacing: var(--board-tracking-mono);
3411
+ margin: 4px 0;
3412
+ padding: 3px 8px;
3413
+ border: 1px solid var(--board-border);
3414
+ border-radius: var(--board-radius-md);
3415
+ display: inline-block;
3416
+ }
3417
+
3418
+ .rec-doc-gate-fail {
3419
+ color: var(--board-error);
3420
+ border-color: var(--board-error);
3421
+ background: color-mix(in srgb, var(--board-error) 8%, var(--board-card));
3422
+ }
3423
+
3424
+ .rec-doc-gate-pass {
3425
+ color: var(--board-success);
3426
+ border-color: var(--board-success);
3427
+ background: color-mix(in srgb, var(--board-success) 8%, var(--board-card));
3428
+ }
3429
+
3430
+ .rec-spec-empty {
3431
+ margin: 0;
3432
+ padding: var(--board-space-12);
3433
+ color: var(--board-muted-fg);
3434
+ font-size: var(--board-text-xs);
3435
+ }
3436
+
2468
3437
  @media (prefers-reduced-motion: reduce) {
2469
3438
  .rec-card, .rec-back, .rec-close, .rec-theme-toggle { transition: none; }
3439
+ .rec-spec { transition: none; }
2470
3440
  }
2471
3441
  `;
2472
3442
  /**
@@ -2605,6 +3575,10 @@ dl.rec-settings-rows {
2605
3575
  useWorkspaces: props?.useWorkspaces
2606
3576
  });
2607
3577
  })));
3578
+ disposers.push(ctx.slots.inject("tool.call.toolview", () => ctx.slots.register({
3579
+ name: "tool.call.toolview",
3580
+ key: RUN_START_TOOL_NAME
3581
+ }, RunStartSpecSheet)));
2608
3582
  disposers.push(ctx.slots.inject("settings.section", () => ctx.slots.register({
2609
3583
  name: "settings.section",
2610
3584
  id: "recursive",