bareloop 0.42.0 → 0.44.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 (79) hide show
  1. package/CHANGELOG.md +88 -0
  2. package/bareloop.context.md +103 -41
  3. package/package.json +2 -2
  4. package/src/authorflow.js +26 -11
  5. package/src/authoring.js +1 -1
  6. package/src/authorjob.js +33 -15
  7. package/src/authorreadout.js +7 -6
  8. package/src/declaredclose.js +6 -3
  9. package/src/draftspend.js +20 -2
  10. package/src/facts.js +54 -9
  11. package/src/factwords.js +58 -6
  12. package/src/inputlook.js +38 -3
  13. package/src/interviewrun.js +0 -1
  14. package/src/job.js +39 -12
  15. package/src/kinds.js +5 -0
  16. package/src/pagedropwords.js +64 -0
  17. package/src/pagemap.js +264 -0
  18. package/src/pagerules.js +319 -0
  19. package/src/panel/authorroutes.js +138 -60
  20. package/src/panel/authorsession.js +88 -32
  21. package/src/panel/index.html +1251 -522
  22. package/src/panel/jobplan.js +4 -2
  23. package/src/panel/runroutes.js +188 -6
  24. package/src/panel/scheduler.js +156 -0
  25. package/src/panel/schedules.js +667 -0
  26. package/src/panel/schedulestart.js +243 -0
  27. package/src/panel/server.js +1234 -68
  28. package/src/panel/status.js +33 -0
  29. package/src/panelmoney.js +29 -0
  30. package/src/planrun.js +92 -11
  31. package/src/promptregisters.js +4 -0
  32. package/src/providerrows.js +3 -3
  33. package/src/replay.js +118 -7
  34. package/src/reviewdoor.js +37 -0
  35. package/src/rubricstep.js +123 -11
  36. package/src/runlist.js +37 -1
  37. package/src/schedulewords.js +299 -0
  38. package/src/source.js +18 -0
  39. package/src/thinking.js +63 -2
  40. package/src/trigger.js +48 -0
  41. package/src/triggerclock.js +127 -0
  42. package/src/triggerfiles.js +215 -0
  43. package/src/triggerledger.js +79 -0
  44. package/src/userrun.js +34 -21
  45. package/types/authorflow.d.ts +42 -6
  46. package/types/authorjob.d.ts +10 -2
  47. package/types/declaredclose.d.ts +3 -1
  48. package/types/draftspend.d.ts +27 -1
  49. package/types/facts.d.ts +15 -0
  50. package/types/factwords.d.ts +26 -3
  51. package/types/inputlook.d.ts +15 -0
  52. package/types/job.d.ts +8 -1
  53. package/types/kinds.d.ts +5 -0
  54. package/types/pagedropwords.d.ts +26 -0
  55. package/types/pagemap.d.ts +154 -0
  56. package/types/pagerules.d.ts +86 -0
  57. package/types/panel/authorroutes.d.ts +50 -14
  58. package/types/panel/authorsession.d.ts +37 -12
  59. package/types/panel/runroutes.d.ts +32 -1
  60. package/types/panel/scheduler.d.ts +17 -0
  61. package/types/panel/schedules.d.ts +412 -0
  62. package/types/panel/schedulestart.d.ts +106 -0
  63. package/types/panel/server.d.ts +297 -15
  64. package/types/panel/status.d.ts +51 -0
  65. package/types/panelmoney.d.ts +5 -0
  66. package/types/planrun.d.ts +2 -0
  67. package/types/providerrows.d.ts +2 -2
  68. package/types/replay.d.ts +21 -1
  69. package/types/reviewdoor.d.ts +27 -0
  70. package/types/rubricstep.d.ts +57 -1
  71. package/types/runlist.d.ts +24 -1
  72. package/types/schedulewords.d.ts +137 -0
  73. package/types/source.d.ts +11 -0
  74. package/types/thinking.d.ts +39 -0
  75. package/types/trigger.d.ts +37 -0
  76. package/types/triggerclock.d.ts +52 -0
  77. package/types/triggerfiles.d.ts +93 -0
  78. package/types/triggerledger.d.ts +35 -0
  79. package/types/userrun.d.ts +4 -0
@@ -19,19 +19,27 @@
19
19
 
20
20
  import { randomBytes } from 'node:crypto';
21
21
  import { spawn as realSpawn } from 'node:child_process';
22
- import { openSync } from 'node:fs';
22
+ import { openSync, existsSync } from 'node:fs';
23
23
  import { join } from 'node:path';
24
24
  import { readFileSync } from 'node:fs';
25
25
  import { planMatchesClose } from '../rubricstep.js';
26
- import { createSession, validateJobCard, validateReuseCard, lockedFieldChanged, buildReuseSpec, cardFields, STEP_LABELS } from './authorsession.js';
26
+ import { createSession, validateJobCard, validateReuseCard, jobBoxesChanged, buildReuseSpec, cardFields, nextFreeJobName, startDestinationProblem, STEP_LABELS } from './authorsession.js';
27
27
  import { parseInputs } from '../jobblock.js';
28
- import { proveInputFiles, looksLikeRubricInput } from '../source.js';
28
+ import { proveInputFiles, looksLikeRubricInput, webFetchState } from '../source.js';
29
29
  import { validateJob } from '../job.js';
30
+
31
+ /** plain words for a run whose saved job no longer passes today's `validateJob` (RULED 2026-10-15): never the raw red codes */
32
+ export const OLD_JOB_REUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be reused. Start a new job instead.";
33
+ export const OLD_JOB_RESUME_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be resumed. Use Reuse workflow on a newer run, or start a new job.";
34
+ export const OLD_JOB_ACCEPT_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be accepted. Use Reuse workflow on a newer run, or start a new job.";
35
+ export const OLD_JOB_RERUN_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be rerun. Use Reuse workflow on a newer run, or start a new job.";
36
+ export const OLD_JOB_PAUSE_WORDS = "This run was made by an older bareloop. Its saved job doesn't pass today's checks, so it can't be paused from here. Use Reuse workflow on a newer run, or start a new job.";
30
37
  import { checkMonthlyRoom, monthlyRefusalText } from '../monthly.js';
