bareloop 0.30.0 → 0.32.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +154 -0
  2. package/bareloop.context.md +205 -10
  3. package/bin/bareloop.mjs +0 -1
  4. package/package.json +1 -1
  5. package/src/authorflow.js +22 -42
  6. package/src/authorjob.js +5 -8
  7. package/src/authorrun.js +42 -16
  8. package/src/cli.js +12 -6
  9. package/src/config.js +135 -0
  10. package/src/interviewrun.js +10 -2
  11. package/src/keysfile.js +156 -0
  12. package/src/ledger.js +26 -0
  13. package/src/monthly.js +352 -0
  14. package/src/panel/authorroutes.js +328 -0
  15. package/src/panel/authorsession.js +555 -0
  16. package/src/panel/index.html +1247 -50
  17. package/src/panel/server.js +449 -51
  18. package/src/panel/settingsroutes.js +230 -0
  19. package/src/planrun.js +61 -5
  20. package/src/promptregisters.js +3 -0
  21. package/src/providerrows.js +217 -0
  22. package/src/providers.js +84 -0
  23. package/src/replay.js +80 -2
  24. package/src/replayio.js +14 -3
  25. package/src/run.js +48 -2
  26. package/src/runlist.js +13 -0
  27. package/src/userrun.js +134 -13
  28. package/types/authorflow.d.ts +4 -21
  29. package/types/authorrun.d.ts +5 -0
  30. package/types/cli.d.ts +2 -1
  31. package/types/config.d.ts +48 -0
  32. package/types/interviewrun.d.ts +5 -0
  33. package/types/keysfile.d.ts +90 -0
  34. package/types/ledger.d.ts +14 -0
  35. package/types/monthly.d.ts +192 -0
  36. package/types/panel/authorroutes.d.ts +67 -0
  37. package/types/panel/authorsession.d.ts +53 -0
  38. package/types/panel/server.d.ts +42 -5
  39. package/types/panel/settingsroutes.d.ts +14 -0
  40. package/types/providerrows.d.ts +164 -0
  41. package/types/providers.d.ts +34 -0
  42. package/types/replay.d.ts +31 -0
  43. package/types/replayio.d.ts +12 -1
  44. package/types/run.d.ts +26 -1
  45. package/types/runlist.d.ts +1 -0
  46. package/types/userrun.d.ts +22 -0
@@ -5,8 +5,10 @@
5
5
  // `src/cli.js` calls — `src/replayio.js`'s read side and `src/runlist.js`'s
6
6
  // run list — never a re-implementation of either.
7
7
  //
8
- // READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405); no
9
- // endpoint runs a job, spends money, signs, or reads a key/.env. Nothing
8
+ // READ-ONLY, BY CONSTRUCTION: GET/HEAD only (anything else -> 405) EXCEPT the two
9
+ // human-click-guarded route families in their own modules (`/api/author/*`,
10
+ // `/api/settings/*` — P3/P4a); no endpoint here runs a job, spends money, signs, or
11
+ // returns a key/.env value. Nothing
10
12
  // here imports `src/providers.js` or touches `process.env` for a secret —
11
13
  // grepped before writing this file, and the same discipline is kept here.
12
14
  //
@@ -28,18 +30,23 @@ import {
28
30
  dirname, join, basename, relative, isAbsolute, sep,
29
31
  } from 'node:path';
30
32
  import { fileURLToPath } from 'node:url';
31
- import { readRunList } from '../runlist.js';
33
+ import { readRunList, DIED_MTIME_MS } from '../runlist.js';
32
34
  import {
33
35
  replayOne, parseJsonl, resolveSiblings, isSidecarByName,
34
36
  } from '../replayio.js';
35
37
  import { summarizeForAllLine, auditWindow } from '../replay.js';
36
38
  import { runBehaviour } from '../behaviour.js';
37
- import { SPEND_RECORD_TYPES } from '../ledger.js';
39
+ import { SPEND_RECORD_TYPES, floorsFromRecords } from '../ledger.js';
38
40
  import { jobSpecHash } from '../job.js';
39
41
  import { confirmProtections } from '../authorflow.js';
42
+ import { createAuthorRoutes, mintToken } from './authorroutes.js';
43
+ import { createSettingsRoutes } from './settingsroutes.js';
40
44
 
41
45
  const HERE = dirname(fileURLToPath(import.meta.url));
42
46
 
