@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.
@@ -14,7 +14,7 @@ const BOARD_CSS = `
14
14
  /* ===== dsh-recursive Paper theme (run 16) ===== */
15
15
 
16
16
  /* Raw tokens: light (default) + dark + shared, scoped to the board/inspector. */
17
- .rec-board, .rec-inspector {
17
+ .rec-board, .rec-inspector, .rec-spec {
18
18
  /* light */
19
19
  --rm3-light-background: #FFFFFF;
20
20
  --rm3-light-foreground: #111111;
@@ -673,6 +673,9 @@ const BOARD_CSS = `
673
673
  line-height: 1.7;
674
674
  padding: 0 6px;
675
675
  border-left: 2px solid transparent;
676
+ /* Containing block for the visually-hidden text that carries a task box's tick to a screen reader, so it
677
+ cannot be positioned against the page instead of against its own line. */
678
+ position: relative;
676
679
  }
677
680
 
678
681
  .rec-doc-line-current {
@@ -1097,8 +1100,302 @@ dl.rec-settings-rows {
1097
1100
  overflow-wrap: anywhere;
1098
1101
  }
1099
1102
 
1103
+ /* ===== Run-start spec sheet (tool.call.toolview key 'recursive_ask') =====
1104
+ The document beside the run-start question. LIGHT is the only theme here: the sheet renders inline in a
1105
+ transcript row, not on the board, so it does not own a data-theme attribute and must not inherit the
1106
+ board's. The body is the ONLY scrolling box (a max-height cap with overflow-y: auto) so the decision
1107
+ controls below it can never be pushed out of reach by a long document. */
1108
+ .rec-spec {
1109
+ box-sizing: border-box;
1110
+ margin: 6px 0;
1111
+ padding: var(--board-space-12);
1112
+ border: 1px solid var(--board-border);
1113
+ border-radius: var(--board-radius-lg);
1114
+ background: var(--board-card);
1115
+ color: var(--board-fg);
1116
+ font-family: var(--board-font-sans);
1117
+ font-size: var(--board-text-sm);
1118
+ /* The one transition this sheet has. It is disabled outright under prefers-reduced-motion below. */
1119
+ transition: border-color 160ms ease;
1120
+ }
1121
+
1122
+ .rec-spec-header {
1123
+ display: flex;
1124
+ align-items: baseline;
1125
+ justify-content: space-between;
1126
+ gap: var(--board-space-8);
1127
+ }
1128
+
1129
+ .rec-spec-title {
1130
+ margin: 0;
1131
+ font-size: var(--board-text-sm);
1132
+ font-weight: var(--board-fw-semibold);
1133
+ letter-spacing: var(--board-tracking-tight);
1134
+ overflow-wrap: anywhere;
1135
+ }
1136
+
1137
+ .rec-spec-tag {
1138
+ flex: none;
1139
+ font-size: var(--board-text-xs);
1140
+ color: var(--board-muted-fg);
1141
+ }
1142
+
1143
+ .rec-spec-path {
1144
+ margin: 2px 0 var(--board-space-8);
1145
+ font-family: var(--board-font-mono);
1146
+ font-size: var(--board-text-xs);
1147
+ color: var(--board-muted-fg);
1148
+ overflow-wrap: anywhere;
1149
+ }
1150
+
1151
+ .rec-spec-notice {
1152
+ margin: 0 0 var(--board-space-8);
1153
+ padding: var(--board-space-8);
1154
+ border: 1px solid var(--board-border);
1155
+ border-radius: var(--board-radius-md);
1156
+ background: var(--board-muted);
1157
+ font-size: var(--board-text-xs);
1158
+ line-height: 1.5;
1159
+ }
1160
+
1161
+ /* An UNFILLED template is stated as a warning, never as a neutral fact: the whole point of this seat is that
1162
+ nobody is nudged toward approving a hollow document. */
1163
+ .rec-spec-notice-unfilled {
1164
+ border-color: var(--board-warning);
1165
+ background: color-mix(in srgb, var(--board-warning) 10%, var(--board-card));
1166
+ }
1167
+
1168
+ .rec-spec-unfilled-lead {
1169
+ margin: 0 0 6px;
1170
+ font-weight: var(--board-fw-semibold);
1171
+ color: var(--board-warning);
1172
+ }
1173
+
1174
+ .rec-spec-unfilled-why {
1175
+ margin: 0 0 4px;
1176
+ color: var(--board-muted-fg);
1177
+ }
1178
+
1179
+ .rec-spec-unfilled-next {
1180
+ margin: 6px 0 0;
1181
+ font-weight: var(--board-fw-medium);
1182
+ }
1183
+
1184
+ .rec-spec-evidence {
1185
+ margin: 0 0 4px;
1186
+ padding-left: 18px;
1187
+ font-family: var(--board-font-mono);
1188
+ font-size: var(--board-text-xs);
1189
+ }
1190
+
1191
+ .rec-spec-evidence-item {
1192
+ overflow-wrap: anywhere;
1193
+ }
1194
+
1195
+ .rec-spec-notice-filled {
1196
+ border-color: var(--board-success);
1197
+ background: color-mix(in srgb, var(--board-success) 8%, var(--board-card));
1198
+ }
1199
+
1200
+ .rec-spec-notice-error {
1201
+ border-color: var(--board-error);
1202
+ }
1203
+
1204
+ .rec-spec-question {
1205
+ margin: 0 0 var(--board-space-8);
1206
+ }
1207
+
1208
+ .rec-spec-question-lead,
1209
+ .rec-spec-question-options,
1210
+ .rec-spec-question-where {
1211
+ margin: 0 0 4px;
1212
+ font-size: var(--board-text-xs);
1213
+ line-height: 1.5;
1214
+ }
1215
+
1216
+ .rec-spec-question-where {
1217
+ color: var(--board-muted-fg);
1218
+ }
1219
+
1220
+ .rec-spec-decision {
1221
+ margin: 0;
1222
+ font-size: var(--board-text-xs);
1223
+ font-weight: var(--board-fw-medium);
1224
+ }
1225
+
1226
+ /* The document body: the one bounded scroller. Focusable (tabIndex=0) for the keyboard, and the border
1227
+ marks the focus ring's home rather than relying on a colour change alone. */
1228
+ .rec-spec-body-scroll {
1229
+ max-height: 420px;
1230
+ overflow-y: auto;
1231
+ overscroll-behavior: contain;
1232
+ border: 1px solid var(--board-border);
1233
+ border-radius: var(--board-radius-md);
1234
+ background: var(--board-muted);
1235
+ }
1236
+
1237
+ .rec-spec-body-scroll:focus-visible {
1238
+ outline: 2px solid var(--board-info);
1239
+ outline-offset: 2px;
1240
+ }
1241
+
1242
+ /* The rendered preview inside that scroller (PreviewLines from doc-viewer.tsx). The line blocks carry their
1243
+ own padding so the marks — task boxes, gate readings — line up in a column down the left edge. */
1244
+ .rec-spec-body-scroll .rec-doc-lines {
1245
+ padding: var(--board-space-12);
1246
+ }
1247
+
1248
+ .rec-spec-body-scroll .rec-doc-line {
1249
+ padding: 0;
1250
+ }
1251
+
1252
+ /* Raw source is monospace, wrapping, and NOT reflowed: the same characters the preview was built from. */
1253
+ .rec-spec-text {
1254
+ margin: 0;
1255
+ padding: var(--board-space-12);
1256
+ font-family: var(--board-font-mono);
1257
+ font-size: var(--board-text-xs);
1258
+ line-height: 1.55;
1259
+ /* The document is shown VERBATIM: only wrapping is normalised, never the characters. */
1260
+ white-space: pre-wrap;
1261
+ overflow-wrap: anywhere;
1262
+ tab-size: 2;
1263
+ }
1264
+
1265
+ /* ===== The mode control (preview <-> raw source) =====
1266
+ One real button, so Tab reaches it and Enter/Space press it; aria-pressed carries its state to a screen
1267
+ reader and the role=status line beside it names the mode on screen in words. */
1268
+ .rec-spec-view {
1269
+ display: flex;
1270
+ align-items: center;
1271
+ flex-wrap: wrap;
1272
+ gap: var(--board-space-8);
1273
+ margin: 0 0 var(--board-space-8);
1274
+ }
1275
+
1276
+ .rec-spec-view-toggle {
1277
+ flex: none;
1278
+ padding: 4px 12px;
1279
+ font-family: inherit;
1280
+ font-size: var(--board-text-xs);
1281
+ color: var(--board-fg);
1282
+ background: var(--board-card);
1283
+ border: 1px solid var(--board-border);
1284
+ border-radius: 999px;
1285
+ cursor: pointer;
1286
+ white-space: nowrap;
1287
+ }
1288
+
1289
+ .rec-spec-view-toggle:hover {
1290
+ background: var(--board-accent);
1291
+ }
1292
+
1293
+ .rec-spec-view-toggle:focus-visible {
1294
+ outline: 2px solid var(--board-info);
1295
+ outline-offset: 2px;
1296
+ }
1297
+
1298
+ /* A pressed toggle says "the verbatim source is what you are reading" — marked, and not by colour alone. */
1299
+ .rec-spec-view-toggle[aria-pressed='true'] {
1300
+ border-color: var(--board-info);
1301
+ color: var(--board-info);
1302
+ font-weight: var(--board-fw-semibold);
1303
+ }
1304
+
1305
+ .rec-spec-view-toggle[aria-disabled='true'] {
1306
+ opacity: 0.55;
1307
+ cursor: default;
1308
+ }
1309
+
1310
+ .rec-spec-view-state {
1311
+ flex: 1 1 auto;
1312
+ min-width: 0;
1313
+ font-size: var(--board-text-xs);
1314
+ color: var(--board-muted-fg);
1315
+ overflow-wrap: anywhere;
1316
+ }
1317
+
1318
+ /* ===== Marks the PREVIEW must not hide (shared with the phase-doc viewer) =====
1319
+ The scaffolded Phase 0 template ships unticked boxes and two FAIL gates; a preview that drew them as
1320
+ generic body text would make an unfilled form look like a finished spec. */
1321
+ .rec-doc-sr {
1322
+ position: absolute;
1323
+ width: 1px;
1324
+ height: 1px;
1325
+ margin: -1px;
1326
+ padding: 0;
1327
+ border: 0;
1328
+ overflow: hidden;
1329
+ clip: rect(0 0 0 0);
1330
+ white-space: nowrap;
1331
+ }
1332
+
1333
+ .rec-doc-todo {
1334
+ display: flex;
1335
+ align-items: baseline;
1336
+ gap: 8px;
1337
+ }
1338
+
1339
+ .rec-doc-todo-check {
1340
+ flex: none;
1341
+ font-family: var(--board-font-mono);
1342
+ font-size: 12.5px;
1343
+ line-height: 1.7;
1344
+ letter-spacing: var(--board-tracking-mono);
1345
+ }
1346
+
1347
+ .rec-doc-todo-check-off {
1348
+ color: var(--board-warning);
1349
+ font-weight: var(--board-fw-semibold);
1350
+ }
1351
+
1352
+ .rec-doc-todo-check-on {
1353
+ color: var(--board-success);
1354
+ }
1355
+
1356
+ /* An unticked box is stated, not merely greyed: the warning colour AND the mark together. */
1357
+ .rec-doc-todo-open .rec-doc-li-text {
1358
+ color: var(--board-fg);
1359
+ }
1360
+
1361
+ .rec-doc-todo-done .rec-doc-li-text {
1362
+ color: var(--board-muted-fg);
1363
+ }
1364
+
1365
+ .rec-doc-gate {
1366
+ font-family: var(--board-font-mono);
1367
+ font-size: var(--board-text-xs);
1368
+ font-weight: var(--board-fw-semibold);
1369
+ letter-spacing: var(--board-tracking-mono);
1370
+ margin: 4px 0;
1371
+ padding: 3px 8px;
1372
+ border: 1px solid var(--board-border);
1373
+ border-radius: var(--board-radius-md);
1374
+ display: inline-block;
1375
+ }
1376
+
1377
+ .rec-doc-gate-fail {
1378
+ color: var(--board-error);
1379
+ border-color: var(--board-error);
1380
+ background: color-mix(in srgb, var(--board-error) 8%, var(--board-card));
1381
+ }
1382
+
1383
+ .rec-doc-gate-pass {
1384
+ color: var(--board-success);
1385
+ border-color: var(--board-success);
1386
+ background: color-mix(in srgb, var(--board-success) 8%, var(--board-card));
1387
+ }
1388
+
1389
+ .rec-spec-empty {
1390
+ margin: 0;
1391
+ padding: var(--board-space-12);
1392
+ color: var(--board-muted-fg);
1393
+ font-size: var(--board-text-xs);
1394
+ }
1395
+
1100
1396
  @media (prefers-reduced-motion: reduce) {
1101
1397
  .rec-card, .rec-back, .rec-close, .rec-theme-toggle { transition: none; }
1398
+ .rec-spec { transition: none; }
1102
1399
  }
1103
1400
  `
1104
1401
 
@@ -23,15 +23,34 @@ function isEmpty(scope: LiveScope): boolean {
23
23
  return !scope.sessionId && !scope.cwd
24
24
  }
25
25
 
26
+ /** Options for {@link useLiveProjection}. */
27
+ export interface LiveProjectionOptions {
28
+ /**
29
+ * Skip the route entirely (default true).
30
+ *
31
+ * ⚠ THIS EXISTS FOR THE PER-CALL SEATS, NOT THE PANELS. A `tool.call.toolview` entry mounts once per Tool
32
+ * call in the transcript, so a seat that always subscribed would open one `/state` fetch and one SSE
33
+ * stream per call row. A row that has nothing to show passes `enabled: false` and asks nothing. The hook
34
+ * itself stays UNCONDITIONAL at every call site — a conditional hook is the run 13 crash, and the flag is
35
+ * how a caller keeps the call order fixed while making the request conditional.
36
+ */
37
+ enabled?: boolean
38
+ }
39
+
26
40
  /**
27
41
  * Subscribe to the live route for one scope. Returns the current state. The SSE
28
42
  * feed pushes full frames on every fs change; a dropped stream (sleep/error)
29
43
  * triggers one refetch so the board never serves a stale fold.
44
+ *
45
+ * @param scope - sessionId PRIMARY + cwd fallback hint (host resolves the root).
46
+ * @param options - `enabled: false` clears the snapshot and never fetches.
30
47
  */
31
- export function useLiveProjection(scope: LiveScope): LiveProjectionSnapshot {
48
+ export function useLiveProjection(scope: LiveScope, options: LiveProjectionOptions = {}): LiveProjectionSnapshot {
49
+ const { enabled = true } = options
32
50
  const [state, setState] = useState<LiveRecursiveState | null>(null)
33
51
 
34
52
  useEffect(() => {
53
+ if (!enabled) { setState(null); return }
35
54
  // Empty scope = no resolvable session row yet — render nothing, never fetch.
36
55
  if (isEmpty(scope)) { setState(null); return }
37
56
  // 0.1.18 (LIVE BUG 5): do NOT null the snapshot on scope change. 0.1.16
@@ -51,7 +70,9 @@ export function useLiveProjection(scope: LiveScope): LiveProjectionSnapshot {
51
70
  })
52
71
 
53
72
  return () => { disposed = true; disposeEvents() }
54
- }, [scope.sessionId, scope.cwd])
73
+ // `enabled` belongs in the deps: it is read inside the effect, so leaving it out would keep a stale
74
+ // closure's verdict and mount the very stream the flag exists to avoid.
75
+ }, [scope.sessionId, scope.cwd, enabled])
55
76
 
56
77
  return state
57
78
  }
package/src/errors.ts CHANGED
@@ -65,12 +65,30 @@ export const TOOL_ERRORS = {
65
65
  problem: 'this gate has no default artifact, so one must be named',
66
66
  next: 'pass artifact: <file> so the answer has somewhere durable to land',
67
67
  },
68
+ RELAY_ONLY_FOR_RUN_START: {
69
+ code: 'RM1150',
70
+ klass: 'input',
71
+ problem: 'relay applies only to the run-start gate, which is the only gate that asks the user-questions channel',
72
+ next: 'drop relay for this gate and answer it with one of its own labels, or call recursive_ask with gate: run-start when the decision is whether to start the run',
73
+ },
68
74
  MISSING_RUN_ID: {
69
75
  code: 'RM1101',
70
76
  klass: 'input',
71
77
  problem: 'runId is required',
72
78
  next: 'call recursive_status with no runId to see the latest run id in this workspace',
73
79
  },
80
+ /**
81
+ * A run id is the NAME of the run directory and is joined onto the run layer
82
+ * as one path segment, so a path-shaped id is refused before any directory is
83
+ * created. See `run-id.ts` for the rule and for why it is not the runtime that
84
+ * learns to accept a path.
85
+ */
86
+ BAD_RUN_ID: {
87
+ code: 'RM1107',
88
+ klass: 'input',
89
+ problem: 'runId is not a single directory name',
90
+ next: 'pass the run directory name such as 03-something, then call recursive_init again',
91
+ },
74
92
  MISSING_ARTIFACT: {
75
93
  code: 'RM1102',
76
94
  klass: 'input',
@@ -144,6 +162,19 @@ export const TOOL_ERRORS = {
144
162
  problem: 'the run has unresolved delegated work, so this phase cannot lock yet',
145
163
  next: 'call recursive_status to see the pending delegation, have the child write its reply.md, then lock again',
146
164
  },
165
+ /**
166
+ * THE ORDERING DEFECT, AS A CODE. `recursive_ask gate=run-start` used to raise "start this run or hold?"
167
+ * over a Phase 0 document that was still the scaffold `recursive_init` wrote — placeholder requirements,
168
+ * unchecked lists, `FAIL` gates — and nothing put that document in front of the person either. The owner:
169
+ * *"i was never shown the spec before that so how could i approve if i havent seen it"*. Approving an
170
+ * unfilled template is not a decision about a spec, so the gate refuses to be raised until there is one.
171
+ */
172
+ RUN_START_SPEC_UNFILLED: {
173
+ code: 'RM4404',
174
+ klass: 'state',
175
+ problem: 'the Phase 0 requirements document is still the unfilled template, so there is no run spec for a person to approve',
176
+ 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',
177
+ },
147
178
 
148
179
  /* 5xxx — the runtime refused an operation it understands. */
149
180
 
@@ -153,11 +184,26 @@ export const TOOL_ERRORS = {
153
184
  problem: 'the run-start gate needs an answer, and this composition mounts no user-questions channel to ask one directly',
154
185
  next: 'call recursive_ask with gate: run-start and no answer to surface the question, then retry with answer: ' + '"Start run"',
155
186
  },
187
+ /**
188
+ * ⚠ RM5503 USED TO LIE. Its text asserted "so no person was asked" for EVERY cause, because the caller
189
+ * that produced it had already thrown the cause away — and a live session showed the cost: the gate
190
+ * failed 22.9 s into the call with a reason nobody could see, and its `Next:` clause prescribed the very
191
+ * call that had just failed, so no route to start a run remained. The problem statement now claims only
192
+ * what the gate knows (no decision came back), and the cause travels in the `detail` the caller
193
+ * supplies. `RUN_START_ANSWER_UNUSABLE` carries the one case this entry must NOT cover: a person was
194
+ * reached and their answer was not an approval.
195
+ */
156
196
  RUN_START_UNANSWERED: {
157
197
  code: 'RM5503',
158
198
  klass: 'runtime',
159
- problem: 'the user-questions channel mounted in this composition refused the run-start question, so no person was asked',
160
- next: 'use recursive_ask without an answer to surface the question, and retry it with answer: ' + '"Start run" once the user has approved the run start',
199
+ problem: 'the run-start question reached no decision: the mounted user-questions channel failed before a person answered it',
200
+ next: 'read the cause named in the detail, fix it and call recursive_ask again, or - when this composition cannot deliver the question at all - call recursive_ask with answer: ' + '"Start run"' + ' and relay=true to record the person\'s explicit approval as a relayed one',
201
+ },
202
+ RUN_START_ANSWER_UNUSABLE: {
203
+ code: 'RM5504',
204
+ klass: 'runtime',
205
+ problem: 'a person was asked to start this run and their answer was not one of the labels the run-start gate offered',
206
+ next: 'call recursive_ask again and have the person choose exactly "Start run" or "Hold"; a skipped or custom answer is not an approval, and no relayed answer can replace a decision the person made',
161
207
  },
162
208
  RUNTIME_REFUSED: {
163
209
  code: 'RM5501',