31
38
  import { ConfigError } from '../config.js';
32
39
  import { keysForDoor, keysHome } from '../keysfile.js';
33
40
  import { endpointOf, judgePick, keyValueOf, rowsForHome, tickedLines } from '../providerrows.js';
34
41
  import { thinkingForLine } from '../thinking.js';
42
+ import { panelMoney2 } from '../panelmoney.js';
35
43
  import { apiKeyProblem, checkProviderReachable } from '../providers.js';
36
44
 
37
45
  /** @returns {string} a fresh per-process token — never persisted, never logged */
@@ -85,6 +93,18 @@ export function checkHumanGuard(req, { token, port }) {
85
93
  * one; a terminal session is kept (so its chat/hash stay readable) but no
86
94
  * longer counts against the one-at-a-time rule.
87
95
  */
96
+ /**
97
+ * PRD v1.106 Part 5 Q2: the "fetching the page..." step is done once the run's own refetch has written `refrozenAt` (source.json, written last).
98
+ * Settled when the state is read, so nothing polls and the step never stays running after the fetch.
99
+ * @param {any} state
100
+ */
101
+ export function settleFetchingStep(state) {
102
+ const st = Array.isArray(state?.steps) ? state.steps.find((/** @type {any} */ x) => x.id === 'fetching') : null;
103
+ if (!st || st.status !== 'running') return;
104
+ const file = join(String(state.outDir), 'source-seed', 'source.json');
105
+ if (existsSync(file) && !webFetchState(file, st.since).fetching) st.status = 'done';
106
+ }
107
+
88
108
  const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'signing-failed']);
89
109
 
90
110
  /**
@@ -95,6 +115,9 @@ const TERMINAL_PHASES = new Set(['refused', 'abandoned', 'error', 'signed', 'sig
95
115
  * @param {{ port: number, token: string, env?: Record<string,string|undefined>,
96
116
  * sessionsRoot?: string, spawnFn?: typeof realSpawn, bareloopBin?: string,
97
117
  * jobsDir?: string, fetchImpl?: typeof fetch, home?: string,
118
+ * schedules?: ReturnType<typeof import('./schedules.js').createScheduleDoor>,
119
+ * sessionDeps?: Record<string, any>,
120
+ * runJobNames?: () => string[],
98
121
  * startFrom?: {get: (runid: string, o?: {model?: string}) => any, getImport: (id: string, o?: {model?: string}) => any, listJobs?: (o?: {model?: string}) => any[]} }} opts
99
122
  */
