bareloop 0.29.0 → 0.31.0

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.
@@ -37,6 +37,7 @@ import { runBehaviour } from '../behaviour.js';
37
37
  import { SPEND_RECORD_TYPES } from '../ledger.js';
38
38
  import { jobSpecHash } from '../job.js';
39
39
  import { confirmProtections } from '../authorflow.js';
40
+ import { createAuthorRoutes, mintToken } from './authorroutes.js';
40
41
 
41
42
  const HERE = dirname(fileURLToPath(import.meta.url));
42
43
 
@@ -236,20 +237,17 @@ export function formatTimestamp(ts) {
236
237
  * @param {string|null} outcome `replayRun`'s own `summary.outcome`
237
238
  * @returns {{died: boolean, why: string|null, spendFloorUsd: number|null, wallFloorMs: number|null}}
238
239
  */
239
- function deriveDeath(spinePath, records, outcome) {
240
- const notDied = {
241
- died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
242
- };
243
- if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
244
- let mtimeMs;
245
- try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return notDied; }
246
- if (Date.now() - mtimeMs <= DIED_MTIME_MS) return notDied; // still fresh — genuinely `running`, not died
247
-
248
- const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
249
- const last = withTs.length ? withTs[withTs.length - 1] : null;
250
- const when = last ? formatTimestamp(last.ts) : 'an unknown time';
251
- const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
252
-
240
+ /**
241
+ * The priced-rounds spend floor + first-record→last-record wall floor,
242
+ * derived straight off the spine's raw records — the same derivation a
243
+ * died run's "at least …" figures use, factored out so build item 6
244
+ * (2026-09-28) can hand a genuinely-still-running spine the identical
245
+ * floor instead of "unknown" on the Run tab, without a second copy of this
246
+ * math.
247
+ * @param {any[]} records raw parsed spine records
248
+ * @returns {{spendFloorUsd: number|null, wallFloorMs: number|null}}
249
+ */
250
+ function floorsFromRecords(records) {
253
251
  let spendSum = 0;
254
252
  let pricedCount = 0;
255
253
  for (const r of records) {
@@ -258,12 +256,33 @@ function deriveDeath(spinePath, records, outcome) {
258
256
  }
259
257
  const spendFloorUsd = pricedCount > 0 ? spendSum : null;
260
258
 
259
+ const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
261
260
  const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
262
261
  const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
263
262
  const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
263
+ return { spendFloorUsd, wallFloorMs };
264
+ }
265
+
266
+ function deriveDeath(spinePath, records, outcome) {
267
+ const notDied = {
268
+ died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
269
+ };
270
+ if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
271
+ // no job-end yet — the floor derivation is identical whether this turns
272
+ // out to be a died run or one that is genuinely still running (build item
273
+ // 6): computed here, once, before the died/still-running branch below.
274
+ const floors = floorsFromRecords(records);
275
+ let mtimeMs;
276
+ try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
277
+ if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
278
+
279
+ const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
280
+ const last = withTs.length ? withTs[withTs.length - 1] : null;
281
+ const when = last ? formatTimestamp(last.ts) : 'an unknown time';
282
+ const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
264
283
 
265
284
  return {
266
- died: true, why, spendFloorUsd, wallFloorMs,
285
+ died: true, why, ...floors,
267
286
  };
268
287
  }
269
288
 
@@ -322,9 +341,24 @@ function summarizeRow(row) {
322
341
  model: summary.model,
323
342
  // died: never "unknown" when a priced round exists — glyph [?] already
324
343
  // carries the word "died", so the row meta text itself never repeats it.
344
+ // `spend` is the LIBRARY's own 4-decimal string (never changed here —
345
+ // `--all`'s CLI listing reads the same `summarizeForAllLine`, F: "do not
346
+ // change the library's CLI output"). The panel page renders its own
347
+ // 2-decimal text off the numeric fields below instead of this string.
325
348
  spend: death.died ? (death.spendFloorUsd !== null ? `at least $${death.spendFloorUsd.toFixed(4)}` : 'unknown') : line.spend,
326
349
  wall: death.died ? (death.wallFloorMs !== null ? `at least ${formatDurationMs(death.wallFloorMs)}` : 'unknown') : line.wall,
327
350
  date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
351
+ // numeric fields for the panel's own 2-decimal render (hamr's ruling
352
+ // 2026-09-28, "panel money 2-decimals") — `spentUsd`/`spendComplete` are
353
+ // `null`/`false` on a died row (no job-end, see `spendFloorUsd` instead);
354
+ // `draftSpentUsd`/`draftSpendComplete` are `null` when this run carried
355
+ // no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
356
+ spentUsd: death.died ? null : summary.spentUsd,
357
+ spendComplete: death.died ? false : summary.spendComplete,
358
+ spendFloorUsd: death.died ? death.spendFloorUsd : null,
359
+ draftSpentUsd: summary.draftSpentUsd,
360
+ draftSpendComplete: summary.draftSpendComplete,
361
+ budgetUsd: summary.budgetUsd,
328
362
  };
329
363
  }
330
364
 
@@ -375,9 +409,19 @@ export function getRunDetail(runid, opts = {}) {
375
409
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
376
410
  };
377
411
  }
