@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.
@@ -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
@@ -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
+ }
package/src/run-start.ts CHANGED
@@ -42,6 +42,8 @@
42
42
  import { readFileSync } from 'node:fs'
43
43
  import { join } from 'node:path'
44
44
  import { getMdFieldValue } from './status.ts'
45
+ import { classifyArtifact, describeEvidence, unfilledEvidence } from './run-spec.ts'
46
+ import { toolError } from './errors.ts'
45
47
 
46
48
  /** The gate id `recursive_ask` answers for a run start. Deliberately NOT in ASK_GATE_IDS. */
47
49
  export const RUN_START_GATE_ID = 'run-start'
@@ -127,3 +129,60 @@ export function readRunStartApproval(root: string, runId: string): { approved: b
127
129
  * the same string instead of re-typing it (a re-typed reason is a caller that silently stops matching).
128
130
  */
129
131
  export const RUN_START_NOT_APPROVED = 'run not started: phase 0 approval has not been granted'
132
+
133
+ /* ============================ THE ORDERING GUARD ============================ */
134
+
135
+ /**
136
+ * PHASE 0 — THE GATE CANNOT BE RAISED BEFORE THERE IS A SPEC TO DECIDE ABOUT.
137
+ *
138
+ * THE DEFECT. `recursive_init` scaffolds Phase 0 as a TEMPLATE, and `recursive_ask gate=run-start` raised
139
+ * "start this run or hold?" over it immediately — while every requirement was still `<short title>`, every
140
+ * acceptance criterion was still `[observable condition 1]`, and nothing put the document in front of the
141
+ * person at all. The owner: *"the card ui for accepting the spec appeared, but i was never shown the spec
142
+ * before that so how could i approve if i havent seen it"*. Approving an unfilled template is not a decision
143
+ * about a spec; there is no spec yet, and a card that asks the question anyway teaches a person to answer
144
+ * without reading.
145
+ *
146
+ * ⚠ WHAT THIS DOES *NOT* TOUCH. It does not weaken the gate's own contract, it does not add a second way to
147
+ * start a run, and it does not make the plugin the decider: it only refuses to ASK. A person's own answer
148
+ * still wins (`recordRunStartAnswer` is unchanged), a spec still creates no goal, and cancellation / abort /
149
+ * timeout are still unrelayable. The check runs BEFORE the question is put to anybody, so no card is shown
150
+ * for a document that cannot be approved meaningfully.
151
+ *
152
+ * ⚠ AND IT IS A CHECK ON THE DOCUMENT, NOT ON THE CALLER. A `runId` that does not resolve is not this
153
+ * refusal's business — the ask path already reports that — so the guard says `ok: true` there and lets the
154
+ * existing route handle it.
155
+ */
156
+ export function runStartSpecGuard(root: string, runId: string): { ok: true } | { ok: false; reason: string } {
157
+ const content = readRunStartArtifact(root, runId)
158
+ if (content === null) {
159
+ // ⚠ A MISSING ARTIFACT IS REFUSED TOO, AND IT IS THE SAME DEFECT. Approving a run that has no Phase 0
160
+ // document is approving something nobody can read — the gate's own question ("approve phase 0 and start
161
+ // this run?") has no referent. The refusal names the path a reader can go and look at.
162
+ return {
163
+ ok: false,
164
+ reason: toolError(
165
+ 'RUN_START_SPEC_UNFILLED',
166
+ 'there is no Phase 0 document to approve: ' + runStartArtifactPath(root, runId) + ' does not exist yet',
167
+ ),
168
+ }
169
+ }
170
+ const verdict = classifyArtifact(content)
171
+ if (verdict.verdict === 'filled') return { ok: true }
172
+ const evidence = unfilledEvidence(verdict)
173
+ const context = verdict.hits.filter((hit) => hit.id !== 'placeholder')
174
+ const contextNote = context.length === 0
175
+ ? ''
176
+ : ' (it also carries ' + String(context.length) + ' unfinished marker(s) of its own, starting at line '
177
+ + String(context[0]?.line ?? 0) + ')'
178
+ // The evidence — line numbers and the placeholder text VERBATIM — travels in the detail: a refusal that
179
+ // asserts "it is a template" without quoting it is a refusal the caller can only take on trust.
180
+ return {
181
+ ok: false,
182
+ reason: toolError(
183
+ 'RUN_START_SPEC_UNFILLED',
184
+ RUN_START_ARTIFACT + ' for run ' + JSON.stringify(runId) + ' still carries the template scaffold'
185
+ + contextNote + ': ' + describeEvidence(evidence),
186
+ ),
187
+ }
188
+ }