100
123
  export function createAuthorRoutes(opts) {
@@ -110,8 +133,43 @@ export function createAuthorRoutes(opts) {
110
133
  /** @type {Map<string, ReturnType<typeof createSession>>} */
111
134
  const sessions = new Map();
112
135
 
136
+ /** Headless (scheduled) sessions live apart from the person's: neither blocks nor is blocked by the other (T I.5). Not addressable over HTTP. */
137
+ /** @type {Map<string, ReturnType<typeof createSession>>} */
138
+ const headless = new Map();
139
+
113
140
  const hasLiveSession = () => [...sessions.values()].some((s) => !TERMINAL_PHASES.has(s.state.phase));
114
141
 
142
+ /**
143
+ * THE one session start (T Part 4, C.4.2): prove the Inputs at $0, then create the session — the route's tail, factored so the
144
+ * HTTP route and the schedule starter share ONE function. A scheduled fire is exactly a Reuse the person did not click.
145
+ * `headless` puts the session in its own map and skips the one-live-session rule.
146
+ * @param {any} card the form (fields only)
147
+ * @param {{spec: any, workflowKey: string}|null} reuse
148
+ * @param {import('../jobblock.js').JobInput[]} inputs the parsed Inputs
149
+ * @param {{headless?: boolean}} [o]
150
+ * @returns {Promise<{ok: true, session: ReturnType<typeof createSession>}|{ok: false, status: number, error: string}>}
151
+ */
152
+ function startSession(card, reuse, inputs, o = {}) {
153
+ // ONE owner of "is this a rubric job on files": a reuse (and so a scheduled fire) reads it from the signed spec's verdict type,
154
+ // never from the card; a fresh job reads the card. A rubric job whose line 1 is a file or URL has no repo to prove inputs against;
155
+ // the session proves every line itself (`proveRubricInputs`), at $0, before any model call.
156
+ const rubric = reuse ? reuse.spec?.verdictType === 'soft-green' : card?.checkType === 'rubric';
157
+ if (reuse) card = { ...card, checkType: rubric ? 'rubric' : 'deterministic' };
158
+ const filesJob = rubric && inputs.length > 0 && looksLikeRubricInput(inputs[0].value);
159
+ return (filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(inputs)).then((proof) => {
160
+ if (!proof.ok) return { ok: /** @type {false} */ (false), status: 400, error: proof.error };
161
+ if (!o.headless && hasLiveSession()) return { ok: /** @type {false} */ (false), status: 409, error: 'an authoring session is already live — one at a time' };
162
+ // the SAME Destination check the session runs, at $0, BEFORE the session (and its folder) exists
163
+ const destProblem = startDestinationProblem(card);
164
+ if (destProblem !== null) return { ok: /** @type {false} */ (false), status: 400, error: destProblem };
165
+ const session = createSession(cardFields(card), {
166
+ env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}), ...(opts.sessionDeps ?? {}),
167
+ });
168
+ (o.headless ? headless : sessions).set(session.id, session);
169
+ return { ok: /** @type {true} */ (true), session };
170
+ }).catch((e) => ({ ok: /** @type {false} */ (false), status: 500, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }));
171
+ }
172
+
115
173
  /**
116
174
  * @param {import('node:http').IncomingMessage} req
117
175
  * @param {import('node:http').ServerResponse} res
@@ -217,10 +275,20 @@ export function createAuthorRoutes(opts) {
217
275
  const pre = sp.has('import') ? opts.startFrom.getImport(sp.get('import') ?? '', asModel) : opts.startFrom.get(runid, asModel);
218
276
  if (pre === null) { send(404, { ok: false, error: 'no such run' }); return true; }
219
277
  if (!pre.ok) { send(409, pre); return true; }
278
+ // the card of a SCHEDULED job opens with the folder that schedule delivers to (a plain-files job keeps it on the schedule, not in the spec)
279
+ const sched = sp.get('mode') === 'schedule' && opts.schedules ? opts.schedules.readSchedule(pre.origin.job) : null;
280
+ const card = typeof sched?.destination === 'string' ? { ...pre.card, destination: sched.destination } : pre.card;
220
281
  send(200, {
221
- ok: true, origin: pre.origin, card: pre.card, from: pre.from, note: pre.note,
222
- locked: pre.locked, open: pre.open, specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
282
+ ok: true, origin: pre.origin, card, from: pre.from, note: pre.note,
283
+ // ONE door (v1.101): `green` = the origin earned a real green (an imported job counts), so an unchanged job reuses the signed plan;
284
+ // `nextName` = the Job name box once a job box changes (the box shows the origin's name until then). Nothing is locked.
285
+ green: pre.origin.importId !== undefined || pre.outcome === 'green' || pre.outcome === 'already-green',
286
+ nextName: nextFreeJobName(pre.origin.job, opts.runJobNames ? opts.runJobNames() : [], { jobsDir: opts.jobsDir }),
287
+ specHash: pre.specHash, workflowKey: pre.workflowKey, trackRecord: pre.trackRecord,
223
288
  line: pre.line, specNamesModel: pre.specNamesModel, specWorker: pre.specWorker,
289
+ // T Part 3: `mode=schedule` also returns the job's current schedule (data only) for the When field's prefill; Part 7 adds the
290
+ // card's own words (`scheduleCard`: now / refusal / the button table). A reuse never touches a schedule (v1.100).
291
+ ...(sp.get('mode') === 'schedule' && opts.schedules ? { schedule: opts.schedules.read(pre.origin.job), scheduleCard: opts.schedules.cardFor(pre.origin.job) } : {}),
224
292
  });
225
293
  return true;
226
294
  }
@@ -236,11 +304,23 @@ export function createAuthorRoutes(opts) {
236
304
  return true;
237
305
  }
238
306
 
307
+ // ── T Part 3: the schedule door, two clicks, both $0 (no drafter, no run, no provider call). Click 1 signs and
308
+ // writes nothing; click 2 carries the hash it was shown. `when: null` cancels (never refused). Placed with the
309
+ // other exact matches, before the session regex below.
310
+ if (pathname === '/api/author/schedule-prepare' || pathname === '/api/author/schedule') {
311
+ if (!opts.schedules) { send(404, { ok: false, error: 'not found' }); return true; }
312
+ if (req.method !== 'POST') { send(405, { ok: false, error: 'POST only' }); return true; }
313
+ const r = pathname.endsWith('-prepare') ? opts.schedules.prepare(body ?? {}) : opts.schedules.set(body ?? {});
314
+ send(r.status, r.body);
315
+ return true;
316
+ }
317
+
239
318
  // A refreshed page re-attaches to the one live (non-terminal) session: its id and state (card, messages, steps),
240
319
  // or `null`. The server's session map is the only truth; nothing is stored page-side.
241
320
  if (pathname === '/api/author/live') {
242
321
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
243
322
  const live = [...sessions.values()].find((s) => !TERMINAL_PHASES.has(s.state.phase));
323
+ if (live) settleFetchingStep(live.state);
244
324
  send(200, { ok: true, sessionId: live ? live.state.id : null, state: live ? live.state : null });
245
325
  return true;
246
326
  }
@@ -250,11 +330,15 @@ export function createAuthorRoutes(opts) {
250
330
  if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return true; }
251
331
  const card = body ?? {};
252
332
  const rows = rowsForHome(keysHome(opts.home), envNow());
253
- // Reuse workflow — started from a green run or an imported job: only Source, Destination and the two caps may
254
- // differ from the signed workflow. The SERVER refuses any other change (the page greys those boxes, but is
255
- // never trusted); the spec it signs is a copy of the origin's with just those four set. Nothing is drafted.
333
+ // Reuse workflow, ONE DOOR (v1.101): every box is open; the CARD's job boxes (job text, check type, thinking) are compared with
334
+ // the origin run's. A GREEN origin (or an imported job) with no job box changed signs the origin's plan verbatim, under the
335
+ // origin's name, with no drafting. Any other start drafts: a changed job is a NEW workflow under the Job name box; an unchanged
336
+ // job from a run that is neither green nor already-green drafts again under the ORIGIN's name (another try of the same job). The name is decided
337
+ // here, before drafting, so the hash the person signs already covers it. The server never trusts the page.
256
338
  /** @type {{spec: any, workflowKey: string}|null} */
257
339
  let reuse = null;
340
+ /** @type {string|undefined} */
341
+ let allowName;
258
342
  const from = card.startFrom;
259
343
  const fromRunid = typeof from === 'string' ? from : (typeof from?.runid === 'string' ? from.runid : null);
260
344
  const fromImport = typeof from?.importId === 'string' ? from.importId : null;