378
- const summary = replayOne(row.spine);
379
- const timelineKind = summary.timelineKind;
412
+ // build item 7 (2026-09-28): resolve the audit path through the ONE
413
+ // shared resolver (`resolveAuditPathForRow`, already the sole owner for
414
+ // `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
415
+ // the result — otherwise `replayOne`'s own internal resolution only ever
416
+ // finds the FINISHED-run convention, so a still-LIVE run's part-level
417
+ // `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
418
+ // "unknown" even though the SAME run's Run-tab tools/cache summary and
419
+ // Audit tab (both driven through `resolveAuditPathForRow` already) can
420
+ // see the during-run sidecar just fine.
380
421
  const rawSpineRecords = parseJsonl(row.spine).records;
422
+ const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
423
+ const summary = replayOne(row.spine, { auditPathOverride: auditPath });
424
+ const timelineKind = summary.timelineKind;
381
425
  const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
382
426
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
383
427
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
@@ -509,12 +553,24 @@ export function getRunDetail(runid, opts = {}) {
509
553
  died: death.died,
510
554
  stopReason: death.died ? death.why : summary.stopReason,
511
555
  spentUsd: summary.spentUsd,
512
- // died: a spend floor summed from real priced rounds present in the
513
- // file — never null/unknown when at least one priced round exists.
514
- // `null` on a non-died run (the normal `spentUsd`/`wallMs` fields above
515
- // are already the real, complete figures there).
516
- spendFloorUsd: death.died ? death.spendFloorUsd : null,
517
- wallFloorMs: death.died ? death.wallFloorMs : null,
556
+ // draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
557
+ // run's cap, or null when this run carried none. `src/replay.js`'s
558
+ // `moneyWithDraft` is the one place that decides how to render it.
559
+ draftSpentUsd: summary.draftSpentUsd,
560
+ // draftSpendComplete (hamr's ruling 2026-09-28, 2nd addendum) — whether
561
+ // the drafting share above was EXACT; `null` when there is no drafting
562
+ // share to qualify at all. Read by the panel's own `panelMoneyWithDraft`.
563
+ draftSpendComplete: summary.draftSpendComplete,
564
+ // a spend/wall floor summed from real priced rounds/timestamped records
565
+ // present in the file — never null/unknown when at least one priced
566
+ // round exists. `deriveDeath` already returns `null` for both whenever
567
+ // a real job-end WAS reached (the normal `spentUsd`/`wallMs` fields
568
+ // above are the real, complete figures there); build item 6
569
+ // (2026-09-28) widened this from "died runs only" to ALSO cover a
570
+ // genuinely still-running spine, so the Run tab can show a running
571
+ // floor instead of "unknown" while a run is live.
572
+ spendFloorUsd: death.spendFloorUsd,
573
+ wallFloorMs: death.wallFloorMs,
518
574
  spendComplete: summary.spendComplete,
519
575
  wallMs: summary.wallMs,
520
576
  timelineKind,
@@ -529,9 +585,15 @@ export function getRunDetail(runid, opts = {}) {
529
585
  // parts (panel build item B/C, 2026-09-26): the ONE ordered part list
530
586
  // (`src/replay.js`'s own field, computed once server-side) — drives the
531
587
  // Run tab's map+cards and the Audit tab's grouped rows. Passed through
532
- // VERBATIM (never re-derived client-side) so the two tabs can never
533
- // disagree about run order, counts, or blocked-call figures.
534
- parts: summary.parts,
588
+ // VERBATIM (never re-derived client-side) EXCEPT each attempt's own
589
+ // declared-close `stages` array, which gets its per-stage `kind`/
590
+ // `direction`/`baselineKind` attached here (build item, 2026-09-28: the
591
+ // gap 764a9ef named — the signed closeDecl is the only place this lives,
592
+ // and it is not part of `replayOne`'s own spine-derived shape) — see
593
+ // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
594
+ // passes through unchanged) whenever no spec resolves at all, so the two
595
+ // tabs still never disagree about order/counts/blocked-call figures.
596
+ parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
535
597
  replans: summary.replans,
536
598
  close: summary.close,
537
599
  branch: summary.branch,
@@ -556,7 +618,7 @@ export function getRunDetail(runid, opts = {}) {
556
618
  // `replayRun` (a single spine record, never audit-sidecar-sourced, so it
557
619
  // carries no contamination risk); `null` when the spine carries no
558
620
  // `memory-cache` record at all (never armed on this run).
559
- behaviour: scopedBehaviour(row.spine, rawSpineRecords),
621
+ behaviour: scopedBehaviour(row, rawSpineRecords),
560
622
  memoryCache: summary.memoryCache,
561
623
  };
562
624
  }
@@ -633,6 +695,55 @@ function makeRoundLookup(spineRecords) {
633
695
  };
634
696
  }