47
+ /** Largest POST body the panel buffers (job cards are short prose); a bigger one is refused with 413. */
48
+ const MAX_BODY_BYTES = 1024 * 1024;
49
+
43
50
  /**
44
51
  * A runid, as it appears in a URL path segment — never a raw filesystem
45
52
  * path. `~` is included so a backfill-disambiguated runid (F197:
@@ -130,17 +137,10 @@ export function checkTypeTitle(verdictType, atIso) {
130
137
  return null;
131
138
  }
132
139
 
133
- // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` never shares
134
- // the failed glyph — [✗] stays reserved for a run whose close/arbiter
135
- // actually rendered a "no" (a real result). A spine that just stops, with
136
- // no ending ever recorded, is a DIFFERENT fact (killed, crashed, or the
137
- // machine slept) and gets its own [?] glyph. The distinguishing signal is
138
- // the spine FILE's own mtime, never wall-clock "now minus job-start" (a
139
- // resumed/paused run can legitimately sit quiet for a long time without
140
- // having died): still fresh (written to within this window) reads as the
141
- // existing `running` [▶] state; older than this reads as died. Tighten-only,
142
- // named so a future change is a deliberate, visible edit.
143
- export const DIED_MTIME_MS = 10 * 60 * 1000;
140
+ // DIED (hamr's ruling B, 2026-09-25): a run with no `job-end` is `died` [?], never [✗], once its
141
+ // spine file's mtime is older than DIED_MTIME_MS — the constant and its full comment live in
142
+ // src/runlist.js (the monthly limit reads the same rule); re-exported here for the panel's callers.
143
+ export { DIED_MTIME_MS };
144
144
 
145
145
  /**
146
146
  * Plain-words description of ONE spine record, for the died "why" sentence's
@@ -241,29 +241,21 @@ function deriveDeath(spinePath, records, outcome) {
241
241
  died: false, why: null, spendFloorUsd: null, wallFloorMs: null,
242
242
  };
243
243
  if (outcome !== null && outcome !== undefined) return notDied; // a real job-end was reached
244
+ // no job-end yet — the floor derivation is identical whether this turns
245
+ // out to be a died run or one that is genuinely still running (build item
246
+ // 6): computed here, once, before the died/still-running branch below.
247
+ const floors = floorsFromRecords(records);
244
248
  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
249
+ try { mtimeMs = statSync(spinePath).mtimeMs; } catch { return { ...notDied, ...floors }; }
250
+ if (Date.now() - mtimeMs <= DIED_MTIME_MS) return { ...notDied, ...floors }; // still fresh — genuinely `running`, not died
247
251
 
248
252
  const withTs = records.filter((r) => r && typeof r === 'object' && typeof r.ts === 'string');
249
253
  const last = withTs.length ? withTs[withTs.length - 1] : null;
250
254
  const when = last ? formatTimestamp(last.ts) : 'an unknown time';
251
255
  const why = `died — no ending was recorded (killed, crashed, or the machine slept). Last thing it did: ${describeLastRecord(last)} at ${when}.`;
252
256
 
253
- let spendSum = 0;
254
- let pricedCount = 0;
255
- for (const r of records) {
256
- if (!r || typeof r !== 'object' || !SPEND_RECORD_TYPES.includes(r.type)) continue; // worker-result echoes excluded by construction — not a spend type
257
- if (typeof r.costUsd === 'number' && Number.isFinite(r.costUsd)) { spendSum += r.costUsd; pricedCount += 1; }
258
- }
259
- const spendFloorUsd = pricedCount > 0 ? spendSum : null;
260
-
261
- const firstMs = withTs.length ? Date.parse(withTs[0].ts) : NaN;
262
- const lastMs = withTs.length ? Date.parse(withTs[withTs.length - 1].ts) : NaN;
263
- const wallFloorMs = Number.isFinite(firstMs) && Number.isFinite(lastMs) && lastMs >= firstMs ? lastMs - firstMs : null;
264
-
265
257
  return {
266
- died: true, why, spendFloorUsd, wallFloorMs,
258
+ died: true, why, ...floors,
267
259
  };
268
260
  }
269
261
 
@@ -322,9 +314,24 @@ function summarizeRow(row) {
322
314
  model: summary.model,
323
315
  // died: never "unknown" when a priced round exists — glyph [?] already
324
316
  // carries the word "died", so the row meta text itself never repeats it.
317
+ // `spend` is the LIBRARY's own 4-decimal string (never changed here —
318
+ // `--all`'s CLI listing reads the same `summarizeForAllLine`, F: "do not
319
+ // change the library's CLI output"). The panel page renders its own
320
+ // 2-decimal text off the numeric fields below instead of this string.
325
321
  spend: death.died ? (death.spendFloorUsd !== null ? `at least $${death.spendFloorUsd.toFixed(4)}` : 'unknown') : line.spend,
326
322
  wall: death.died ? (death.wallFloorMs !== null ? `at least ${formatDurationMs(death.wallFloorMs)}` : 'unknown') : line.wall,
327
323
  date: typeof row.at === 'string' ? row.at.slice(0, 10) : null,
324
+ // numeric fields for the panel's own 2-decimal render (hamr's ruling
325
+ // 2026-09-28, "panel money 2-decimals") — `spentUsd`/`spendComplete` are
326
+ // `null`/`false` on a died row (no job-end, see `spendFloorUsd` instead);
327
+ // `draftSpentUsd`/`draftSpendComplete` are `null` when this run carried
328
+ // no drafting fold at all, exactly like `summary.draftSpentUsd` itself.
329
+ spentUsd: death.died ? null : summary.spentUsd,
330
+ spendComplete: death.died ? false : summary.spendComplete,
331
+ spendFloorUsd: death.died ? death.spendFloorUsd : null,
332
+ draftSpentUsd: summary.draftSpentUsd,
333
+ draftSpendComplete: summary.draftSpendComplete,
334
+ budgetUsd: summary.budgetUsd,
328
335
  };
329
336
  }
330
337
 
@@ -375,9 +382,19 @@ export function getRunDetail(runid, opts = {}) {
375
382
  runid, job: row.job, at: row.at, via: row.via, fileMissing: true,
376
383
  };
377
384
  }
378
- const summary = replayOne(row.spine);
379
- const timelineKind = summary.timelineKind;
385
+ // build item 7 (2026-09-28): resolve the audit path through the ONE
386
+ // shared resolver (`resolveAuditPathForRow`, already the sole owner for
387
+ // `scopedBehaviour`/`getRunAudit`) BEFORE calling `replayOne`, and hand it
388
+ // the result — otherwise `replayOne`'s own internal resolution only ever
389
+ // finds the FINISHED-run convention, so a still-LIVE run's part-level
390
+ // `byTool`/`toolCalls` (summary.parts, read from `replayRun`) read
391
+ // "unknown" even though the SAME run's Run-tab tools/cache summary and
392
+ // Audit tab (both driven through `resolveAuditPathForRow` already) can
393
+ // see the during-run sidecar just fine.
380
394
  const rawSpineRecords = parseJsonl(row.spine).records;
395
+ const auditPath = resolveAuditPathForRow(row, rawSpineRecords);
396
+ const summary = replayOne(row.spine, { auditPathOverride: auditPath });
397
+ const timelineKind = summary.timelineKind;
381
398
  const death = deriveDeath(row.spine, rawSpineRecords, summary.outcome);
382
399
  // judgeModel (Summary box "judge:" line): the FIRST `judge-round`'s own
383
400
  // `model` field (src/planrun.js:1704's `onJudgeCost` emit) — a soft-green
@@ -509,12 +526,24 @@ export function getRunDetail(runid, opts = {}) {
509
526
  died: death.died,
510
527
  stopReason: death.died ? death.why : summary.stopReason,
511
528
  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,
529
+ // draftSpentUsd (hamr's ruling 2026-09-28) — the drafting share of this
530
+ // run's cap, or null when this run carried none. `src/replay.js`'s
531
+ // `moneyWithDraft` is the one place that decides how to render it.
532
+ draftSpentUsd: summary.draftSpentUsd,
533
+ // draftSpendComplete (hamr's ruling 2026-09-28, 2nd addendum) — whether
534
+ // the drafting share above was EXACT; `null` when there is no drafting
535
+ // share to qualify at all. Read by the panel's own `panelMoneyWithDraft`.
536
+ draftSpendComplete: summary.draftSpendComplete,
537
+ // a spend/wall floor summed from real priced rounds/timestamped records
538
+ // present in the file — never null/unknown when at least one priced
539
+ // round exists. `deriveDeath` already returns `null` for both whenever
540
+ // a real job-end WAS reached (the normal `spentUsd`/`wallMs` fields
541
+ // above are the real, complete figures there); build item 6
542
+ // (2026-09-28) widened this from "died runs only" to ALSO cover a
543
+ // genuinely still-running spine, so the Run tab can show a running
544
+ // floor instead of "unknown" while a run is live.
545
+ spendFloorUsd: death.spendFloorUsd,
546
+ wallFloorMs: death.wallFloorMs,
518
547
  spendComplete: summary.spendComplete,
519
548
  wallMs: summary.wallMs,
520
549
  timelineKind,
@@ -529,9 +558,15 @@ export function getRunDetail(runid, opts = {}) {
529
558
  // parts (panel build item B/C, 2026-09-26): the ONE ordered part list
530
559
  // (`src/replay.js`'s own field, computed once server-side) — drives the
531
560
  // 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,
561
+ // VERBATIM (never re-derived client-side) EXCEPT each attempt's own
562
+ // declared-close `stages` array, which gets its per-stage `kind`/
563
+ // `direction`/`baselineKind` attached here (build item, 2026-09-28: the
564
+ // gap 764a9ef named — the signed closeDecl is the only place this lives,
565
+ // and it is not part of `replayOne`'s own spine-derived shape) — see
566
+ // {@link enrichPartsWithStageKind}. `kindMeta` is `null` (every stage
567
+ // passes through unchanged) whenever no spec resolves at all, so the two
568
+ // tabs still never disagree about order/counts/blocked-call figures.
569
+ parts: enrichPartsWithStageKind(summary.parts, stageKindMetaFromSpec(resolveSpecForRow(row))),
535
570
  replans: summary.replans,
536
571
  close: summary.close,
537
572
  branch: summary.branch,
@@ -1121,6 +1156,278 @@ function sourceNearSpine(spinePath) {
1121
1156
  };
1122
1157
  }
1123
1158
 
1159
+ /**
1160
+ * The resolved SPEC OBJECT for a run — routes (a) bundle `spec.json`, (b)
1161
+ * `jobs/<job>.json`, (c) the run's own `resolved-spec.json` beside the spine
1162
+ * (the three real-spec routes {@link getRunJob}'s `fromSpec` also tries, in
1163
+ * the same order; never route (d), the run's own job-start record, which
1164
+ * carries no `closeDecl` for {@link stageKindMetaFromSpec} to read a
1165
+ * stage's kind from). A specHash mismatch against `jobs/<job>.json` is NOT
1166
+ * checked here — the Job tab's own mismatch note is a display concern of
1167
+ * that endpoint, not a reason to withhold a stage's kind (the declaration a
1168
+ * hash mismatch flags is still the one the close actually ran under until
1169
+ * the run is repeated). `null` when no route resolves or every candidate
1170
+ * fails to parse as an object — never a guessed spec.
1171
+ * @param {{ spine: string, job: string }} row
1172
+ * @returns {any|null}
1173
+ */
1174
+ function resolveSpecForRow(row) {
1175
+ if (!existsSync(row.spine)) return null;
1176
+ const bundleDir = bundleDirForSpine(row.spine);
1177
+ const bundleSpecPath = bundleDir ? join(bundleDir, 'spec.json') : null;
1178
+ if (bundleSpecPath && existsSync(bundleSpecPath)) {
1179
+ try {
1180
+ const spec = JSON.parse(readFileSync(bundleSpecPath, 'utf8'));
1181
+ if (spec && typeof spec === 'object') return spec;
1182
+ } catch { /* falls through to the next route */ }
1183
+ }
1184
+ const jobsSpecPath = join(jobsDir(), `${row.job}.json`);
1185
+ if (existsSync(jobsSpecPath)) {
1186
+ try {
1187
+ const spec = JSON.parse(readFileSync(jobsSpecPath, 'utf8'));
1188
+ if (spec && typeof spec === 'object') return spec;
1189
+ } catch { /* falls through to the next route */ }
1190
+ }
1191
+ const near = sourceNearSpine(row.spine);
1192
+ if (near.specPath) {
1193
+ try {
1194
+ const spec = JSON.parse(readFileSync(near.specPath, 'utf8'));
1195
+ if (spec && typeof spec === 'object') return spec;
1196
+ } catch { /* no spec resolvable */ }
1197
+ }
1198
+ return null;
1199
+ }
1200
+
1201
+ /**
1202
+ * THE PLAIN-QUESTION OWNER (plain-checks build, 2026-09-28, hamr's ruling:
1203
+ * the "checks N/M" headline is WITHDRAWN — "confusing, reads like 6 failed
1204
+ * when 5 never ran"). ONE pure function mapping a declared stage's own
1205
+ * signed `kind`+`params` (plus the close's own `genre`, also a signed field)
1206
+ * to a short plain-English question a person can read without knowing the
1207
+ * catalogue — never a second spelling on the client, and never anything the
1208
+ * model authored (the catalogue's kinds/params are the whole vocabulary this
1209
+ * reads).
1210
+ *
1211
+ * Every branch is traced straight off `src/kinds.js`'s runner semantics and
1212
+ * the TYPES genre template (`src/authoring.js` `TYPES_GENRE_TEMPLATE`,
1213
+ * `classGuards`'s `MECHANICAL_GUARDS`) — never guessed:
1214
+ * - `files-changed` is always the `changed-from-seed` guard: "did it
1215
+ * change any file?"
1216
+ * - `pattern-absent-in-diff` is always the `no-suppressions` guard: "no
1217
+ * casts or silencers added?"
1218
+ * - `command-exit` is the genre's `suite-green` stage (the only shipped use
1219
+ * of this kind): "does the test suite pass?"
1220
+ * - `count-not-worse` splits three ways on its own declared
1221
+ * `direction`/`baseline` (never the runtime number — see
1222
+ * {@link stageKindMetaFromSpec}'s own doc for why the baseline KIND must
1223
+ * travel separately from the measured figure):
1224
+ * - `higher-is-better` + `baseline: 'seed'` is the genre's `tests-kept`
1225
+ * floor: "did all the old tests still exist?"
1226
+ * - `lower-is-better` + `baseline: 0` is the genre's in-scope
1227
+ * `typecheck` stage — UNLESS its own `cmd`+`args` is the SAME
1228
+ * command a `command-exit` stage in the same close already runs
1229
+ * (`suiteCmdKeys`, below): the TYPES template's `suite-green` is "the
1230
+ * suite exits clean AND reports zero failing tests — TWO assertions"
1231
+ * over ONE population (the same `npm test`/`pytest` invocation), so a
1232
+ * `count-not-worse` stage sharing that exact command is the failing-
1233
+ * test-count half of that pair, never a typecheck stage: "no failing
1234
+ * tests?" — derived from the same cmd+args correlation (the stage
1235
+ * counts the suite's own failing tests), never from the stage's own
1236
+ * name string. The live check that found this (run mul5fofw's own
1237
+ * `suite-zero-failing-tests`, cmd `npm test`, same as its sibling
1238
+ * `suite-green` command-exit stage) would otherwise have read "test/
1239
+ * has 0 type errors?", which is false: its parser counts FAILING
1240
+ * TESTS (`^# fail (\d+)$`), not type-checker output. hamr's ruling:
1241
+ * name this half honestly rather than reusing "type errors" for a
1242
+ * population it was never about, or falling back to the stage's raw
1243
+ * name. When the command differs from every sibling `command-exit`
1244
+ * stage, the declared `scope.includePrefixes` naming exactly ONE path
1245
+ * is quoted verbatim (a signed param, never invented): "<path> has 0
1246
+ * type errors?" — otherwise the generic "type errors is 0?", never a
1247
+ * guessed filename.
1248
+ * - `lower-is-better` + `baseline: 'seed'` with a non-empty
1249
+ * `scope.excludePrefixes` is the genre's `typecheck-outside` ceiling:
1250
+ * "other files didn't get more type errors?"
1251
+ * The "type errors" wording is genre-owned (`GENRE_WHAT`), because TYPES is
1252
+ * the only genre this catalogue admits today (doc-genre kinds are not yet
1253
+ * built) — an unrecognised genre, or a `count-not-worse` shape none of the
1254
+ * three above matches, returns `null` rather than invent a fourth shape.
1255
+ *
1256
+ * `null` means "no honest plain question" — the caller (buildAttemptsList,
1257
+ * partLine1Text's `attemptChecksLine`) falls back to the stage's OWN name,
1258
+ * exactly as it already does for a stage with no resolvable kind at all.
1259
+ * @param {{kind: string|null, params: any, genre: string|null, suiteCmdKeys: Set<string>}} o
1260
+ * @returns {string|null}
1261
+ */
1262
+ const GENRE_WHAT = Object.freeze({ TYPES: 'type errors' });
1263
+
1264
+ /** `cmd`+`args`, as a comparable key — the one signal (besides `kind`
1265
+ * itself) that two stages measure the SAME command's output, used to tell a
1266
+ * genuine typecheck stage apart from the TYPES genre's other `lower-is-
1267
+ * better`/`baseline: 0` stage (the failing-test-count half of `suite-green`'s
1268
+ * "two assertions"), which shares that stage's own shape but not its
1269
+ * population. `null` for a stage whose `cmd` isn't a non-empty string —
1270
+ * never matches anything, so it never falsely suppresses a real typecheck
1271
+ * question.
1272
+ * @param {any} params @returns {string|null} */
1273
+ function cmdKey(params) {
1274
+ if (!params || typeof params.cmd !== 'string' || params.cmd.length === 0) return null;
1275
+ const args = Array.isArray(params.args) ? params.args : [];
1276
+ return JSON.stringify([params.cmd, args]);
1277
+ }
1278
+
1279
+ function stageQuestionText({
1280
+ kind, params, genre, suiteCmdKeys,
1281
+ }) {
1282
+ if (kind === 'files-changed') return 'did it change any file?';
1283
+ if (kind === 'pattern-absent-in-diff') return 'no casts or silencers added?';
1284
+ if (kind === 'command-exit') return 'does the test suite pass?';
1285
+ if (kind !== 'count-not-worse' || !params || typeof params !== 'object') return null;
1286
+ const what = Object.hasOwn(GENRE_WHAT, String(genre)) ? GENRE_WHAT[String(genre)] : null;
1287
+ if (params.direction === 'higher-is-better' && params.baseline === 'seed') {
1288
+ return 'did all the old tests still exist?';
1289
+ }
1290
+ if (!what) return null;
1291
+ const scope = params.scope && typeof params.scope === 'object' ? params.scope : null;
1292
+ if (params.direction === 'lower-is-better' && params.baseline === 0) {
1293
+ const key = cmdKey(params);
1294
+ if (key !== null && suiteCmdKeys instanceof Set && suiteCmdKeys.has(key)) return 'no failing tests?';
1295
+ const include = scope && Array.isArray(scope.includePrefixes) ? scope.includePrefixes : null;
1296
+ if (include && include.length === 1 && typeof include[0] === 'string' && include[0].length > 0) {
1297
+ return `${include[0]} has 0 ${what}?`;
1298
+ }
1299
+ return `${what} is 0?`;
1300
+ }
1301
+ if (params.direction === 'lower-is-better' && params.baseline === 'seed') {
1302
+ const exclude = scope && Array.isArray(scope.excludePrefixes) ? scope.excludePrefixes : null;
1303
+ if (exclude && exclude.length > 0) return `other files didn't get more ${what}?`;
1304
+ return null;
1305
+ }
1306
+ return null;
1307
+ }
1308
+
1309
+ /**
1310
+ * Per-stage `{kind, direction, baselineKind, question}`, keyed by stage NAME,
1311
+ * off a resolved spec's own signed `closeDecl.stages` — the facts the client
1312
+ * needs to pick §3a's numeric wording (down-to-a-goal / up-out-of-a-total /
1313
+ * not-worse-than-a-baseline / pass-fail-only) AND the plain-checks build's
1314
+ * own question text ({@link stageQuestionText}), never guessed from the
1315
+ * runtime number or the stage's model-authored name alone: a `lower-is-better`
1316
+ * stage whose SEED happened to measure 0 is numerically indistinguishable
1317
+ * from a `baseline: 0` declared goal, so the declared baseline KIND (`'seed'`
1318
+ * vs the literal `0`) has to travel separately from the measured number.
1319
+ * Iteration order is `spec.closeDecl.stages`' own DECLARED order (a `Map`
1320
+ * preserves insertion order) — the plain-checks build's client reads this
1321
+ * same order for a stage's `#N` position and for which declared stages never
1322
+ * ran. `null` when the spec has no `closeDecl.stages` array at all (a
1323
+ * command-close spec, or no spec resolved).
1324
+ * @param {any} spec
1325
+ * @returns {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null}
1326
+ */
1327
+ function stageKindMetaFromSpec(spec) {
1328
+ if (!spec || typeof spec !== 'object' || !spec.closeDecl || !Array.isArray(spec.closeDecl.stages)) return null;
1329
+ const genre = typeof spec.closeDecl.genre === 'string' ? spec.closeDecl.genre : null;
1330
+ // every command-exit stage's own cmd+args, gathered FIRST (a separate pass)
1331
+ // so stageQuestionText can tell the failing-test-count half of suite-green's
1332
+ // "two assertions" apart from a genuine typecheck stage — see its own doc.
1333
+ /** @type {Set<string>} */
1334
+ const suiteCmdKeys = new Set();
1335
+ for (const s of spec.closeDecl.stages) {
1336
+ if (s && s.kind === 'command-exit') {
1337
+ const key = cmdKey(s.params);
1338
+ if (key !== null) suiteCmdKeys.add(key);
1339
+ }
1340
+ }
1341
+ /** @type {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>} */
1342
+ const map = new Map();
1343
+ for (const s of spec.closeDecl.stages) {
1344
+ if (!s || typeof s.name !== 'string' || s.name.length === 0) continue;
1345
+ const kind = typeof s.kind === 'string' ? s.kind : null;
1346
+ const params = s.params && typeof s.params === 'object' ? s.params : null;
1347
+ const direction = params && typeof params.direction === 'string' ? params.direction : null;
1348
+ const baselineKind = params && (params.baseline === 'seed' || params.baseline === 0) ? params.baseline : null;
1349
+ const question = stageQuestionText({
1350
+ kind, params, genre, suiteCmdKeys,
1351
+ });
1352
+ map.set(s.name, {
1353
+ kind, direction, baselineKind, question,
1354
+ });
1355
+ }
1356
+ return map;
1357
+ }
1358
+
1359
+ /**
1360
+ * Attaches one stage's `{kind, direction, baselineKind, question}` (from
1361
+ * {@link stageKindMetaFromSpec}'s map) onto its already-recorded runtime
1362
+ * shape (`{name, verdict, value?, baseline?, …}`, straight off the spine) —
1363
+ * a shallow copy, never a mutation of the spine-derived object, and only when
1364
+ * BOTH a name and a matching declaration entry exist; otherwise the stage
1365
+ * passes through unchanged (the client's existing name fallback still
1366
+ * renders for it).
1367
+ * @param {any} stage
1368
+ * @param {Map<string, {kind: string|null, direction: string|null, baselineKind: 'seed'|0|null, question: string|null}>|null} kindMeta
1369
+ * @returns {any}
1370
+ */
1371
+ function attachStageKind(stage, kindMeta) {
1372
+ if (!stage || typeof stage !== 'object' || typeof stage.name !== 'string' || !kindMeta) return stage;
1373
+ const meta = kindMeta.get(stage.name);
1374
+ if (!meta) return stage;
1375
+ return {
1376
+ ...stage, kind: meta.kind, direction: meta.direction, baselineKind: meta.baselineKind, question: meta.question,
1377
+ };
1378
+ }
1379
+
1380
+ /**
1381
+ * `summary.parts` (verbatim except each attempt's `stages` array, which gets
1382
+ * {@link attachStageKind} applied per stage) — a shallow re-map, never a
1383
+ * mutation of `replayOne`'s own returned objects (other endpoints, e.g.
1384
+ * {@link getRunAudit}, call `replayOne` fresh per request, but this stays
1385
+ * defensive rather than relying on that).
1386
+ *
1387
+ * Also stamps each attempt with `declaredStagesTotal` (checks-count fix,
1388
+ * 2026-09-28): `kindMeta.size` — the number of stages the signed
1389
+ * `closeDecl` DECLARED, never `stages.length` (the number that RAN). First-
1390
+ * red-wins means an attempt that stopped early carries a `stages` array
1391
+ * shorter than the declaration; the client reads this field for the total
1392
+ * count so an unrun stage still counts in the total exactly once, computed
1393
+ * here and never re-derived client-side — "checks 1/2" on a 7-stage close
1394
+ * (hamr's live catch) was this exact bug.
1395
+ *
1396
+ * `declaredStages` (plain-checks build, 2026-09-28): every declared stage's
1397
+ * `{name, question}`, in DECLARED order — `Array.from(kindMeta)` walks a
1398
+ * `Map`'s own insertion order, which is `spec.closeDecl.stages`' order. The
1399
+ * client zips this against the attempt's own (shorter, when first-red-wins
1400
+ * stopped it early) `stages` array BY NAME to render every declared stage in
1401
+ * the expanded view, including ones that never ran ("· not run") — never by
1402
+ * POSITION, because a stage's declared index is exactly what `#N` in the
1403
+ * headline/expanded view already means, and a name lookup is robust to any
1404
+ * future reordering between the resolved spec and the executed close.
1405
+ *
1406
+ * Both fields are `null`/omitted whenever `kindMeta` is `null` — no spec
1407
+ * resolved, or the spec carries no `closeDecl.stages` at all — so the client
1408
+ * falls back to `stages.length` and renders only the stages that ran, exactly
1409
+ * as before this build.
1410
+ * @param {any[]|null|undefined} parts
1411
+ * @param {Map<string, any>|null} kindMeta
1412
+ * @returns {any[]|null|undefined}
1413
+ */
1414
+ function enrichPartsWithStageKind(parts, kindMeta) {
1415
+ if (!Array.isArray(parts)) return parts;
1416
+ const declaredStagesTotal = kindMeta ? kindMeta.size : null;
1417
+ const declaredStages = kindMeta
1418
+ ? Array.from(kindMeta, ([name, meta]) => ({ name, question: meta.question }))
1419
+ : null;
1420
+ return parts.map((part) => ({
1421
+ ...part,
1422
+ attempts: Array.isArray(part.attempts) ? part.attempts.map((a) => ({
1423
+ ...a,
1424
+ stages: (kindMeta && Array.isArray(a.stages)) ? a.stages.map((s) => attachStageKind(s, kindMeta)) : a.stages,
1425
+ ...(declaredStagesTotal ? { declaredStagesTotal } : {}),
1426
+ ...(declaredStages ? { declaredStages } : {}),
1427
+ })) : part.attempts,
1428
+ }));
1429
+ }
1430
+
1124
1431
  /**
1125
1432
  * The close's stage names, in declared order — `close[].name` (a hand-authored
1126
1433
  * command close) or `closeDecl.stages[].name` (an authored declaration); the
@@ -1406,16 +1713,29 @@ function sendText(res, code, text) {
1406
1713
  * separately from {@link createPanelServer} so tests can drive it without a
1407
1714
  * real listening socket where that is simpler (most path-safety/405 tests
1408
1715
  * still go through a real socket, per the build spec).
1716
+ *
1717
+ * PANEL-BUILD.md P3: this file's own GET/HEAD-only rule stands for every
1718
+ * route it owns; `/api/author/*` is the ONE family of routes that may be
1719
+ * POSTed to, and it is handled entirely by {@link createAuthorRoutes}
1720
+ * (`src/panel/authorroutes.js`) — a separate module so this file's own
1721
+ * "read-only, by construction" header comment stays true of everything else
1722
+ * in it. `opts.authorRoutes` is absent on every P1/P2 caller (read-only
1723
+ * tests, and the CLI's own `panelMain` when no author routes are wired) —
1724
+ * only `createPanelServer` below always supplies one.
1409
1725
  * @param {import('node:http').IncomingMessage} req
1410
1726
  * @param {import('node:http').ServerResponse} res
1411
- * @param {{ home?: string, port: number }} opts
1727
+ * @param {{ home?: string, port: number, token?: string, authorRoutes?: ReturnType<typeof createAuthorRoutes>, settingsRoutes?: ReturnType<typeof createSettingsRoutes> }} opts
1412
1728
  */