@@ -262,42 +346,43 @@ export function createAuthorRoutes(opts) {
262
346
  const pre = opts.startFrom ? (fromImport !== null ? opts.startFrom.getImport(fromImport) : opts.startFrom.get(fromRunid)) : null;
263
347
  if (pre === null || pre === undefined) { send(404, { ok: false, error: 'no such run to reuse' }); return true; }
264
348
  if (!pre.ok) { send(409, pre); return true; }
265
- // only a run that earned a real green is a reuse source (hamr 2026-10-08) — the pickers already filter, but a
266
- // hand-made POST must not sign a reuse of an already-green, red or still-running run. Imports are green by ruling;
267
- // Edit in chat and Resume only READ start-from (GET) and never post a startFrom, so this gate never touches them.
268
- if (fromImport === null && pre.outcome !== 'green') {
269
- send(409, { ok: false, error: 'only a run that finished green can be reused — this one did not' });
270
- return true;
349
+ const changed = jobBoxesChanged(card, pre.card);
350
+ if (changed) {
351
+ // a NEW workflow: its name may not be any existing job's, the origin's included (a different job cannot pass for the old one)
352
+ if (typeof card.jobName === 'string' && (card.jobName === pre.origin.job || (opts.runJobNames && opts.runJobNames().includes(card.jobName)))) {
353
+ send(400, { ok: false, error: `A job named "${card.jobName}" already exists and is a different job. Pick another name.` });
354
+ return true;
355
+ }
356
+ } else {
357
+ // a scheduled run's green waits for the person's Accept (quarantine): its plan is neither reused nor re-drafted until then
358
+ if (pre.outcome === 'green-held') { send(409, { ok: false, error: 'This run was started by a schedule and is waiting for your Accept. Accept it first, then it can be reused.' }); return true; }
359
+ card.jobName = pre.origin.job; // an unchanged job keeps its name; a typed one is ignored
360
+ if (fromImport !== null || pre.outcome === 'green' || pre.outcome === 'already-green') {
361
+ const rv = validateReuseCard(card, { rows });
362
+ if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
363
+ const spec = buildReuseSpec(pre.spec, card, rows);
364
+ // validateJob can THROW (not only red) on a saved job the guard-battery lookup no longer admits: same plain words, never a raw 500
365
+ /** @type {ReturnType<typeof validateJob>|null} */
366
+ let jv = null;
367
+ try { jv = validateJob(spec, { shellCapUsd: spec.budgetUsd }); } catch { jv = null; }
368
+ // RULED 2026-10-15: an old run's saved job that fails today's checks gets plain words; the raw red codes ride in `reds`, which the page never shows
369
+ if (jv === null || !jv.ok) { send(400, { ok: false, error: OLD_JOB_REUSE_WORDS, reds: jv === null ? [] : jv.reds.map((r) => `${r.code} at ${r.path}`) }); return true; }
370
+ reuse = { spec, workflowKey: pre.workflowKey };
371
+ } else allowName = pre.origin.job;
271
372
  }
272
- const locked = lockedFieldChanged(card, pre.card);
273
- if (locked !== null) { send(400, { ok: false, error: `${locked} is locked on a reused workflow — Clear the card to change it` }); return true; }
274
- const rv = validateReuseCard(card, { rows });
275
- if (!rv.ok) { send(400, { ok: false, error: rv.error }); return true; }
276
- const spec = buildReuseSpec(pre.spec, card, rows);
277
- const jv = validateJob(spec, { shellCapUsd: spec.budgetUsd });
278
- if (!jv.ok) { send(400, { ok: false, error: `the spec this reuse would sign is not valid (${jv.reds.map((r) => `${r.code} at ${r.path}`).join('; ')})` }); return true; }
279
- reuse = { spec, workflowKey: pre.workflowKey };
280
373
  }
281
374
  if (reuse === null) {
282
- const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows });
375
+ const v = validateJobCard(card, { jobsDir: opts.jobsDir, rows, ...(allowName !== undefined ? { allowName } : {}) });
283
376
  if (!v.ok) { send(400, { ok: false, error: v.error }); return true; }
284
377
  }
285
378
  // P7: the Inputs are proven at $0 before a session (so before any token): line 1 the repo, lines 2+ tracked files inside it.
286
379
  // A miss is a plain 400 naming the line, shown under the card exactly like every other refused start.
287
380
  const ip = parseInputs(card.inputs);
288
381
  if (!ip.ok) { send(400, { ok: false, error: ip.error }); return true; }
289
- // Part 1a: a rubric job whose first input is a local FILE has no repo to prove inputs against; the session proves every
290
- // line itself (`proveRubricInputs`), at $0, before any model call
291
- const filesJob = reuse === null && card?.checkType === 'rubric' && looksLikeRubricInput(ip.inputs[0].value);
292
- (filesJob ? Promise.resolve(/** @type {{ok: true}} */ ({ ok: true })) : proveInputFiles(ip.inputs)).then((proof) => {
293
- if (!proof.ok) { send(400, { ok: false, error: proof.error }); return; }
294
- if (hasLiveSession()) { send(409, { ok: false, error: 'an authoring session is already live — one at a time' }); return; }
295
- const session = createSession(cardFields(card), {
296
- env: envNow(), sessionsRoot: opts.sessionsRoot, ...(opts.home !== undefined ? { home: opts.home } : {}), ...(reuse ? { reuse } : {}),
297
- });
298
- sessions.set(session.id, session);
299
- send(200, { ok: true, sessionId: session.id, reuse: reuse !== null, state: session.state });
300
- }).catch((e) => { send(500, { ok: false, error: `could not check the inputs: ${/** @type {Error} */ (e)?.message ?? e}` }); });
382
+ startSession(card, reuse, ip.inputs).then((r) => {
383
+ if (!r.ok) { send(r.status, { ok: false, error: r.error }); return; }
384
+ send(200, { ok: true, sessionId: r.session.id, reuse: reuse !== null, state: r.session.state });
385
+ });
301
386
  return true;
302
387
  }
303
388
 
@@ -309,6 +394,7 @@ export function createAuthorRoutes(opts) {
309
394
 
310
395
  if (sub === null) {
311
396
  if (req.method !== 'GET') { send(405, { ok: false, error: 'GET only' }); return true; }
397
+ settleFetchingStep(session.state);
312
398
  send(200, { ok: true, state: session.state });
313
399
  return true;
314
400
  }
@@ -336,7 +422,7 @@ export function createAuthorRoutes(opts) {
336
422
  }
337
423
  if (sub === 'sign') {
338
424
  const claimedHash = String(body?.specHash ?? '');
339
- const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home });
425
+ const r = signRun(session, claimedHash, { env: envNow(), spawnFn, bareloopBin, sessionsRoot: opts.sessionsRoot, home: opts.home, ...(opts.schedules ? { schedules: opts.schedules } : {}) });
340
426
  send(r.ok ? 200 : 400, r);