635
697
 
698
+ /**
699
+ * One owner for "where does THIS run's gate-audit sidecar live on disk right
700
+ * now" — used by both {@link scopedBehaviour} (Run tab) and {@link
701
+ * getRunAudit} (Audit tab), so the two can never disagree. Two cases:
702
+ * (a) the FINISHED convention ({@link resolveSiblings}: `<spine-stem>-
703
+ * gate-audit.jsonl` beside the spine) — `run-u`'s end-of-run rename
704
+ * (`src/userrun.js:1727-1729`) and every existing archived-run test
705
+ * already reads this shape;
706
+ * (b) LIVE fallback (panel P2 defect 3, hamr-watched run mujjtrvd,
707
+ * 2026-09-27): while a run has no `job-end` yet, its sidecar is still
708
+ * sitting at its DURING-RUN path, `<row.patient>/gate-audit.jsonl` —
709
+ * `run-u` writes it at `wd` (`src/userrun.js:1727`'s `auditSrc`) and
710
+ * `bareloop run`/`via:'bundle'` writes it at the worktree
711
+ * (`src/cli.js:448`'s `auditSrc`); `row.patient` already carries
712
+ * exactly that path for both (`src/userrun.js:1355`, `src/cli.js:418`),
713
+ * so this one fallback covers both without knowing which via wrote it.
714
+ * Only tried when (a) found nothing AND this run's own spine carries no
715
+ * `job-end` yet, so a finished run never pays the extra stat and never
716
+ * risks reading a stale leftover file ((a)'s rename already moved it
717
+ * away by the time job-end lands). `row.patient` is `null` on a
718
+ * `backfill` row (never a live run) and on some pre-cutoff `run-u` rows
719
+ * — both correctly fall through to `null` here.
720
+ * Callers still scope the rows they read from this file by this run's own
721
+ * job-start..job-end ts window ({@link runAuditWindow}) — a shared tree/
722
+ * worktree gate-audit file can carry another run's rows too (this file's own
723
+ * header comment on `runAuditWindow`), live or finished.
724
+ *
725
+ * NOT handled here (a separate, pre-existing gap, out of this fix's scope):
726
+ * a FINISHED `via:'bundle'` run's sidecar is renamed to a bare
727
+ * `gate-audit.jsonl` inside its own `runs/<runid>/` dir (`src/cli.js:449`),
728
+ * but its spine is named `spine.jsonl`, so {@link resolveSiblings} looks for
729
+ * `spine-gate-audit.jsonl` and never finds it — a finished bundle run reads
730
+ * `no-sidecar`/`toolLogSaved:false` even though its tool log really was
731
+ * saved. Reported, not fixed here (a naming mismatch in `src/cli.js`'s own
732
+ * rename target, not a panel-server read-path bug).
733
+ * @param {{spine: string, patient: string|null}} row
734
+ * @param {any[]} spineRecords
735
+ * @returns {string|null}
736
+ */
737
+ function resolveAuditPathForRow(row, spineRecords) {
738
+ const { auditPath } = resolveSiblings(row.spine);
739
+ if (auditPath) return auditPath;
740
+ const hasJobEnd = spineRecords.some((r) => r && r.type === 'job-end');
741
+ if (hasJobEnd) return null;
742
+ if (typeof row.patient !== 'string' || row.patient.length === 0) return null;
743
+ const live = join(row.patient, 'gate-audit.jsonl');
744
+ return existsSync(live) ? live : null;
745
+ }
746
+
636
747
  /**
637
748
  * `runBehaviour`, fed only the gate-audit rows inside this run's own ts
638
749
  * window (see {@link runAuditWindow}) — the panel-side fix for the same
@@ -641,12 +752,14 @@ function makeRoundLookup(spineRecords) {
641
752
  * can never disagree with each other about which rows belong to this run.
642
753
  * `null` when no gate-audit sidecar exists at all (never a fake all-zero
643
754
  * object — same rule `replayRun`'s own `auditAvailable` already follows).
644
- * @param {string} spinePath
755
+ * `auditPath` resolution (finished sibling, or the live during-run fallback)
756
+ * is {@link resolveAuditPathForRow} — the one shared owner.
757
+ * @param {{spine: string, patient: string|null}} row
645
758
  * @param {any[]} spineRecords
646
759
  * @returns {ReturnType<typeof runBehaviour>|null}
647
760
  */