1413
1729
  export function handleRequest(req, res, opts) {
1414
- const method = req.method ?? 'GET';
1415
- if (method !== 'GET' && method !== 'HEAD') {
1416
- sendText(res, 405, 'method not allowed — this panel is read-only (GET/HEAD only)');
1417
- return;
1730
+ /** the ONE writable route family for a path, or null — `/api/author/*` (authoring) or
1731
+ * `/api/settings/*` (Settings, P4a): separate modules, each behind the human-click guard.
1732
+ * @param {any} o @param {string} p */
1733
+ function routesFor(o, p) {
1734
+ if (o.authorRoutes && p.startsWith('/api/author')) return o.authorRoutes;
1735
+ if (o.settingsRoutes && p.startsWith('/api/settings')) return o.settingsRoutes;
1736
+ return null;
1418
1737
  }
1738
+ const method = req.method ?? 'GET';
1419
1739
 
1420
1740
  let url;
1421
1741
  try {
@@ -1426,6 +1746,43 @@ export function handleRequest(req, res, opts) {
1426
1746
  }
1427
1747
  const { pathname } = url;
1428
1748
 
1749
+ if (method !== 'GET' && method !== 'HEAD') {
1750
+ const routes = routesFor(opts, pathname);
1751
+ if (routes) {
1752
+ let raw = '';
1753
+ let size = 0;
1754
+ let refused = false;
1755
+ req.on('data', (c) => {
1756
+ if (refused) return;
1757
+ size += c.length;
1758
+ if (size > MAX_BODY_BYTES) {
1759
+ refused = true;
1760
+ raw = '';
1761
+ res.once('finish', () => { req.destroy(); });
1762
+ res.setHeader('connection', 'close');
1763
+ sendText(res, 413, `request body over ${MAX_BODY_BYTES} bytes`);
1764
+ return;
1765
+ }
1766
+ raw += c;
1767
+ });
1768
+ req.on('end', () => {
1769
+ if (refused) return;
1770
+ /** @type {any} */
1771
+ let body = null;
1772
+ if (raw.length > 0) { try { body = JSON.parse(raw); } catch { body = null; } }
1773
+ routes.handle(req, res, pathname, body);
1774
+ });
1775
+ return;
1776
+ }
1777
+ sendText(res, 405, 'method not allowed — this panel is read-only outside /api/author and /api/settings (GET/HEAD only)');
1778
+ return;
1779
+ }
1780
+ const getRoutes = routesFor(opts, pathname);
1781
+ if (getRoutes) {
1782
+ getRoutes.handle(req, res, pathname, null);
1783
+ return;
1784
+ }
1785
+
1429
1786
  const send = (code, body) => {
1430
1787
  if (method === 'HEAD') {
1431
1788
  const text = JSON.stringify(body);
@@ -1446,6 +1803,11 @@ export function handleRequest(req, res, opts) {
1446
1803
  return;
1447
1804
  }
1448
1805
  html = html.replace(/__BARELOOP_PANEL_PORT__/g, String(opts.port));
1806
+ // PANEL-BUILD.md P3's human-click guard: a per-server-start token,
1807
+ // templated into the page exactly like the port — absent on every P1/P2
1808
+ // caller (no `opts.token`), which is fine: the page's own JS only reads
1809
+ // it to send on a POST, and there are none to send without P3's routes.
1810
+ html = html.replace(/__BARELOOP_PANEL_TOKEN__/g, String(opts.token ?? ''));
1449
1811
  if (method === 'HEAD') {
1450
1812
  res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'content-length': Buffer.byteLength(html) });
1451
1813
  res.end();
@@ -1509,12 +1871,25 @@ export function handleRequest(req, res, opts) {
1509
1871
  * header). Resolves once actually listening; rejects on a bind error
1510
1872
  * (including `EADDRINUSE`) with a `.port` field on the error for the
1511
1873
  * caller's message.
1512
- * @param {{ port?: number, home?: string }} [opts]
1513
- * @returns {Promise<{ server: import('node:http').Server, port: number, close: () => Promise<void> }>}
1874
+ *
1875
+ * PANEL-BUILD.md P3: a fresh {@link mintToken} token and one {@link
1876
+ * createAuthorRoutes} instance are built here, ONCE per server start, and
1877
+ * held for the server's lifetime — `opts.env`/`opts.sessionsRoot`/
1878
+ * `opts.spawnFn`/`opts.bareloopBin` are test seams the author routes take
1879
+ * (a real caller passes none of them and {@link createAuthorRoutes} defaults
1880
+ * each one to the real environment/spawn/binary, this file itself touching
1881
+ * none of them, and the real
1882
+ * `~/.config/bareloop/panel-sessions`, the real `child_process.spawn`, and
1883
+ * this package's own `bin/bareloop.mjs`).
1884
+ * @param {{ port?: number, home?: string, env?: Record<string,string|undefined>,
1885
+ * sessionsRoot?: string, spawnFn?: (...a: any[]) => any, bareloopBin?: string,
1886
+ * fetchImpl?: typeof fetch }} [opts]
1887
+ * @returns {Promise<{ server: import('node:http').Server, port: number, token: string, close: () => Promise<void> }>}
1514
1888
  */
1515
1889
  export function createPanelServer(opts = {}) {
1516
1890
  const requestedPort = opts.port ?? DEFAULT_PORT;
1517
1891
  const home = opts.home;
1892
+ const token = mintToken();
1518
1893
  return new Promise((resolve, reject) => {
1519
1894
  // Bound port is resolved from the live socket (`server.address().port`)
1520
1895
  // once listening starts, not the requested value — this is what makes
@@ -1522,9 +1897,15 @@ export function createPanelServer(opts = {}) {
1522
1897
  // test suite, while `--port N` / DEFAULT_PORT callers still get back
1523
1898
  // exactly the port they asked for.
1524
1899
  let boundPort = requestedPort;
1900
+ /** @type {ReturnType<typeof createAuthorRoutes>|undefined} */
1901
+ let authorRoutes;
1902
+ /** @type {ReturnType<typeof createSettingsRoutes>|undefined} */
1903
+ let settingsRoutes;
1525
1904
  const server = createServer((req, res) => {
1526
1905
  try {
1527
- handleRequest(req, res, { home, port: boundPort });
1906
+ handleRequest(req, res, {
1907
+ home, port: boundPort, token, authorRoutes, settingsRoutes,
1908
+ });
1528
1909
  } catch (e) {
1529
1910
  sendText(res, 500, `internal error: ${/** @type {Error} */ (e).message}`);
1530
1911
  }
@@ -1537,9 +1918,23 @@ export function createPanelServer(opts = {}) {
1537
1918
  server.listen(requestedPort, '127.0.0.1', () => {
1538
1919
  const addr = server.address();
1539
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
+ home,
1930
+ });
1931
+ settingsRoutes = createSettingsRoutes({
1932
+ port: boundPort, token, home, env: opts.env, fetchImpl: opts.fetchImpl,
1933
+ });
1540
1934
  resolve({
1541
1935
  server,
1542
1936
  port: boundPort,
1937
+ token,
1543
1938
  close: () => new Promise((res2) => { server.close(() => res2(undefined)); }),
1544
1939
  });
1545
1940
  });
@@ -1551,7 +1946,10 @@ export function createPanelServer(opts = {}) {
1551
1946
  * Never picks a different port on collision (see file header): prints a
1552
1947
  * loud, named error to stderr and returns 1.
1553
1948
  * @param {string[]} argv
1554
- * @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string }} ctx
1949
+ * @param {{ out: (s: string) => void, err: (s: string) => void, runlistHome?: string, env?: Record<string,string|undefined> }} ctx
1950
+ * `env` is the RAW shell env (never the start-time merge with the keys file) — the routes
1951
+ * re-merge `~/.config/bareloop/.env` onto it per request so Reload keys is real;
1952
+ * undefined = the process env.
1555
1953
  * @returns {Promise<number>}
1556
1954
  */
1557
1955
  export async function panelMain(argv, ctx) {
@@ -1565,7 +1963,7 @@ export async function panelMain(argv, ctx) {
1565
1963
  }
1566
1964
  }
1567
1965
  try {
1568
- const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome });
1966
+ const { port: boundPort } = await createPanelServer({ port, home: ctx.runlistHome, env: ctx.env });
1569
1967
  ctx.out(`bareloop panel — read-only, http://127.0.0.1:${boundPort} (Ctrl-C to stop)`);
1570
1968
  // never resolves on its own — the process stays up until killed, same
1571
1969
  // shape any other long-running dev server takes.