341
427
  return true;
342
428
  }
@@ -344,31 +430,14 @@ export function createAuthorRoutes(opts) {
344
430
  return true;
345
431
  }
346
432
 
347
- return { handle, sessions };
433
+ return {
434
+ handle, sessions, headless, startSession, envNow, spawnFn, bareloopBin,
435
+ /** a headless session the starter has removed from disk leaves its map too @param {string} id */
436
+ forgetHeadless: (id) => { headless.delete(id); },
437
+ };
348
438
  }
349
439
 
350
- /**
351
- * The panel's OWN 2-decimal money render for a chat line — hamr's ruling
352
- * 2026-09-28 (2nd addendum, panel money 2-decimals): a positive amount below
353
- * a cent reads `<$0.01`, never `$0.00` (a real cost must never read as
354
- * free). This is the server-side (Node, chat-text) sibling of
355
- * `src/panel/index.html`'s own inline `panelMoney` — the two can't share
356
- * code (one runs in the browser as inlined script, the other in Node), the
357
- * same duplication `src/replay.js`'s `money()`/`index.html`'s `money()`
358
- * already carry, so both apply the identical rule rather than drift.
359
- * @param {number} n a non-negative dollar amount
360
- * @returns {string}
361
- */
362
- export function panelMoney2(n) {
363
- if (n > 0 && n < 0.01) return '<$0.01';
364
- // two-step rounding (matching index.html's own panelMoney): clean to
365
- // 6-decimal precision first (the same precision src/text.js's tallyCalls
366
- // sums to) before rounding to cents, so a summed float landing just under
367
- // an exact cent boundary is never mis-rounded.
368
- const clean = Math.round(n * 1e6) / 1e6;
369
- const cents = Math.round(clean * 100 + 1e-6);
370
- return `$${(cents / 100).toFixed(2)}`;
371
- }
440
+ export { panelMoney2 };
372
441
 
373
442
  /**
374
443
  * THE ONLY function in this file (or anywhere in the panel) that spawns a
@@ -378,7 +447,7 @@ export function panelMoney2(n) {
378
447
  * it to sign"), and this is where it is checked, not trusted.
379
448
  * @param {ReturnType<typeof createSession>} session
380
449
  * @param {string} claimedHash
381
- * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string}} o
450
+ * @param {{env: any, spawnFn: typeof realSpawn, bareloopBin: string, sessionsRoot?: string, home?: string, reviewDoor?: boolean, onSpawnError?: (e: Error) => void, onExit?: (code: number|null) => void}} o
382
451
  * @returns {{ok: boolean, error?: string, job?: string}}
383
452
  */
384
453
  export function signRun(session, claimedHash, o) {
@@ -430,6 +499,8 @@ export function signRun(session, claimedHash, o) {
430
499
  'systemd-inhibit', '--why=bareloop panel run',
431
500
  process.execPath, o.bareloopBin,
432
501
  'run-u', '--spec', session.state.resolvedSpecPath, '--approve', session.state.specHash,
502
+ // T (F.1): a scheduled run opens the review door even when deterministic, so its green waits for the person's Accept
503
+ ...(o.reviewDoor === true ? ['--review-door'] : []),
433
504
  ...(draftSpentUsd !== null ? ['--draft-spent-usd', String(draftSpentUsd), ...(draftIncomplete ? ['--draft-spend-incomplete'] : [])] : []),
434
505
  ];
435
506
  /** @type {number|null} */
@@ -439,6 +510,8 @@ export function signRun(session, claimedHash, o) {
439
510
  detached: true, stdio: ['ignore', logFd ?? 'ignore', logFd ?? 'ignore'], env: o.env,
440
511
  });
441
512
  if (typeof child?.unref === 'function') child.unref();
513
+ if (o.onSpawnError && typeof child?.once === 'function') child.once('error', o.onSpawnError);
514
+ if (o.onExit && typeof child?.once === 'function') child.once('exit', o.onExit);
442
515
  session.state.phase = 'signed';
443
516
  // hamr 2026-10-05: the sign shows as the last step of the one progress list, never as a thread bubble
444
517
  // hamr 2026-10-05 (option A): the drafting-spend line is that step's detail — nothing when there was no drafting spend (a Reuse)
@@ -446,5 +519,10 @@ export function signRun(session, claimedHash, o) {
446
519
  ? `drafting spent ${draftIncomplete ? 'at least ' : ''}${panelMoney2(draftSpentUsd)} (folds out of the run's own cap)`
447
520
  : '';
448
521
  if (Array.isArray(session.state.steps)) session.state.steps.push({ id: 'signed', label: STEP_LABELS.signed, status: 'done', detail: draftLine });
522
+ // PRD v1.106 Part 5 Q2: a run with a web input fetches the page again before its log exists (3-5 s of silence): say so, next to the sign step
523
+ const sinceIso = new Date().toISOString();
524
+ if (Array.isArray(session.state.steps) && webFetchState(join(session.state.outDir, 'source-seed', 'source.json'), sinceIso).hasWeb) {
525
+ session.state.steps.push({ id: 'fetching', label: STEP_LABELS.fetching, status: 'running', since: sinceIso });
526
+ }
449
527
  return { ok: true, job: session.state.resolvedSpecPath };
450
528
  }
@@ -53,6 +53,7 @@ import { closeJudges } from '../kinds.js';
53
53
  import { RUBRIC_LANG } from '../authoring.js';
54
54
  import { rubricGoal, reconcileRubricPlan } from '../rubricstep.js';
55
55
  import { redactSecrets } from '../validate.js';
56
+ import { isDropIt, isAgree } from '../pagemap.js';
56
57
  import { tallyCalls } from '../text.js';
57
58
  import { writeDraftSpend, appendDraftLog } from '../draftspend.js';
58
59
  import { PLAIN_PROPOSAL_STOPS, proposalStopText, signingStopText, draftStopText, redsRecord, declarationRecord, calibrationSummary, tryRefusedText } from '../authorreadout.js';