648
- function scopedBehaviour(spinePath, spineRecords) {
649
- const { auditPath } = resolveSiblings(spinePath);
761
+ function scopedBehaviour(row, spineRecords) {
762
+ const auditPath = resolveAuditPathForRow(row, spineRecords);
650
763
  if (!auditPath || !existsSync(auditPath)) return null;
651
764
  const { records } = parseJsonl(auditPath);
652
765
  const { startTs, endTs } = runAuditWindow(spineRecords);
@@ -803,13 +916,18 @@ export function getRunAudit(runid, opts = {}) {
803
916
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
804
917
  };
805
918
  }
806
- const { auditPath } = resolveSiblings(row.spine);
919
+ const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
920
+ // {@link resolveAuditPathForRow} needs `spineRecords` (to know whether this
921
+ // run has reached `job-end` yet) before it can decide whether the LIVE
922
+ // during-run fallback is even worth trying — so the spine is parsed once,
923
+ // here, before the sidecar path is resolved (moved up from right after the
924
+ // old `resolveSiblings`-only check this replaces).
925
+ const auditPath = resolveAuditPathForRow(row, spineRecords);
807
926
  if (!auditPath || !existsSync(auditPath)) {
808
927
  return {
809
928
  runid, rows: [], raw: '', empty: true, reason: 'no-sidecar',
810
929
  };
811
930
  }
812
- const { records: spineRecords, skipped: spineSkipped } = parseJsonl(row.spine);
813
931
  const { startTs, endTs } = runAuditWindow(spineRecords);
814
932
  const roundOf = makeRoundLookup(spineRecords);
815
933
  const roundRecords = sortedRoundRecords(spineRecords);
@@ -1065,6 +1183,278 @@ function sourceNearSpine(spinePath) {
1065
1183
  };
1066
1184
  }
1067
1185
 
