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

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/delegation.ts CHANGED
@@ -559,9 +559,18 @@ export async function delegateContinuable(input: {
559
559
  // turn-shaped caller resumes on a later turn instead of treating the round
560
560
  // as lost, and so `accepted` stays false — an unobserved round is never an
561
561
  // approval.
562
+ //
563
+ // ⚠ THE SENTENCE NAMES THE CHILD, and that is not decoration. The advice this
564
+ // reason carries ("resume with the SAME child") is UNACTIONABLE without the id,
565
+ // so a caller that reads it and does not also read `childId` can only report the
566
+ // park, not act on it — and the record built from this reason is exactly where a
567
+ // live run read "no settlement" as "the child is dead" and went around its own
568
+ // reviewer. `resumeChild` is the field that consumes this id.
562
569
  return {
563
570
  ok: false,
564
- reason: 'no settlement has landed for round ' + (round + 1) + ' yet (the child is still working)',
571
+ reason: 'no settlement has landed for round ' + (round + 1) + ' yet, so the round is PARKED, not failed: '
572
+ + 'the child may still be working. Resume it on a later turn with childId ' + String(childId)
573
+ + ' (the `resumeChild` argument) — do not start a second child.',
565
574
  childId,
566
575
  messageIds,
567
576
  rounds,
@@ -774,8 +783,43 @@ export interface ActionRecordInput {
774
783
  * nothing else: a delegation that FAILED and a delegation that NEVER HAPPENED read identically, which is what
775
784
  * let me conclude for three rounds that the host was not scheduling children. The caller ALREADY passed
776
785
  * `stopReason`, and the live record said `n/a` — because there was no result to take a stop reason from.
786
+ *
787
+ * ⚠ AND IT IS EMITTED ONLY FOR A GENUINE FAILURE. A PARKED round is neither accepted nor failed, so it
788
+ * carries {@link ActionRecordInput.parked} instead: calling a live child a failure is the defect this
789
+ * field's own history is made of.
777
790
  */
778
791
  failure?: string
792
+ /**
793
+ * ⚠ THE THIRD STATE, AND WHY `success` COULD NOT CARRY IT.
794
+ *
795
+ * A continuable round that has not settled is NOT a failure — `ContinuableDelegationLike.parked` says so in
796
+ * its own doc comment, and the tool result already surfaces it. The RECORD did not: a live run's child was
797
+ * parked, the record said `Status: failed` with "the child never reported, or never ran", the main agent
798
+ * read that as a dead child and obtained the review elsewhere — while the child was still working and
799
+ * replied eighteen minutes later. `success: false` cannot express "still in flight", because a genuinely
800
+ * dead child also produces `success: false`, so a second field is required rather than a cleverer boolean.
801
+ *
802
+ * When true, the record says `parked` and carries a `Parked:` line (never a `Failure:` line) whose text
803
+ * states only what is KNOWN, names the `childId`, and names the resume step. It takes precedence over
804
+ * `success`: an unobserved round is never an acceptance, whatever a caller passes alongside it.
805
+ */
806
+ parked?: boolean
807
+ }
808
+
809
+ /**
810
+ * The status a record states, in ONE place, because the three states are the fix and a second writer would
811
+ * drift from this one.
812
+ *
813
+ * The wording is chosen for two readers at once. The MODEL reads it to decide whether to resume or to give up
814
+ * and obtain the result another way — the exact decision the live defect got wrong — so the token must not
815
+ * read as a failure and must not need the rest of the document to be understood. A HUMAN reading the run tree
816
+ * months later needs to tell "died" from "still working" at a glance. Hence `parked (still running; no
817
+ * settlement yet)`: a third token rather than a renamed second, qualified with the two facts that separate it
818
+ * from `failed`, and short enough to sit in a status line.
819
+ */
820
+ export function actionRecordStatus(input: Pick<ActionRecordInput, 'success' | 'parked'>): string {
821
+ if (input.parked === true) return 'parked (still running; no settlement yet)'
822
+ return input.success ? 'accepted' : 'failed'
779
823
  }
780
824
 
781
825
  function slugify(value: string): string {
@@ -794,7 +838,10 @@ function slugify(value: string): string {
794
838
  * `Code Refs` strictly inside ## Inputs Provided. The linter resolves each of
795
839
  * those through the heading body, so a field under another heading is not
796
840
  * found at all.
797
- * A success:false attempt is written with a failed status and is NOT accepted.
841
+ * A success:false attempt is written with a failed status and is NOT accepted. A round that PARKED is written
842
+ * with a status of its own (`parked (still running; no settlement yet)`, see {@link actionRecordStatus}) and a
843
+ * `Parked:` line instead of a `Failure:` one, because no settlement is not a death: it is the caller's signal to
844
+ * resume the same child. Only a genuine failure carries `Failure:`.
798
845
  */
799
846
  export function writeActionRecord(input: ActionRecordInput): string {
800
847
  const { root, runId } = input
@@ -844,11 +891,18 @@ export function writeActionRecord(input: ActionRecordInput): string {
844
891
  '- Phase: ' + input.phase,
845
892
  '- Purpose: ' + input.purpose,
846
893
  '- Execution Mode: ' + input.executionMode,
847
- '- Status: ' + (input.success ? 'accepted' : 'failed'),
894
+ '- Status: ' + actionRecordStatus(input),
848
895
  // ⚠ EMITTED ONLY WHEN A REASON IS GIVEN, so a caller that says nothing produces the record it always did.
849
896
  // Not politeness: this record's shape is asserted by specs, and a first attempt that always emitted the line
850
897
  // failed 13 tests across 5 files. A change to a shared surface should be additive where it can be.
851
- ...(input.success === false && input.failure !== undefined ? ['- Failure: ' + input.failure] : []),
898
+ //
899
+ // ⚠ AND `parked` IS THE ONE STATE THAT MUST NOT WEAR THE `Failure:` LABEL. This line used to be
900
+ // `input.success === false && input.failure !== undefined`, which is true of a PARKED round as well — so a
901
+ // live child that was still working was recorded, on the same line, as a failure. A parked round is not a
902
+ // failure, so it gets its own field and states only what is established.
903
+ ...(input.success === false && input.failure !== undefined
904
+ ? [input.parked === true ? '- Parked: ' + input.failure : '- Failure: ' + input.failure]
905
+ : []),
852
906
  '- Stop Reason: ' + (input.stopReason ?? 'n/a'),
853
907
  // Timestamp LAST in Metadata. This USED to be load-bearing: `getHeadingBody` ended
854
908
  // its capture with a `\Z` that JavaScript reads as a literal `Z`, so a body was
package/src/errors.ts CHANGED
@@ -162,6 +162,19 @@ export const TOOL_ERRORS = {
162
162
  problem: 'the run has unresolved delegated work, so this phase cannot lock yet',
163
163
  next: 'call recursive_status to see the pending delegation, have the child write its reply.md, then lock again',
164
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
+ },
165
178
 
166
179
  /* 5xxx — the runtime refused an operation it understands. */
167
180
 
@@ -27,6 +27,7 @@ import {
27
27
  RUN_START_ARTIFACT,
28
28
  RUN_START_GATE,
29
29
  RUN_START_GATE_ID,
30
+ runStartSpecGuard,
30
31
  } from './run-start.ts'
31
32
 
32
33
 
@@ -294,7 +295,7 @@ export function pendingGateFor(artifactFile: string, artifactText: string | null
294
295
  export function createRecursiveAskTool(recursive: RecursiveRuntime) {
295
296
  return defineTool({
296
297
  name: 'recursive_ask',
297
- 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.',
298
+ 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.',
298
299
  parameters: {
299
300
  gate: { type: 'string', description: 'tdd-mode | qa-signoff | gate-block | run-start. Required. `run-start` is phase 0: approving it records the approval and arms the run goal, which is what makes the harness drive rounds.' },
300
301
  runId: { type: 'string', description: 'Run id. Required; must resolve inside the current workspace.' },
@@ -326,6 +327,32 @@ export function createRecursiveAskTool(recursive: RecursiveRuntime) {
326
327
  // phase-0 record, and letting a caller aim the approval somewhere else is how an approval ends up
327
328
  // in a file no reader looks at. Every other gate keeps its per-gate default and its override.
328
329
  const artifact = (isRunStartGate(gateId) ? RUN_START_ARTIFACT : args.artifact ?? GATE_DEFAULT_ARTIFACT[gateId as AskGateId]).trim()
330
+
331
+ // ⚠ THE ORDERING GUARD, AND IT COMES BEFORE THE QUESTION IS PUT TO ANYBODY. A scaffolded run's Phase 0
332
+ // document is a template: placeholder requirements, unchecked lists, FAIL gates. Raising "start this run
333
+ // or hold?" over that asks a person to approve a document that is not a spec — and until this change
334
+ // nothing even SHOWED it to them. So the gate refuses while the artifact is still the template, naming
335
+ // the artifact and quoting the placeholder lines, and records nothing.
336
+ //
337
+ // It runs before BOTH branches below on purpose: asking the channel first and refusing afterwards would
338
+ // put the card in front of the person anyway, which is the defect. A refusal here does not weaken any
339
+ // part of the gate's contract — the person's own answer still wins everywhere below, a spec still
340
+ // creates no goal, and the relay rules are untouched.
341
+ if (isRunStartGate(gateId)) {
342
+ const guard = runStartSpecGuard(root, runId)
343
+ if (!guard.ok) {
344
+ return {
345
+ error: guard.reason,
346
+ gate: RUN_START_GATE_ID,
347
+ runId,
348
+ artifact: RUN_START_ARTIFACT,
349
+ // The question travels with the refusal so the caller can put the DECISION in the transcript while
350
+ // it cannot yet raise the card — the same shape the channel refusals use.
351
+ question: buildAskQuestionFor(RUN_START_GATE_ID),
352
+ } as unknown as JsonValue
353
+ }
354
+ }
355
+
329
356
  let question: AskQuestion
330
357
  try {
331
358
  question = buildAskQuestionFor(gateId)
@@ -0,0 +1,148 @@
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
+
30
+ /** What the checker decided about one artifact's text. */
31
+ export type ArtifactVerdict = 'unfilled' | 'filled'
32
+
33
+ /** One piece of evidence found in the document, quoted with its line number. */
34
+ export interface ArtifactMarkerHit {
35
+ /** Stable id of the marker that matched. */
36
+ id: string
37
+ /** 1-based line number in the document as it was read. */
38
+ line: number
39
+ /** The line verbatim, trimmed of surrounding whitespace. */
40
+ text: string
41
+ }
42
+
43
+ /** The verdict plus the evidence for it. */
44
+ export interface ArtifactVerdictResult {
45
+ verdict: ArtifactVerdict
46
+ /** Marker hits, in line order. Empty exactly when the verdict is `filled`. */
47
+ hits: ArtifactMarkerHit[]
48
+ }
49
+
50
+ /** The named evidence classes, so a reader can tell a placeholder from an unmet gate. */
51
+ export const ARTIFACT_MARKER_IDS = {
52
+ placeholder: 'placeholder',
53
+ uncheckedTodo: 'unchecked-todo',
54
+ failedGate: 'failed-gate',
55
+ } as const
56
+
57
+ export type ArtifactMarkerId = (typeof ARTIFACT_MARKER_IDS)[keyof typeof ARTIFACT_MARKER_IDS]
58
+
59
+ /**
60
+ * `...` on a line of its own, or a bracketed/angle placeholder.
61
+ *
62
+ * The bracketed form is deliberately `<[^<>\n]+>` rather than `<.+>`: a greedy pattern would swallow a
63
+ * legitimate line that happens to contain two unrelated angle brackets, and the refusal it produced would
64
+ * name a line the person could not see a placeholder in.
65
+ */
66
+ const PLACEHOLDER_RE = /(^\s*\.\.\.\s*$)|(\[[^[\]\n<>]{2,120}\])|(<[^<>\n]{2,120}>)/
67
+
68
+ /** An unchecked task box: the template ships five of them and a filled spec has none in the TODO block. */
69
+ const UNCHECKED_TODO_RE = /^\s*[-*]\s*\[ \]/
70
+
71
+ /** The two FAIL gates the template ships (`Coverage: FAIL` / `Approval: FAIL`). */
72
+ const FAILED_GATE_RE = /^\s*(Coverage|Approval):\s*FAIL\b/i
73
+
74
+ /** Every marker, in the order they are reported for a single line. */
75
+ const MARKER_PATTERNS: readonly { id: ArtifactMarkerId; re: RegExp }[] = [
76
+ { id: ARTIFACT_MARKER_IDS.placeholder, re: PLACEHOLDER_RE },
77
+ { id: ARTIFACT_MARKER_IDS.uncheckedTodo, re: UNCHECKED_TODO_RE },
78
+ { id: ARTIFACT_MARKER_IDS.failedGate, re: FAILED_GATE_RE },
79
+ ]
80
+
81
+ /**
82
+ * Which marker a single line carries, or null.
83
+ *
84
+ * Exported because the client prints the marker NAMES beside the quoted lines, and a second classifier that
85
+ * re-derived them would be a second answer to the same question.
86
+ */
87
+ export function markerIdsOnLine(line: string): ArtifactMarkerId[] {
88
+ const ids: ArtifactMarkerId[] = []
89
+ for (const pattern of MARKER_PATTERNS) {
90
+ if (pattern.re.test(line)) ids.push(pattern.id)
91
+ }
92
+ return ids
93
+ }
94
+
95
+ /**
96
+ * Does this line still carry a marker that PROVES the document is an unfilled template?
97
+ *
98
+ * ⚠ THE EVIDENCE IS NOT SYMMETRIC, and that asymmetry is the whole design. An angle-bracket placeholder or
99
+ * `[observable condition 1]` cannot appear in a document somebody actually wrote, so it REFUTES the spec's
100
+ * readiness. An unchecked box or a `FAIL` gate is weaker: a person may legitimately hold `Coverage: FAIL`
101
+ * open as an objection while still having written real requirements. So the strong markers decide, the weak
102
+ * ones are reported as context, and a real document is never refused on their account.
103
+ */
104
+ function refutes(line: string): boolean {
105
+ return markerIdsOnLine(line).includes(ARTIFACT_MARKER_IDS.placeholder)
106
+ }
107
+
108
+ /**
109
+ * Classify one artifact's text.
110
+ *
111
+ * A verdict of `unfilled` means the document still carries the template's own placeholder text — the
112
+ * evidence travels with it, line by line, so the refusal (and the client notice) can name what is missing
113
+ * instead of asserting a state the reader cannot check.
114
+ */
115
+ export function classifyArtifact(text: string): ArtifactVerdictResult {
116
+ const lines = text.split(/\r?\n/)
117
+ const hits: ArtifactMarkerHit[] = []
118
+ for (let i = 0; i < lines.length; i += 1) {
119
+ const line = lines[i] as string
120
+ for (const id of markerIdsOnLine(line)) {
121
+ hits.push({ id, line: i + 1, text: line.trim() })
122
+ }
123
+ }
124
+ return { verdict: hits.some((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder) ? 'unfilled' : 'filled', hits }
125
+ }
126
+
127
+ /** The unfilled evidence only (what a refusal names). */
128
+ export function unfilledEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[] {
129
+ return result.hits.filter((hit) => hit.id === ARTIFACT_MARKER_IDS.placeholder)
130
+ }
131
+
132
+ /** The weak, contextual markers (unchecked boxes, FAIL gates). */
133
+ export function contextEvidence(result: ArtifactVerdictResult): ArtifactMarkerHit[] {
134
+ return result.hits.filter((hit) => hit.id !== ARTIFACT_MARKER_IDS.placeholder)
135
+ }
136
+
137
+ /**
138
+ * One line naming what is missing, for a refusal sentence.
139
+ *
140
+ * The line number and the text are both quoted: "line 12: <short title>" is a thing a reader can go and
141
+ * look at, while "the requirements are not filled in" is an assertion they would have to take on trust.
142
+ */
143
+ export function describeEvidence(hits: readonly ArtifactMarkerHit[], limit = 3): string {
144
+ const shown = hits.slice(0, limit).map((hit) => 'line ' + String(hit.line) + ': ' + hit.text)
145
+ const rest = hits.length - shown.length
146
+ const suffix = rest > 0 ? ' (and ' + String(rest) + ' more)' : ''
147
+ return shown.join(' | ') + suffix
148
+ }