@@ -85,6 +86,7 @@ export const STEP_LABELS = Object.freeze({
85
86
  gates: 'checking signing gates',
86
87
  hash: 'generating hash',
87
88
  signed: 'signed hash',
89
+ fetching: 'fetching the page…',
88
90
  });
89
91
 
90
92
  /**
@@ -198,6 +200,25 @@ export function jobNameTaken(name, opts = {}) {
198
200
  } catch { return false; }
199
201
  }
200
202
 
203
+ /**
204
+ * The next free name for a NEW workflow made from `origin` (RULED 2026-10-15, v1.101): `<base>-N`, N the next free number from 2.
205
+ * Base: when the origin ends in `-<digits>` AND the part before it is itself a job (in the run list or jobs/), that part (so `x-2`
206
+ * suggests `x-3`, never `x-2-2`); else the origin's own name. Free = not a run-list job and not {@link jobNameTaken}.
207
+ * @param {string} origin
208
+ * @param {Iterable<string>} runJobs job names in the panel's run list
209
+ * @param {{jobsDir?: string}} [opts] test seam for {@link jobNameTaken}
210
+ * @returns {string}
211
+ */
212
+ export function nextFreeJobName(origin, runJobs, opts = {}) {
213
+ const used = new Set(runJobs);
214
+ const m = /^(.+)-\d+$/.exec(origin);
215
+ const base = m && (used.has(m[1]) || jobNameTaken(m[1], opts)) ? m[1] : origin;
216
+ for (let n = 2; ; n += 1) {
217
+ const name = `${base}-${n}`;
218
+ if (!used.has(name) && !jobNameTaken(name, opts)) return name;
219
+ }
220
+ }
221
+
201
222
  /** the form's fields, in card order — what `card.json` stores, verbatim (P5 item 3) */
202
223
  export const CARD_FIELDS = Object.freeze(['jobName', 'checkType', 'provider', 'model', 'thinking', 'jobText', 'inputs', 'destination', 'capUsd', 'maxWallMs']);
203
224
 
@@ -225,30 +246,25 @@ export function cardFields(card) {
225
246
  }
226
247
 
227
248
  /**
228
- * REUSE WORKFLOW (hamr 2026-10-03, replaces P5 item 3's same-job rule; Model opened 2026-10-04): a reuse card has exactly
229
- * FIVE open boxes — Inputs, Destination, Model, $ cap, Time cap — and every other box is the signed workflow, shown greyed and never
230
- * editable. The page greys them; THIS is what refuses (the server never trusts the page). Changing any locked box
231
- * is a different job: Clear the card, which drafts.
249
+ * REUSE WORKFLOW, ONE DOOR (RULED 2026-10-15, v1.101; replaces v1.88's five open boxes). Every box on the card is open. What decides
250
+ * the path is the CARD compared with the origin run's card, never the drafted spec: the JOB boxes are the job text (numbered lines,
251
+ * `~` rules, `~ PASS`/`~ FAIL`), the check type and the thinking tick. The RUN boxes (Inputs, Destination, Model, $ cap, Time cap)
252
+ * never decide it. A green origin with no job box changed reuses the signed plan verbatim (no drafting); anything else drafts.
232
253
  */
233
- export const REUSE_OPEN_FIELDS = Object.freeze(['inputs', 'destination', 'model', 'capUsd', 'maxWallMs']);
234
- /** every card field that is NOT open on a reuse card, in card order */
235
- export const REUSE_LOCKED_FIELDS = Object.freeze(CARD_FIELDS.filter((f) => !REUSE_OPEN_FIELDS.includes(f)));
236
- const REUSE_LABELS = Object.freeze(/** @type {Record<string, string>} */ ({
237
- jobName: 'Job name', checkType: 'Check type', jobText: 'The job',
238
- }));
254
+ export const JOB_BOXES = Object.freeze(['jobText', 'checkType', 'thinking']);
239
255
 
240
256
  /**
241
- * The first LOCKED box on `card` that differs from what the signed workflow prefilled (`origin`), or `null` when
242
- * every locked box is untouched. Blank and absent read alike; whitespace is not compared.
257
+ * Did any JOB box on `card` change from what the origin run prefilled (`origin`)? Whitespace is not compared (a one-line input drops
258
+ * a text's newlines); an absent thinking tick reads as off.
243
259
  * @param {Record<string, any>} card the form as it was submitted
244
260
  * @param {Record<string, any>} origin the form as the server prefilled it
245
- * @returns {string|null} the offending field's label
261
+ * @returns {boolean}
246
262
  */