1186
+ /**
1187
+ * The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
1188
+ * `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
1189
+ * (the three real-spec routes {@link getRunJob}'s `fromSpec` also tries, in
1190
+ * the same order; never route (d), the run's own job-start record, which
1191
+ * carries no `closeDecl` for {@link stageKindMetaFromSpec} to read a
1192
+ * stage's kind from). A specHash mismatch against `jobs/<job>.json` is NOT
1193
+ * checked here — the Job tab's own mismatch note is a display concern of
1194
+ * that endpoint, not a reason to withhold a stage's kind (the declaration a
1195
+ * hash mismatch flags is still the one the close actually ran under until
1196
+ * the run is repeated). `null` when no route resolves or every candidate
1197
+ * fails to parse as an object — never a guessed spec.
1198
+ * @param {{ spine: string, job: string }} row
1199
+ * @returns {any|null}
1200
+ */
1201
+ function resolveSpecForRow(row) {
1202
+ if (!existsSync(row.spine)) return null;
1203
+ const bundleDir = bundleDirForSpine(row.spine);
1204
+ const bundleSpecPath = bundleDir ? join(bundleDir, 'spec.json') : null;
1205
+ if (bundleSpecPath && existsSync(bundleSpecPath)) {
1206
+ try {
1207
+ const spec = JSON.parse(readFileSync(bundleSpecPath, 'utf8'));
1208
+ if (spec && typeof spec === 'object') return spec;
1209
+ } catch { /* falls through to the next route */ }
1210
+ }
1211
+ const jobsSpecPath = join(jobsDir(), `${row.job}.json`);
1212
+ if (existsSync(jobsSpecPath)) {
1213
+ try {
1214
+ const spec = JSON.parse(readFileSync(jobsSpecPath, 'utf8'));
1215
+ if (spec && typeof spec === 'object') return spec;
1216
+ } catch { /* falls through to the next route */ }
1217
+ }
1218
+ const near = sourceNearSpine(row.spine);
1219
+ if (near.specPath) {
1220
+ try {
1221
+ const spec = JSON.parse(readFileSync(near.specPath, 'utf8'));
1222
+ if (spec && typeof spec === 'object') return spec;
1223
+ } catch { /* no spec resolvable */ }
1224
+ }
1225
+ return null;
1226
+ }
1227
+
1228
+ /**
1229
+ * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
1230
+ * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
1231
+ * when 5 never ran"). ONE pure function mapping a declared stage's own
1232
+ * signed `kind`+`params` (plus the close's own `genre`, also a signed field)
1233
+ * to a short plain-English question a person can read without knowing the
1234
+ * catalogue — never a second spelling on the client, and never anything the
1235
+ * model authored (the catalogue's kinds/params are the whole vocabulary this
1236
+ * reads).
1237
+ *
1238
+ * Every branch is traced straight off `src/kinds.js`'s runner semantics and
1239
+ * the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
1240
+ * `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
1241
+ * - `files-changed` is always the `changed-from-seed` guard: "did it
1242
+ * change any file?"
1243
+ * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
1244
+ * casts or silencers added?"
1245
+ * - `command-exit` is the genre's `suite-green` stage (the only shipped use
1246
+ * of this kind): "does the test suite pass?"
1247
+ * - `count-not-worse` splits three ways on its own declared
1248
+ * `direction`/`baseline` (never the runtime number — see
1249
+ * {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
1250
+ * travel separately from the measured figure):
1251
+ * - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
1252
+ * floor: "did all the old tests still exist?"
1253
+ * - `lower-is-better` + `baseline: 0` is the genre's in-scope
1254
+ * `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
1255
+ * command a `command-exit` stage in the same close already runs
1256
+ * (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
1257
+ * suite exits clean AND reports zero failing tests — TWO assertions"
1258
+ * over ONE population (the same `npm test`/`pytest` invocation), so a
1259
+ * `count-not-worse` stage sharing that exact command is the failing-
1260
+ * test-count half of that pair, never a typecheck stage: "no failing
1261
+ * tests?" — derived from the same cmd+args correlation (the stage
1262
+ * counts the suite's own failing tests), never from the stage's own
1263
+ * name string. The live check that found this (run mul5fofw's own
1264
+ * `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
1265
+ * `suite-green` command-exit stage) would otherwise have read "test/
1266
+ * has 0 type errors?", which is false: its parser counts FAILING
1267
+ * TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
1268
+ * name this half honestly rather than reusing "type errors" for a
1269
+ * population it was never about, or falling back to the stage's raw
1270
+ * name. When the command differs from every sibling `command-exit`
1271
+ * stage, the declared `scope.includePrefixes` naming exactly ONE path
1272
+ * is quoted verbatim (a signed param, never invented): "<path> has 0
1273
+ * type errors?" — otherwise the generic "type errors is 0?", never a
1274
+ * guessed filename.
1275
+ * - `lower-is-better` + `baseline: 'seed'` with a non-empty
1276
+ * `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
1277
+ * "other files didn't get more type errors?"
1278
+ * The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
1279
+ * the only genre this catalogue admits today (doc-genre kinds are not yet
1280
+ * built) — an unrecognised genre, or a `count-not-worse` shape none of the
1281
+ * three above matches, returns `null` rather than invent a fourth shape.
1282
+ *
1283
+ * `null` means "no honest plain question" — the caller (buildAttemptsList,
1284
+ * partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
1285
+ * exactly as it already does for a stage with no resolvable kind at all.
1286
+ * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
1287
+ * @returns {string|null}
1288
+ */
1289
+ const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
1290
+
1291
+ /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
1292
+ * itself) that two stages measure the SAME command's output, used to tell a
1293
+ * genuine typecheck stage apart from the TYPES genre's other `lower-is-
1294
+ * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
1295
+ * "two assertions"), which shares that stage's own shape but not its
1296
+ * population. `null` for a stage whose `cmd` isn't a non-empty string —
1297
+ * never matches anything, so it never falsely suppresses a real typecheck
1298
+ * question.
1299
+ * @param {any} params @returns {string|null} */
1300
+ function cmdKey(params) {
1301
+ if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
1302
+ const args = Array.isArray(params.args) ? params.args : [];
1303
+ return JSON.stringify([params.cmd, args]);
1304
+ }
1305
+
1306
+ function stageQuestionText({
1307
+ kind, params, genre, suiteCmdKeys,
1308
+ }) {
1309
+ if (kind === 'files-changed') return 'did it change any file?';
1310
+ if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
1311
+ if (kind === 'command-exit') return 'does the test suite pass?';
1312
+ if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
1313
+ const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
1314
+ if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
1315
+ return 'did all the old tests still exist?';
1316
+ }
1317
+ if (!what) return null;
1318
+ const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
1319
+ if (params.direction === 'lower-is-better' && params.baseline === 0) {
1320
+ const key = cmdKey(params);
1321
+ if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
1322
+ const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
1323
+ if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
1324
+ return `${include[0]} has 0 ${what}?`;
1325
+ }
1326
+ return `${what} is 0?`;
1327
+ }
1328
+ if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
1329
+ const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
1330
+ if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
1331
+ return null;
1332
+ }
1333
+ return null;
1334
+ }
1335
+
1336
+ /**
1337
+ * Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
1338
+ * off a resolved spec's own signed `closeDecl.stages` — the facts the client
1339
+ * needs to pick §3a's numeric wording (down-to-a-goal / up-out-of-a-total /
1340
+ * not-worse-than-a-baseline / pass-fail-only) AND the plain-checks build's
1341
+ * own question text ({@link stageQuestionText}), never guessed from the
1342
+ * runtime number or the stage's model-authored name alone: a `lower-is-better`
1343
+ * stage whose SEED happened to measure 0 is numerically indistinguishable
1344
+ * from a `baseline: 0` declared goal, so the declared baseline KIND (`'seed'`
1345
+ * vs the literal `0`) has to travel separately from the measured number.
1346
+ * Iteration order is `spec.closeDecl.stages`' own DECLARED order (a `Map`
1347
+ * preserves insertion order) — the plain-checks build's client reads this
1348
+ * same order for a stage's `#N` position and for which declared stages never
1349
+ * ran. `null` when the spec has no `closeDecl.stages` array at all (a
1350
+ * command-close spec, or no spec resolved).
1351
+ * @param {any} spec
1352
+ * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
1353
+ */
1354
+ function stageKindMetaFromSpec(spec) {
1355
+ if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
1356
+ const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
1357
+ // every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
1358
+ // so stageQuestionText can tell the failing-test-count half of suite-green's
1359
+ // "two assertions" apart from a genuine typecheck stage — see its own doc.
1360
+ /** @type {Set<string>} */
1361
+ const suiteCmdKeys = new Set();
1362
+ for (const s of spec.closeDecl.stages) {
1363
+ if (s && s.kind === 'command-exit') {
1364
+ const key = cmdKey(s.params);
1365
+ if (key !== null) suiteCmdKeys.add(key);
1366
+ }
1367
+ }
1368
+ /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
1369
+ const map = new Map();
1370
+ for (const s of spec.closeDecl.stages) {
1371
+ if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
1372
+ const kind = typeof s.kind === 'string' ? s.kind : null;
1373
+ const params = s.params && typeof s.params === 'object' ? s.params : null;
1374
+ const direction = params && typeof params.direction === 'string' ? params.direction : null;
1375
+ const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
1376
+ const question = stageQuestionText({
1377
+ kind, params, genre, suiteCmdKeys,
1378
+ });
1379
+ map.set(s.name, {
1380
+ kind, direction, baselineKind, question,
1381
+ });
1382
+ }
1383
+ return map;
1384
+ }
1385
+
1386
+ /**
1387
+ * Attaches one stage's `{kind, direction, baselineKind, question}` (from
1388
+ * {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
1389
+ * shape (`{name, verdict, value?, baseline?, …}`, straight off the spine) —
1390
+ * a shallow copy, never a mutation of the spine-derived object, and only when
1391
+ * BOTH a name and a matching declaration entry exist; otherwise the stage
1392
+ * passes through unchanged (the client's existing name fallback still
1393
+ * renders for it).
1394
+ * @param {any} stage
1395
+ * @param {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null} kindMeta
1396
+ * @returns {any}
1397
+ */
1398
+ function attachStageKind(stage, kindMeta) {
1399
+ if (!stage || typeof stage !== 'object' || typeof stage.name !== 'string' || !kindMeta) return stage;
1400
+ const meta = kindMeta.get(stage.name);
1401
+ if (!meta) return stage;
1402
+ return {
1403
+ ...stage, kind: meta.kind, direction: meta.direction, baselineKind: meta.baselineKind, question: meta.question,
1404
+ };
1405
+ }
1406
+
1407
+ /**
1408
+ * `summary.parts` (verbatim except each attempt's `stages` array, which gets
1409
+ * {@link attachStageKind} applied per stage) — a shallow re-map, never a
1410
+ * mutation of `replayOne`'s own returned objects (other endpoints, e.g.
1411
+ * {@link getRunAudit}, call `replayOne` fresh per request, but this stays
1412
+ * defensive rather than relying on that).
1413
+ *
1414
+ * Also stamps each attempt with `declaredStagesTotal` (checks-count fix,
1415
+ * 2026-09-28): `kindMeta.size` — the number of stages the signed
1416
+ * `closeDecl` DECLARED, never `stages.length` (the number that RAN). First-
1417
+ * red-wins means an attempt that stopped early carries a `stages` array
1418
+ * shorter than the declaration; the client reads this field for the total
1419
+ * count so an unrun stage still counts in the total exactly once, computed
1420
+ * here and never re-derived client-side — "checks 1/2" on a 7-stage close
1421
+ * (hamr's live catch) was this exact bug.
1422
+ *
1423
+ * `declaredStages` (plain-checks build, 2026-09-28): every declared stage's
1424
+ * `{name, question}`, in DECLARED order — `Array.from(kindMeta)` walks a
1425
+ * `Map`'s own insertion order, which is `spec.closeDecl.stages`' order. The
1426
+ * client zips this against the attempt's own (shorter, when first-red-wins
1427
+ * stopped it early) `stages` array BY NAME to render every declared stage in
1428
+ * the expanded view, including ones that never ran ("· not run") — never by
1429
+ * POSITION, because a stage's declared index is exactly what `#N` in the
1430
+ * headline/expanded view already means, and a name lookup is robust to any
1431
+ * future reordering between the resolved spec and the executed close.
1432
+ *
1433
+ * Both fields are `null`/omitted whenever `kindMeta` is `null` — no spec
1434
+ * resolved, or the spec carries no `closeDecl.stages` at all — so the client
1435
+ * falls back to `stages.length` and renders only the stages that ran, exactly
1436
+ * as before this build.
1437
+ * @param {any[]|null|undefined} parts
1438
+ * @param {Map<string, any>|null} kindMeta
1439
+ * @returns {any[]|null|undefined}
1440
+ */
1441
+ function enrichPartsWithStageKind(parts, kindMeta) {
1442
+ if (!Array.isArray(parts)) return parts;
1443
+ const declaredStagesTotal = kindMeta ? kindMeta.size : null;
1444
+ const declaredStages = kindMeta
1445
+ ? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
1446
+ : null;
1447
+ return parts.map((part) => ({
1448
+ ...part,
1449
+ attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
1450
+ ...a,
1451
+ stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
1452
+ ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
1453
+ ...(declaredStages ? { declaredStages } : {}),
1454
+ })) : part.attempts,
1455
+ }));
1456
+ }
1457
+
1068
1458
  /**
1069
1459
  * The close's stage names, in declared order — `close[].name` (a hand-authored
1070
1460
  * command close) or `closeDecl.stages[].name` (an authored declaration); the
@@ -1350,16 +1740,21 @@ function sendText(res, code, text) {
1350
1740
  * separately from {@link createPanelServer} so tests can drive it without a
1351
1741
  * real listening socket where that is simpler (most path-safety/405 tests
1352
1742
  * still go through a real socket, per the build spec).
1743
+ *
1744
+ * PANEL-BUILD.md P3: this file's own GET/HEAD-only rule stands for every
1745
+ * route it owns; `/api/author/*` is the ONE family of routes that may be
1746
+ * POSTed to, and it is handled entirely by {@link createAuthorRoutes}
1747
+ * (`src/panel/authorroutes.js`) — a separate module so this file's own
1748
+ * "read-only, by construction" header comment stays true of everything else
1749
+ * in it. `opts.authorRoutes` is absent on every P1/P2 caller (read-only
1750
+ * tests, and the CLI's own `panelMain` when no author routes are wired) —
1751
+ * only `createPanelServer` below always supplies one.
1353
1752
  * @param {import('node:http').IncomingMessage} req
1354
1753
  * @param {import('node:http').ServerResponse} res
1355
- * @param {{ home?: string, port: number }} opts
1754
+ * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes> }} opts
1356
1755
  */
1357
1756
  export function handleRequest(req, res, opts) {
1358
1757
  const method = req.method ?? 'GET';
1359
- if (method !== 'GET' && method !== 'HEAD') {
1360
- sendText(res, 405, 'method not allowed — this panel is read-only (GET/HEAD only)');
1361
- return;
1362
- }
1363
1758
 
1364
1759
  let url;
1365
1760
  try {
@@ -1370,6 +1765,26 @@ export function handleRequest(req, res, opts) {
1370
1765
  }
1371
1766
  const { pathname } = url;
1372
1767
 
1768
+ if (method !== 'GET' && method !== 'HEAD') {
1769
+ if (opts.authorRoutes && pathname.startsWith('/api/author')) {
1770
+ let raw = '';
1771
+ req.on('data', (c) => { raw += c; });
1772
+ req.on('end', () => {
1773
+ /** @type {any} */
1774
+ let body = null;
1775
+ if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
1776
+ opts.authorRoutes?.handle(req, res, pathname, body);
1777
+ });
1778
+ return;
1779
+ }
1780
+ sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author (GET/HEAD only)');
1781
+ return;
1782
+ }
1783
+ if (opts.authorRoutes && pathname.startsWith('/api/author')) {
1784
+ opts.authorRoutes.handle(req, res, pathname, null);
1785
+ return;
1786
+ }
1787
+
1373
1788
  const send = (code, body) => {
1374
1789
  if (method === 'HEAD') {
1375
1790
  const text = JSON.stringify(body);
@@ -1390,6 +1805,11 @@ export function handleRequest(req, res, opts) {
1390
1805
  return;
1391
1806
  }
1392
1807
  html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
1808
+ // PANEL-BUILD.md P3's human-click guard: a per-server-start token,
1809
+ // templated into the page exactly like the port — absent on every P1/P2
1810
+ // caller (no `opts.token`), which is fine: the page's own JS only reads
1811
+ // it to send on a POST, and there are none to send without P3's routes.
1812
+ html = html.replace(/__BARELOOP_PANEL_TOKEN__/g, String(opts.token ?? ''));
1393
1813
  if (method === 'HEAD') {
1394
1814
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
1395
1815
  res.end();
@@ -1453,12 +1873,25 @@ export function handleRequest(req, res, opts) {
1453
1873
  * header). Resolves once actually listening; rejects on a bind error
1454
1874
  * (including `EADDRINUSE`) with a `.port` field on the error for the
1455
1875
  * caller's message.
1456
- * @param {{ port?: number, home?: string }} [opts]
1457
- * @returns {Promise<{ server: import('node:http').Server, port: number, close: () => Promise<void> }>}
1876
+ *
1877
+ * PANEL-BUILD.md P3: a fresh {@link mintToken} token and one {@link
1878
+ * createAuthorRoutes} instance are built here, ONCE per server start, and
1879
+ * held for the server's lifetime — `opts.env`/`opts.sessionsRoot`/
1880
+ * `opts.spawnFn`/`opts.bareloopBin` are test seams the author routes take
1881
+ * (a real caller passes none of them and {@link createAuthorRoutes} defaults
1882
+ * each one to the real environment/spawn/binary, this file itself touching
1883
+ * none of them, and the real
1884
+ * `~/.config/bareloop/panel-sessions`, the real `child_process.spawn`, and
1885
+ * this package's own `bin/bareloop.mjs`).
1886
+ * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
1887
+ * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
1888
+ * fetchImpl?: typeof fetch }} [opts]
1889
+ * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
1458
1890
  */
1459
1891
  export function createPanelServer(opts = {}) {
1460
1892
  const requestedPort = opts.port ?? DEFAULT_PORT;
1461
1893
  const home = opts.home;
1894
+ const token = mintToken();
1462
1895
  return new Promise((resolve, reject) => {
1463
1896
  // Bound port is resolved from the live socket (`server.address().port`)
1464
1897
  // once listening starts, not the requested value — this is what makes
@@ -1466,9 +1899,13 @@ export function createPanelServer(opts = {}) {
1466
1899
  // test suite, while `--port N` / DEFAULT_PORT callers still get back
1467
1900
  // exactly the port they asked for.
1468
1901
  let boundPort = requestedPort;
1902
+ /** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
1903
+ let authorRoutes;
1469
1904
  const server = createServer((req, res) => {
1470
1905
  try {
1471
- handleRequest(req, res, { home, port: boundPort });
1906
+ handleRequest(req, res, {
1907
+ home, port: boundPort, token, authorRoutes,
1908
+ });
1472
1909
  } catch (e) {
1473
1910
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
1474
1911
  }
@@ -1481,9 +1918,19 @@ export function createPanelServer(opts = {}) {
1481
1918
  server.listen(requestedPort, '127.0.0.1', () => {
1482
1919
  const addr = server.address();
1483
1920
  boundPort = typeof addr === 'object' && addr !== null ? addr.port : requestedPort;
1921
+ authorRoutes = createAuthorRoutes({
1922
+ port: boundPort,
1923
+ token,
1924
+ env: opts.env,
1925
+ sessionsRoot: opts.sessionsRoot,
1926
+ spawnFn: opts.spawnFn,
1927
+ bareloopBin: opts.bareloopBin,
1928
+ fetchImpl: opts.fetchImpl,
1929
+ });
1484
1930
  resolve({
1485
1931
  server,
1486
1932
  port: boundPort,
1933
+ token,
1487
1934
  close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
1488
1935
  });
1489
1936
  });