247
- export function lockedFieldChanged(card, origin) {
248
- // whitespace is ignored: a one-line <input> drops a goal's newlines, and the signed copy never takes text from the card anyway
249
- const norm = (/** @type {any} */ v) => (v === undefined || v === null ? '' : (typeof v === 'string' ? v.replace(/\s+/g, '') : v));
250
- const bad = REUSE_LOCKED_FIELDS.find((f) => norm(card?.[f]) !== norm(origin?.[f]));
251
- return bad === undefined ? null : (REUSE_LABELS[bad] ?? bad);
263
+ export function jobBoxesChanged(card, origin) {
264
+ const text = (/** @type {any} */ v) => (typeof v === 'string' ? v.replace(/\s+/g, '') : '');
265
+ return text(card?.jobText) !== text(origin?.jobText)
266
+ || (card?.checkType === 'rubric') !== (origin?.checkType === 'rubric')
267
+ || (card?.thinking === true) !== (origin?.thinking === true);
252
268
  }
253
269
 
254
270
  /**
@@ -267,7 +283,9 @@ export function lockedFieldChanged(card, origin) {
267
283
  */
268
284
  export function buildReuseSpec(originSpec, card, rows) {
269
285
  const spec = JSON.parse(JSON.stringify(originSpec));
270
- spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
286
+ // an ABSOLUTE Destination is a per-run output folder (a plain-files job): it is never part of the signed spec, and the spec's own
287
+ // fence (inside the run's tree) stays as signed. A repo job's Destination is the fence and is relative (an absolute one is refused).
288
+ if (!isAbsoluteDestination(String(card.destination ?? '').trim())) spec.writeScope = String(card.destination ?? '').split(/[,\n]/).map((x) => x.trim()).filter(Boolean);
271
289
  const ip = parseInputs(card.inputs);
272
290
  if (ip.ok) spec.inputs = ip.inputs; else delete spec.inputs;
273
291
  spec.budgetUsd = card.capUsd;
@@ -310,6 +328,27 @@ const isAbsoluteDestination = (destination) => /^(\/|[a-zA-Z]:[\\/])/.test(desti
310
328
 
311
329
  export { nonRepoSourceMessage };
312
330
 
331
+ /** the refusal for a plain-files rubric job whose Destination is not an absolute folder (ends with the words the session start shows) */
332
+ export const FILES_DESTINATION_WORDS = 'Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.';
333
+
334
+ /**
335
+ * THE ONE Destination check of a session start, at $0, before any session, copy or model call. The session's own pipeline, the HTTP
336
+ * start (`startSession`), the schedule's two clicks and a scheduled fire's `wouldAccept` all call THIS (never a copy of it), so a start
337
+ * that would be refused for its Destination is refused before anything is made.
338
+ * @param {{inputs?: unknown, destination?: unknown, checkType?: unknown}} card the Inputs text (line 1 = the source), the Destination and the check type
339
+ * @returns {string|null} the plain refusal, or null when the Destination is fine
340
+ */
341
+ export function startDestinationProblem(card) {
342
+ const ip = parseInputs(card?.inputs);
343
+ const source = ip.ok ? (ip.inputs[0]?.value ?? '') : '';
344
+ const destination = typeof card?.destination === 'string' ? card.destination : '';
345
+ const isRepoLike = looksLikeRepoSource(source);
346
+ const repo = repoDestinationProblem(destination, isRepoLike);
347
+ if (repo !== null) return repo;
348
+ if (card?.checkType === 'rubric' && !isRepoLike && !isAbsoluteDestination(destination)) return FILES_DESTINATION_WORDS;
349
+ return null;
350
+ }
351
+
313
352
  /**
314
353
  * The ONE Destination rule for a REPO source (fresh card and Reuse card alike): there Destination is the write fence,
315
354
  * relative to the repo, so an absolute path is refused — at $0, before any copy or model call. A FOLDER source (or a
@@ -327,14 +366,14 @@ export function repoDestinationProblem(destination, isRepoLike) {
327
366
  * creates a session or spends anything — never inside the async pipeline,
328
367
  * so a bad card refuses synchronously, in the same HTTP response.
329
368
  * @param {any} card
330
- * @param {{jobsDir?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card
369
+ * @param {{jobsDir?: string, allowName?: string, rows?: readonly import('../providerrows.js').KeyRow[]}} [opts] `rows` = the Settings rows (the Model menu); jobsDir is a server-side test seam — NEVER read off the client card; `allowName` = the origin job's own name on an unchanged red Reuse (it is the same job, not a collision)
331
370
  * @returns {{ok: true}|{ok: false, error: string}}
332
371
  */
333
372
  export function validateJobCard(card, opts = {}) {
334
373
  if (!card || typeof card !== 'object') return { ok: false, error: 'missing job card' };
335
374
  const { jobName, checkType, model, destination, capUsd } = card;
336
375
  if (!isSlug(jobName)) return { ok: false, error: 'Job name must be a kebab-case slug (letters, digits, dashes)' };
337
- if (jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
376
+ if (jobName !== opts.allowName && jobNameTaken(jobName, { jobsDir: opts.jobsDir })) {
338
377
  return { ok: false, error: `a job named "${jobName}" already exists — P3 is new jobs only (Q4=A)` };
339
378
  }
340
379
  if (checkType !== 'deterministic' && checkType !== 'rubric') return { ok: false, error: 'Check type must be deterministic or rubric' };
@@ -502,7 +541,20 @@ export function createSession(card, deps = {}) {
502
541
  * @param {{kind: string, [k: string]: any}} step
503
542
  * @returns {Promise<string|null>}
504
543
  */
505
- const ask = (step) => new Promise((resolveFn) => {
544
+ const ask = (step) => new Promise((resolveFn0) => {
545
+ // PRD v1.106 Part 5 Q1: every question the person answers (the confirm card's, the early look ask, the last-try ask) is one `human` row in the
546
+ // drafting log, with the answer secret-masked BEFORE it is written. Written only when the answer exists (a blank one re-asks and leaves nothing).
547
+ const resolveFn = step.kind !== 'answer' ? resolveFn0 : (/** @type {string|null} */ value) => {
548
+ const said = value === null ? '' : redactSecrets(String(value).trim());
549
+ if (said !== '' && typeof step.question === 'string') {
550
+ const running = state.steps.findLastIndex((/** @type {any} */ x) => x.status === 'running');
551
+ appendDraftLog(outDir, {
552
+ kind: 'human', no: running === -1 ? Math.max(0, state.steps.length - 1) : running, source: typeof step.source === 'string' ? step.source : 'confirm',
553
+ question: redactSecrets(step.question), answer: said, ...(Number.isInteger(step.line) ? { line: step.line } : {}), at: new Date().toISOString(),
554
+ });
555
+ }
556
+ resolveFn0(value);
557
+ };
506
558
  state.pendingAsk = step;
507
559
  resolvePending = resolveFn;
508
560
  if (step.kind === 'language') say('bot', `${step.field?.prompt ?? 'Which language is this job about?'} (${(step.candidates ?? []).join(', ')})`);
@@ -602,6 +654,8 @@ export function createSession(card, deps = {}) {
602
654
  kind: 'call', no: callNo, step: state.steps[callNo]?.id ?? null, label: call.label, model: draftIdentity?.model ?? null,
603
655
  thinking: draftThinkingPlan === null ? null : draftThinkingPlan.mode, thinkingSupport: draftThinkingPlan === null ? null : draftThinkingPlan.support,
604
656
  ...(call.thinkingStatus === undefined ? {} : { thinkingStatus: call.thinkingStatus }),
657
+ // idea A core: the page-word map's scrubbed answer (src/pagemap.js) - unsigned, kept so a run can be replayed at $0
658
+ ...(typeof call.raw === 'string' ? { raw: call.raw } : {}),
605
659
  costUsd: call.costUsd ?? null, unpricedRounds: call.unpricedRounds ?? 0, at: nowIso,
606
660
  });
607
661
  try {
@@ -685,7 +739,7 @@ export function createSession(card, deps = {}) {
685
739
  // line is one file, frozen by `proveRubricInputs`; there is no repo, no language and no scout (docs/product/ITEM33-BUILD.md)
686
740
  const RUBRIC_FILES = card.checkType === 'rubric' && !isRepoLike;
687
741
  // hamr's ruling B (2026-10-05): ONE rule for both a fresh card and a reuse card, at $0, before the copy and any model call
688
- const destProblem = repoDestinationProblem(card.destination, isRepoLike);
742
+ const destProblem = startDestinationProblem(card);
689
743
  if (destProblem !== null) { refuse(destProblem); return; }
690
744
 
691
745
  state.phase = 'preparing-source';
@@ -707,10 +761,6 @@ export function createSession(card, deps = {}) {
707
761
  // fence read as a directory and refused as "not absolute" — hiding the real problem with the Source. A scope-shaped
708
762
  // (relative) Destination is therefore handed to the door only when the source is repo-like.
709
763
  const destIsDir = isAbsoluteDestination(card.destination);
710
- if (RUBRIC_FILES && !destIsDir) {
711
- refuse('Destination must be the full path of a folder, like /home/me/out — the finished files are delivered there. Nothing spent.');
712
- return;
713
- }
714
764
  /** @type {any} */
715
765
  let prep;
716
766
  if (RUBRIC_FILES) {
@@ -945,7 +995,6 @@ export function createSession(card, deps = {}) {
945
995
  model: modelChoice.name,
946
996
  // the "Thinking on" tick is signed ONLY when ticked (unticked writes no field: the hash stays what it would have been)
947
997
  ...(card.thinking === true && draftThinkingPlan?.support !== false ? { thinking: true } : {}),
948
- cadence: { unit: 'day', every: 1 },
949
998
  budgetUsd: card.capUsd,
950
999
  ...(typeof card.maxWallMs === 'number' ? { maxWallMs: card.maxWallMs } : {}),
951
1000
  writeScope,
@@ -989,27 +1038,34 @@ export function createSession(card, deps = {}) {
989
1038
  // ask path as every other (the chat box + Send); the answer is REQUIRED (a blank one asks again, there is no skip) and is
990
1039
  // added WORD FOR WORD as a `~` rule on the job line it names — never rewritten. The lines are mutated in place, so the
991
1040
  // signed `jobLines`, the answers text and the plan card all read the same words.
992
- const answerQuestions = async (/** @type {{questions: {line: number, question: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
1041
+ const answerQuestions = async (/** @type {{questions: {line: number, question: string, rule?: number, source?: string, ask?: string, reading?: string}[], dropped: {reason: string, raw: any}[], confirmed: any}} */ o) => {
993
1042
  if (o.dropped.length) appendDraftLog(outDir, { kind: 'questions-dropped', no: Math.max(0, state.steps.length - 1), dropped: o.dropped, at: new Date().toISOString() });
994
1043
  const total = o.questions.length;
1044
+ /** @type {{line: number, rule: number}[]} */
1045
+ const left = [];
995
1046
  for (let k = 0; k < total; k += 1) {
996
1047
  const q = o.questions[k];
997
1048
  let text = '';
998
1049
  for (;;) {
999
- const a = await ask({ kind: 'answer', index: k + 1, total, question: q.question, line: q.line });
1050
+ const a = await ask({ kind: 'answer', source: typeof q.source === 'string' ? q.source : 'try-3', index: k + 1, total, question: q.question, line: q.line });
1000
1051
  if (a === null) return null;
1001
1052
  // one `~` line is one line: the person's own words, newlines folded to spaces, nothing else touched
1002
1053
  text = String(a).replace(/\s*\n\s*/g, ' ').trim();
1003
1054
  if (text) break;
1004
1055
  say('bot', 'An answer is needed here — there is no skip. Type it in the box below and press Send.');
1005
1056
  }
1057
+ // PRD v1.107 Part 5b: a code-forced look question about a `~` rule. A can't-check ask answered "drop it" adds NO rule; the caller puts that
1058
+ // rule on the look's Not checked list. A reading ask answered "agree" adds the code's reading, in plain words, as the `~` rule (so the
1059
+ // bound is now the person's own words); anything else typed is the person's own rule, word for word.
1060
+ if (q.source === 'look' && q.ask === 'reading' && typeof q.reading === 'string' && isAgree(text)) text = q.reading;
1061
+ if (q.source === 'look' && q.ask === 'cant-check' && Number.isInteger(q.rule) && isDropIt(text)) { left.push({ line: q.line, rule: /** @type {number} */ (q.rule) }); continue; }
1006
1062
  const line = jobLines.find((l) => l.n === q.line);
1007
1063
  if (line) { line.rule = line.rule ? `${line.rule}; ${text}` : text; line.rules.push(text); }
1008
1064
  }
1009
1065
  draft.jobLines = signedJobLines(jobLines);
1010
1066
  answers[1] = renderJobLines(jobLines);
1011
- state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: 'Your answers are in as ~ rules on those job lines. Drafting again.' });
1012
- return { jobLines, answers };
1067
+ state.messages.push({ ...planMessage(o.confirmed ?? {}, jobLines), text: left.length === total ? 'Noted: those stay Not checked. Drafting.' : 'Your answers are in as ~ rules on those job lines. Drafting again.' });
1068
+ return { jobLines, answers, left };
1013
1069
  };
1014
1070
 
1015
1071
  state.phase = 'drafting';